Add the content service and MCP server (phase 2)

services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:10:38 +00:00
parent a2f723ca2d
commit 3f646048a2
23 changed files with 3482 additions and 38 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* The rules every change runs, in a fixed order. Pure functions: the same
* change against the same accepted state always gets the same answer, and no
* model decides anything.
*
* The rules live here in code, not in the vault, so no agent can loosen them.
*/
import { notePath, typeDef, type CheckedNote } from "@gk/schema";
import type { Author, HeldReason, StoredNote } from "./store.ts";
export interface RuleSettings {
/** A note with at least this much body text… */
textLossMinChars: number;
/** …is held when an edit keeps less than this share of it. */
textLossKeepRatio: number;
/** An agent making more changes than this inside the window is held. */
agentBatchLimit: number;
agentBatchWindowMinutes: number;
/** Note types an agent may publish without review. */
agentAutoPublish: string[];
/** Email addresses that may appear in notes. */
allowedEmails: string[];
}
export const defaultRules: RuleSettings = {
textLossMinChars: 400,
textLossKeepRatio: 0.5,
agentBatchLimit: 10,
agentBatchWindowMinutes: 10,
agentAutoPublish: ["status", "library-item"],
allowedEmails: [],
};
export interface Proposed {
path: string;
name: string;
slug: string;
text: string;
author: Author;
checked: CheckedNote;
}
export interface RuleContext {
/** The accepted version at this path, if any. */
current: StoredNote | null;
/** Another live note with the same file name, if any. */
nameTakenBy: string | null;
/** Another live note of the same type with the same slug, if any. */
slugTakenBy: string | null;
/** The agent's changes inside the batch window, before this one. */
recentAgentChanges: number;
settings: RuleSettings;
}
export type Verdict = { decision: "accept" } | { decision: "hold"; reasons: HeldReason[] };
/** Strings that look like credentials. Matching is on new text only. */
const SECRET_PATTERNS: [string, RegExp][] = [
["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
["an AWS key", /\bAKIA[0-9A-Z]{16}\b/],
["a GitHub token", /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/],
["a Slack token", /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/],
["an API key", /\bsk-[A-Za-z0-9_-]{20,}\b/],
["a bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/],
["a password or key value", /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["']?[^\s"']{8,}/i],
];
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
const PHONE = /(?:\+?1[\s.-]?)?\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g;
function newMatches(re: RegExp, text: string, before: string): string[] {
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
const g = new RegExp(re.source, flags);
const old = new Set(before.match(g) ?? []);
return [...new Set(text.match(g) ?? [])].filter((m) => !old.has(m));
}
const bodyOf = (text: string) => text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
export function evaluate(p: Proposed, ctx: RuleContext): Verdict {
const reasons: HeldReason[] = [];
const { current, settings } = ctx;
const live = current && !current.removed ? current : null;
// 1. The note must be valid. Nothing else is judged on a broken note.
if (!p.checked.ok) {
return {
decision: "hold",
reasons: [
{
code: "invalid",
message: p.checked.problems.map((x) => (x.field ? `${x.field}: ${x.message}` : x.message)).join("; "),
},
],
};
}
const data = p.checked.data;
// 2. Settings notes live at one fixed path, so there is only ever one.
if (typeDef(p.checked.type)?.singleton && notePath(data) !== p.path) {
reasons.push({ code: "misplaced", message: `A ${p.checked.type} note belongs at ${notePath(data)}` });
}
// 3. Names and slugs must stay unambiguous.
if (ctx.nameTakenBy) {
reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${ctx.nameTakenBy}` });
}
if (ctx.slugTakenBy) {
reasons.push({ code: "duplicate-slug", message: `Another ${p.checked.type} already uses the address "${p.slug}": ${ctx.slugTakenBy}` });
}
// 4. A large loss of text in one edit.
if (live) {
const before = bodyOf(live.text).length;
const after = bodyOf(p.text).length;
if (before >= settings.textLossMinChars && after < before * settings.textLossKeepRatio) {
reasons.push({
code: "text-loss",
message: `The text shrank from ${before} to ${after} characters in one edit`,
});
}
}
// 5. Anything that looks like a secret, or contact details from an agent.
const before = live?.text ?? "";
for (const [what, re] of SECRET_PATTERNS) {
if (newMatches(re, p.text, before).length) reasons.push({ code: "secret", message: `The note now contains what looks like ${what}` });
}
if (p.author === "agent") {
const allowed = new Set(settings.allowedEmails.map((e) => e.toLowerCase()));
const emails = newMatches(EMAIL, p.text, before).filter((e) => !allowed.has(e.toLowerCase()));
if (emails.length) reasons.push({ code: "contact", message: `An agent added an email address: ${emails.join(", ")}` });
if (newMatches(PHONE, p.text, before).length) reasons.push({ code: "contact", message: "An agent added a phone number" });
}
// 6. Agents stay inside their lane.
if (p.author === "agent") {
if (live && live.author !== "agent" && live.data.agentEdits !== true) {
reasons.push({ code: "agent-edit", message: "An agent changed a note you wrote, which needs your review" });
}
const publishing = data.publish === true && !(live?.publish ?? false);
if (publishing && !settings.agentAutoPublish.includes(p.checked.type)) {
reasons.push({ code: "agent-publish", message: `An agent wants to publish a ${p.checked.type}, which needs your approval` });
}
if (ctx.recentAgentChanges >= settings.agentBatchLimit) {
reasons.push({
code: "batch",
message: `More than ${settings.agentBatchLimit} agent changes in ${settings.agentBatchWindowMinutes} minutes`,
});
}
}
return reasons.length ? { decision: "hold", reasons } : { decision: "accept" };
}