UNPKG

@brianlovin/notion-skills

Version:

Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.

168 lines 5.72 kB
import { createHash } from "node:crypto"; import { readMultiSelect, readNumber, readRichText, readSelect, } from "./notion.js"; import { SCHEMA } from "./schema.js"; /** * Drift detection signal version. Bump when: * - The set of properties contributing to the hash changes * - Any reader for those properties changes shape * - The hash function itself changes * * On version mismatch, the manifest entry is recomputed from current * page state without flagging drift (silent rebase). v2 dropped tags * (taxonomyOnly) and added body_hash alongside props_hash. */ export const HASH_V = 3; /** * Hash over the page's behavior-affecting properties: anything that * changes how a model executes the skill. Excludes: * - title (covered by the slug, not user content drift) * - metricOnly (Installs, Published — store-managed) * - taxonomyOnly (Tags — discovery sugar, doesn't affect execution) * * Includes any non-SCHEMA Notion column the user added (surfaced as * `metadata.<column>` in SKILL.md frontmatter) — these ARE user * content, so editing them counts as drift. * * Order is fixed by SCHEMA so the hash is stable across runs. */ export function readBehaviorPropertyBag(page) { const out = {}; for (const prop of SCHEMA) { if (prop.kind === "title") continue; if (prop.metricOnly) continue; if (prop.taxonomyOnly) continue; out[prop.frontmatterKey] = readPropertyValue(page, prop); } // Metadata: hash any non-SCHEMA property (excluding derived/auto // types) so users adding custom columns get drift detection too. const schemaNames = new Set(SCHEMA.map((p) => p.notionName)); const metadata = {}; for (const [name, prop] of Object.entries(page.properties)) { if (schemaNames.has(name)) continue; if (NON_USER_PROPERTY_TYPES.has(prop.type)) continue; metadata[name] = readGenericValue(prop); } if (Object.keys(metadata).length > 0) { // Stable order by sorted keys for determinism. const sorted = {}; for (const key of Object.keys(metadata).sort()) sorted[key] = metadata[key]; out["metadata"] = sorted; } return out; } /** Property types that are derived/auto-managed and excluded from * user-content drift detection. */ const NON_USER_PROPERTY_TYPES = new Set([ "created_time", "created_by", "last_edited_time", "last_edited_by", "formula", "rollup", "relation", "unique_id", "people", "files", "verification", "button", ]); function readGenericValue(prop) { switch (prop.type) { case "rich_text": { const rt = prop.rich_text; return (rt ?? []).map((r) => r.plain_text ?? "").join(""); } case "select": { const sel = prop.select; return sel?.name ?? null; } case "multi_select": { const items = prop.multi_select; return (items ?? []) .map((opt) => opt.name) .filter((s) => !!s) .sort(); } case "number": return typeof prop.number === "number" ? prop.number : null; case "checkbox": return prop.checkbox === true; case "url": case "email": case "phone_number": return prop[prop.type] ?? null; case "date": { const d = prop.date; if (!d?.start) return null; return d.end ? `${d.start}/${d.end}` : d.start; } default: return null; } } function readPropertyValue(page, prop) { switch (prop.kind) { case "rich_text": case "list_text": return readRichText(page.properties, prop.notionName); case "select": return readSelect(page.properties, prop.notionName); case "multi_select": return readMultiSelect(page.properties, prop.notionName).slice().sort(); case "number": return readNumber(page.properties, prop.notionName); case "checkbox": case "title": return undefined; } } function hash(s) { return createHash("sha256").update(s).digest("hex").slice(0, 16); } export function hashBehaviorProperties(page) { return hash(JSON.stringify(readBehaviorPropertyBag(page))); } export function hashBody(body) { return hash(body); } /** * Drift signal for the WHOLE skill — parent body plus every sibling * file's content. Backward-compatible: with `files=[]`, the result * equals `hashBody(body)`, so single-file skills keep their existing * hash and don't trigger spurious drift on upgrade. * * Files are sorted by path so the hash is order-independent. Each * file is delimited by a sentinel line including its path so that * renaming a file (different path, same content) yields a different * hash. */ export function hashSkillContent(body, files) { if (files.length === 0) return hash(body); const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); const parts = [body]; for (const f of sorted) { parts.push(`\n---FILE:${f.path}---\n`); parts.push(f.content); } return hash(parts.join("")); } /** * Snapshot a page's content fingerprint. Both halves are stored on the * manifest entry and compared against the live page on every drift check. */ export function computeContentHashes(page, body) { return { hash_v: HASH_V, props_hash: hashBehaviorProperties(page), body_hash: hashBody(body), }; } //# sourceMappingURL=page-hash.js.map