@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
253 lines • 10.7 kB
JavaScript
import chalk from "chalk";
import { getScope } from "../scope.js";
import { loadManifest } from "../manifest.js";
import { NotionClient, readCheckbox, readRichText, readTitle, } from "../notion.js";
import { assertNtnInstalled } from "../ntn.js";
import { slugify } from "../convert.js";
import { withSpinner } from "./_progress.js";
import { formatRelativeTime, parseDurationToDate } from "../time.js";
import { HASH_V, hashBehaviorProperties } from "../page-hash.js";
/**
* `notion-skills feed` — what's new across your skill stores.
*
* Two buckets per source:
* - 🆕 New skills published since the window cutoff that you don't
* have installed. Discovery surface — "try this."
* - 📝 Updates to skills you do have installed. Reminder surface —
* "you should sync."
*
* Drafts (`Published=false`) are filtered out — the feed is for
* team-ready content. Window defaults to 7 days; override with
* `--since 30d`, `--since 2w`, etc.
*
* Drift detection for the 📝 bucket uses `props_hash` — the same
* primitive list/sync use. Comparing timestamps alone would fire on
* every Installs-counter bump (each install PATCHes the page, which
* bumps last_edited_time but not props_hash). The hash comparison
* makes feed trustworthy: if it says updated, sync will agree.
*
* Known false negative: body-only edits to multi-file skills. The
* parent's last_edited_time bumps but neither the parent's props_hash
* nor (per Notion's quirk) reliably its last_edited_time bumps when
* only a child page is edited. Same gap list/sync handle via slow-path
* body fetches; deferred for feed pending real-world demand.
*/
export async function feedCommand(opts) {
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
if (scope.sources.length === 0) {
throw new Error("No sources configured. Run `notion-skills source add` to link a Notion database.");
}
await assertNtnInstalled();
const sinceInput = opts.since ?? "7d";
const since = parseDurationToDate(sinceInput);
if (!since) {
throw new Error(`Invalid --since "${sinceInput}". Use forms like 7d, 30d, 2w, 12h.`);
}
const sources = opts.source
? scope.sources.filter((s) => s.key === opts.source)
: scope.sources;
if (opts.source && sources.length === 0) {
throw new Error(`Unknown source "${opts.source}". Configured: ${scope.sources.map((s) => s.key).join(", ")}.`);
}
const manifest = await loadManifest(scope.sources);
const client = new NotionClient();
const items = [];
for (const source of sources) {
const pages = await withSpinner(`Reading ${source.key}`, () => client.queryDataSource(source.data_source_id), { noteFor: (p) => `${p.length} page${p.length === 1 ? "" : "s"}` });
items.push(partitionFeed(pages, manifest, source, since));
}
if (opts.json) {
renderJson(items, since);
return;
}
renderFeed(items, since);
}
/**
* Partition a single source's pages into feed items. Pure — takes the
* already-fetched pages and decides which bucket they fall into.
*
* - "new" = created within window AND not installed AND Published=true
* - "updated" = installed AND drifted (current props_hash differs from
* the manifest's stored value) AND edited within window
*
* The 📝 bucket gates on `props_hash` rather than `last_edited_time`
* because Notion bumps `last_edited_time` on every PATCH — including
* the Installs counter that fires on every install. Hash comparison
* filters out those metric-only bumps; only edits to behavior-affecting
* properties make it through.
*
* Manifests older than the current `HASH_V` get a free pass — sync
* will rebaseline their hashes on its next run. Treating them as
* outdated would mass-false-positive across an upgrade.
*
* Drafts (`Published=false`) are dropped. Items are sorted within the
* source by activity descending (most-recent first).
*/
export function partitionFeed(pages, manifest, source, since) {
// Map page_id → manifest entry for this source. Used to look up
// both installed-ness and the stored props_hash for drift detection.
const entryByPageId = new Map();
if (manifest) {
for (const entry of Object.values(manifest.skills)) {
if (entry.source_key === source.key) {
entryByPageId.set(entry.page_id, { props_hash: entry.props_hash });
}
}
}
// Pre-HASH_V manifests have hashes from an older scheme — comparing
// them against fresh hashes would fire across the board. Sync
// rebaselines on its next run; until then, no 📝 bucket.
const hashesTrustworthy = manifest ? manifest.hash_v >= HASH_V : false;
const sinceMs = since.getTime();
const items = [];
for (const page of pages) {
if (page.archived || page.in_trash)
continue;
// Drafts are filtered out — feed is for team-ready content.
if (!readCheckbox(page.properties, "Published"))
continue;
const title = readTitle(page.properties);
if (!title)
continue;
const slug = slugify(title);
const description = readRichText(page.properties, "Description");
const createdMs = new Date(page.created_time).getTime();
const editedMs = new Date(page.last_edited_time).getTime();
const installedEntry = entryByPageId.get(page.id);
const isInstalled = installedEntry !== undefined;
// New: created within window, not installed.
if (!isInstalled && createdMs >= sinceMs) {
items.push({
source_key: source.key,
slug,
title,
description,
page_id: page.id,
created_time: page.created_time,
last_edited_time: page.last_edited_time,
kind: "new",
});
continue;
}
// Updated: installed, edited within window, AND props_hash actually
// diverges. Skip pages where last_edited_time bumped but props are
// unchanged (Installs counter bumps, Tags edits, etc.).
if (isInstalled &&
editedMs >= sinceMs &&
hashesTrustworthy &&
hashBehaviorProperties(page) !== installedEntry.props_hash) {
items.push({
source_key: source.key,
slug,
title,
description,
page_id: page.id,
created_time: page.created_time,
last_edited_time: page.last_edited_time,
kind: "updated",
});
}
}
// Most-recent activity first.
items.sort((a, b) => activityTime(b) - activityTime(a));
return { source, items };
}
function activityTime(item) {
return item.kind === "new"
? new Date(item.created_time).getTime()
: new Date(item.last_edited_time).getTime();
}
// ---------- rendering ----------
function renderFeed(perSource, since) {
const total = perSource.reduce((n, s) => n + s.items.length, 0);
console.log();
console.log(chalk.bold(formatWindowHeader(since)));
console.log();
if (total === 0) {
console.log(chalk.dim("Nothing new."));
console.log();
return;
}
for (const { source, items } of perSource) {
if (items.length === 0)
continue;
console.log(chalk.bold(source.key) + chalk.dim(` — ${source.name}`));
for (const item of items) {
const icon = item.kind === "new" ? chalk.green("🆕") : chalk.yellow("📝");
const when = formatRelativeTime(new Date(item.kind === "new" ? item.created_time : item.last_edited_time));
console.log(` ${icon} ${chalk.cyan(item.slug)} ${chalk.dim(when)}`);
if (item.kind === "new" && item.description) {
console.log(` ${chalk.dim(truncate(item.description, 80))}`);
}
}
console.log();
}
// Action summary — what the user can do with this output.
const newCount = perSource.flatMap((s) => s.items).filter((i) => i.kind === "new").length;
const updatedCount = total - newCount;
const hints = [];
if (newCount > 0)
hints.push(`Try one with ${chalk.cyan("notion-skills install <slug>")}`);
if (updatedCount > 0)
hints.push(`Pull updates with ${chalk.cyan("notion-skills sync")}`);
if (hints.length > 0) {
console.log(chalk.dim(hints.join(". ") + "."));
console.log();
}
}
/**
* Render a "since" window in human-readable form for the feed header.
* Picks the largest unit that fully contains the window (a 12-hour
* window reads "last 12 hours," not "last day"), then rounds the
* value within that unit.
*/
export function formatWindowHeader(since, now = new Date()) {
const diffMs = Math.max(0, now.getTime() - since.getTime());
const ONE_HOUR = 60 * 60_000;
const ONE_DAY = 24 * ONE_HOUR;
const ONE_WEEK = 7 * ONE_DAY;
const ONE_MONTH = 30 * ONE_DAY;
const ONE_YEAR = 365 * ONE_DAY;
if (diffMs >= ONE_YEAR) {
const y = Math.round(diffMs / ONE_YEAR);
return `Activity in the last ${y === 1 ? "year" : `${y} years`}`;
}
if (diffMs >= ONE_MONTH) {
const months = Math.round(diffMs / ONE_MONTH);
return `Activity in the last ${months === 1 ? "month" : `${months} months`}`;
}
if (diffMs >= ONE_WEEK) {
const weeks = Math.round(diffMs / ONE_WEEK);
return `Activity in the last ${weeks === 1 ? "week" : `${weeks} weeks`}`;
}
if (diffMs >= ONE_DAY) {
const days = Math.round(diffMs / ONE_DAY);
return `Activity in the last ${days === 1 ? "day" : `${days} days`}`;
}
if (diffMs >= ONE_HOUR) {
const hours = Math.round(diffMs / ONE_HOUR);
return `Activity in the last ${hours === 1 ? "hour" : `${hours} hours`}`;
}
const minutes = Math.max(1, Math.round(diffMs / 60_000));
return `Activity in the last ${minutes === 1 ? "minute" : `${minutes} minutes`}`;
}
function renderJson(perSource, since) {
const payload = {
since: since.toISOString(),
sources: perSource.map(({ source, items }) => ({
key: source.key,
name: source.name,
items,
})),
};
console.log(JSON.stringify(payload, null, 2));
}
function truncate(s, max) {
if (s.length <= max)
return s;
return s.slice(0, max - 1).trimEnd() + "…";
}
//# sourceMappingURL=feed.js.map