mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* The content service core. Every change, from the vault or from the write
|
||||
* API, goes through `propose`, which runs the rules and either accepts the
|
||||
* change into the store or holds it for review.
|
||||
*
|
||||
* The HTTP API, the MCP server, the admin page and any later editing tool all
|
||||
* call these methods; none of them write notes any other way.
|
||||
*/
|
||||
import { basename } from "node:path";
|
||||
import { checkNote, checkVault, formatNote, notePath, outgoingLinks, slugify, typeDef, type CheckedNote } from "@gk/schema";
|
||||
import { defaultRules, evaluate, type RuleSettings } from "./rules.ts";
|
||||
import type { Author, HeldChange, HeldReason, StoredNote, Store } from "./store.ts";
|
||||
import { hashText, isNotePath, type Vault } from "./vault.ts";
|
||||
|
||||
export type Outcome =
|
||||
| { status: "accepted"; path: string; note: StoredNote }
|
||||
| { status: "held"; path: string; held: HeldChange }
|
||||
| { status: "rejected"; path: string | null; reasons: HeldReason[] }
|
||||
| { status: "unchanged"; path: string };
|
||||
|
||||
export type Change =
|
||||
| { op: "create"; data: Record<string, unknown>; body?: string }
|
||||
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
|
||||
|
||||
export interface ServiceOptions {
|
||||
store: Store;
|
||||
vault: Vault;
|
||||
rules?: Partial<RuleSettings>;
|
||||
/** Injectable clock, for tests. */
|
||||
now?: () => Date;
|
||||
/** Called whenever a change is held, for notifications. */
|
||||
onHold?: (held: HeldChange) => void;
|
||||
}
|
||||
|
||||
/** How long after a note disappears a new note can still claim its old address. */
|
||||
const MOVE_WINDOW_MS = 10 * 60_000;
|
||||
|
||||
export class ContentService {
|
||||
readonly store: Store;
|
||||
readonly vault: Vault;
|
||||
readonly rules: RuleSettings;
|
||||
private readonly now: () => Date;
|
||||
private readonly onHold?: (held: HeldChange) => void;
|
||||
|
||||
constructor(o: ServiceOptions) {
|
||||
this.store = o.store;
|
||||
this.vault = o.vault;
|
||||
this.rules = { ...defaultRules, ...o.rules };
|
||||
this.now = o.now ?? (() => new Date());
|
||||
this.onHold = o.onHold;
|
||||
}
|
||||
|
||||
private iso = () => this.now().toISOString();
|
||||
private today = () => this.iso().slice(0, 10);
|
||||
|
||||
// ------------------------------------------------------------ from the vault
|
||||
|
||||
/** Read one file from the vault and take it in if it changed. */
|
||||
ingest(path: string): Outcome {
|
||||
const text = this.vault.read(path);
|
||||
if (text === null) return this.removed(path);
|
||||
const hash = hashText(text);
|
||||
if (this.store.seenHash(path) === hash) return { status: "unchanged", path };
|
||||
this.store.markSeen(path, hash);
|
||||
const current = this.store.get(path);
|
||||
if (current && !current.removed && current.hash === hash) return { status: "unchanged", path };
|
||||
return this.propose(path, text, "greg", "vault");
|
||||
}
|
||||
|
||||
/** The file is gone. The note leaves the site; its history stays. */
|
||||
removed(path: string): Outcome {
|
||||
this.store.markSeen(path, null);
|
||||
this.store.supersede(path, this.iso());
|
||||
const note = this.store.remove(path, "greg", this.iso());
|
||||
return note ? { status: "accepted", path, note } : { status: "unchanged", path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the whole folder with what was seen before. Removals go first, so
|
||||
* a note moved between folders frees its name before it is read again.
|
||||
*/
|
||||
scan(): { changed: number; held: number } {
|
||||
const present = new Set(this.vault.listNotes());
|
||||
let changed = 0;
|
||||
let held = 0;
|
||||
for (const path of this.store.seenPaths()) {
|
||||
if (!present.has(path)) {
|
||||
this.removed(path);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
for (const path of present) {
|
||||
const o = this.ingest(path);
|
||||
if (o.status !== "unchanged") changed++;
|
||||
if (o.status === "held") held++;
|
||||
}
|
||||
return { changed, held };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the write API
|
||||
|
||||
/** One change from the API, made as `author`. Accepted changes are written into the vault. */
|
||||
apply(change: Change, author: Author): Outcome {
|
||||
let data: Record<string, unknown>;
|
||||
let body: string;
|
||||
let path: string;
|
||||
|
||||
if (change.op === "create") {
|
||||
data = { ...change.data, publish: change.data.publish ?? false, created: change.data.created ?? this.today() };
|
||||
body = change.body ?? "";
|
||||
if (!typeDef(String(data.type))) return this.reject(null, "invalid", `Unknown type "${String(data.type)}"`);
|
||||
if (typeof data.title !== "string" || !data.title.trim()) return this.reject(null, "invalid", "title: A title is required");
|
||||
path = notePath(data);
|
||||
if (this.vault.exists(path) || (this.store.get(path) && !this.store.get(path)!.removed)) {
|
||||
return this.reject(path, "exists", `A note already exists at ${path}; update it instead`);
|
||||
}
|
||||
} else {
|
||||
path = change.path;
|
||||
if (!isNotePath(path) || path.startsWith("/") || path.split("/").includes("..")) {
|
||||
return this.reject(null, "invalid", "path: Not a note path inside the Site folder");
|
||||
}
|
||||
// Take in any edit made in Obsidian first, so it is never overwritten.
|
||||
this.ingest(path);
|
||||
const current = this.store.get(path);
|
||||
if (!current || current.removed) return this.reject(path, "missing", `No note at ${path}`);
|
||||
if (this.store.pendingFor(path)) {
|
||||
return this.reject(path, "waiting", "This note has a change waiting for review; decide on that first");
|
||||
}
|
||||
data = { ...current.data };
|
||||
for (const [k, v] of Object.entries(change.fields ?? {})) {
|
||||
if (k === "type") return this.reject(path, "invalid", "type: A note's type cannot change");
|
||||
if (v === null) delete data[k];
|
||||
else data[k] = v;
|
||||
}
|
||||
body = change.body ?? current.body;
|
||||
}
|
||||
|
||||
data.updated = this.today();
|
||||
data.updatedBy = author;
|
||||
const text = formatNote(data, body);
|
||||
return this.propose(path, text, author, "api");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ review
|
||||
|
||||
acceptHeld(id: number): Outcome {
|
||||
const h = this.store.heldById(id);
|
||||
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
|
||||
const checked = checkNote(h.text);
|
||||
if (!checked.ok) {
|
||||
return this.reject(h.path, "invalid", "This change is not a valid note, so it cannot go live. Fix the note in Obsidian.");
|
||||
}
|
||||
const clash = this.clashes(h.path, checked);
|
||||
if (clash.length) return { status: "rejected", path: h.path, reasons: clash };
|
||||
if (h.source === "api") {
|
||||
const onDisk = this.vault.read(h.path);
|
||||
const current = this.store.get(h.path);
|
||||
if (onDisk !== null && hashText(onDisk) !== (current && !current.removed ? current.hash : null)) {
|
||||
return this.reject(h.path, "conflict", "The note changed in Obsidian since this was proposed");
|
||||
}
|
||||
this.writeToVault(h.path, h.text);
|
||||
}
|
||||
const note = this.acceptText(h.path, h.text, checked, h.author, "accept");
|
||||
this.store.decide(id, "accepted", this.iso());
|
||||
return { status: "accepted", path: h.path, note };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a held change down. With `restore`, a held edit from Obsidian is
|
||||
* replaced in the vault by the version the site is showing.
|
||||
*/
|
||||
rejectHeld(id: number, opts: { restore?: boolean } = {}): Outcome {
|
||||
const h = this.store.heldById(id);
|
||||
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
|
||||
this.store.decide(id, "rejected", this.iso());
|
||||
const current = this.store.get(h.path);
|
||||
if (opts.restore && h.source === "vault" && current && !current.removed) this.writeToVault(h.path, current.text);
|
||||
return current ? { status: "accepted", path: h.path, note: current } : { status: "unchanged", path: h.path };
|
||||
}
|
||||
|
||||
/** Put an earlier accepted version back, in the vault and on the site. */
|
||||
rollback(path: string, rev: number): Outcome {
|
||||
const r = this.store.revision(path, rev);
|
||||
if (!r) return this.reject(path, "missing", `No revision ${rev} for ${path}`);
|
||||
const checked = checkNote(r.text);
|
||||
if (!checked.ok) return this.reject(path, "invalid", "That version no longer passes the note rules");
|
||||
const clash = this.clashes(path, checked);
|
||||
if (clash.length) return { status: "rejected", path, reasons: clash };
|
||||
this.store.supersede(path, this.iso());
|
||||
this.writeToVault(path, r.text);
|
||||
const note = this.acceptText(path, r.text, checked, "admin", "rollback");
|
||||
return { status: "accepted", path, note };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ reading
|
||||
|
||||
/** Problems across the whole folder: links to notes that do not exist, and the like. */
|
||||
problems() {
|
||||
const notes = this.store.list().map((n) => ({ path: n.path, type: n.type, data: n.data, body: n.body }));
|
||||
return checkVault(notes, this.vault.listAttachments());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
private reject(path: string | null, code: string, message: string): Outcome {
|
||||
return { status: "rejected", path, reasons: [{ code, message }] };
|
||||
}
|
||||
|
||||
private slugOf(checked: CheckedNote): string {
|
||||
if (!checked.ok) return "";
|
||||
return String(checked.data.slug ?? slugify(String(checked.data.title)));
|
||||
}
|
||||
|
||||
/** Name and slug clashes with other live notes. */
|
||||
private clashes(path: string, checked: CheckedNote): HeldReason[] {
|
||||
const reasons: HeldReason[] = [];
|
||||
if (!checked.ok) return reasons;
|
||||
const other = this.liveOther(this.store.byName(basename(path, ".md")), path);
|
||||
if (other) reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${other}` });
|
||||
const def = typeDef(checked.type);
|
||||
if (!def?.singleton) {
|
||||
const s = this.liveOther(this.store.bySlug(checked.type, this.slugOf(checked)), path);
|
||||
if (s) reasons.push({ code: "duplicate-slug", message: `Another ${checked.type} already uses this address: ${s}` });
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/** Another live note's path, after making sure that note's file still exists. */
|
||||
private liveOther(note: StoredNote | null, path: string): string | null {
|
||||
if (!note || note.path === path) return null;
|
||||
if (!this.vault.exists(note.path)) {
|
||||
// Its file is gone (moved or deleted) and the watcher has not caught up.
|
||||
this.removed(note.path);
|
||||
return null;
|
||||
}
|
||||
return note.path;
|
||||
}
|
||||
|
||||
private propose(path: string, text: string, author: Author, source: "vault" | "api"): Outcome {
|
||||
const checked = checkNote(text);
|
||||
const name = basename(path, ".md");
|
||||
const slug = this.slugOf(checked);
|
||||
const window = new Date(this.now().getTime() - this.rules.agentBatchWindowMinutes * 60_000).toISOString();
|
||||
const current = this.store.get(path);
|
||||
const nameTakenBy = checked.ok ? this.liveOther(this.store.byName(name), path) : null;
|
||||
const slugTakenBy =
|
||||
checked.ok && !typeDef(checked.type)?.singleton ? this.liveOther(this.store.bySlug(checked.type, slug), path) : null;
|
||||
|
||||
const verdict = evaluate(
|
||||
{ path, name, slug, text, author, checked },
|
||||
{
|
||||
current,
|
||||
nameTakenBy,
|
||||
slugTakenBy,
|
||||
recentAgentChanges: author === "agent" ? this.store.changesSince("agent", window) : 0,
|
||||
settings: this.rules,
|
||||
},
|
||||
);
|
||||
|
||||
if (verdict.decision === "accept" && checked.ok) {
|
||||
if (source === "api") this.writeToVault(path, text);
|
||||
const note = this.acceptText(path, text, checked, author, "accept");
|
||||
return { status: "accepted", path, note };
|
||||
}
|
||||
if (verdict.decision === "hold" && source === "api" && verdict.reasons.some((r) => r.code === "invalid")) {
|
||||
// An API caller gets the problems back at once instead of a broken note in the queue.
|
||||
return { status: "rejected", path, reasons: verdict.reasons };
|
||||
}
|
||||
const reasons = verdict.decision === "hold" ? verdict.reasons : [];
|
||||
const held = this.store.hold({
|
||||
path,
|
||||
author,
|
||||
source,
|
||||
text,
|
||||
baseHash: current && !current.removed ? current.hash : null,
|
||||
reasons,
|
||||
at: this.iso(),
|
||||
});
|
||||
this.onHold?.(held);
|
||||
return { status: "held", path, held };
|
||||
}
|
||||
|
||||
private writeToVault(path: string, text: string) {
|
||||
// Mark it seen first, so the watcher recognises the service's own write.
|
||||
this.store.markSeen(path, hashText(text));
|
||||
this.vault.write(path, text);
|
||||
}
|
||||
|
||||
private acceptText(path: string, text: string, checked: CheckedNote & { ok: true }, author: Author, action: "accept" | "rollback"): StoredNote {
|
||||
const at = this.iso();
|
||||
const data = checked.data;
|
||||
const slug = this.slugOf(checked);
|
||||
const hash = hashText(text);
|
||||
const vaultNote = { path, type: checked.type, data, body: checked.body };
|
||||
|
||||
return this.store.transaction(() => {
|
||||
const prev = this.store.get(path);
|
||||
// The old address keeps working after a rename or a slug change.
|
||||
if (prev && !prev.removed && prev.type === checked.type && prev.slug !== slug) {
|
||||
this.store.addRedirect(prev.type, prev.slug, path, at);
|
||||
}
|
||||
if (!prev || prev.removed) {
|
||||
const since = new Date(this.now().getTime() - MOVE_WINDOW_MS).toISOString();
|
||||
const moved = this.store
|
||||
.recentlyRemoved(since)
|
||||
.find((n) => n.path !== path && n.type === checked.type && (n.hash === hash || n.title === data.title));
|
||||
if (moved) {
|
||||
this.store.retarget(moved.path, path);
|
||||
if (moved.slug !== slug) this.store.addRedirect(moved.type, moved.slug, path, at);
|
||||
}
|
||||
}
|
||||
// A slug in use again belongs to its note, not to a redirect.
|
||||
this.store.dropRedirect(checked.type, slug);
|
||||
this.store.supersede(path, at);
|
||||
return this.store.accept(
|
||||
{
|
||||
path,
|
||||
type: checked.type,
|
||||
name: basename(path, ".md"),
|
||||
slug,
|
||||
title: String(data.title),
|
||||
publish: data.publish === true,
|
||||
data,
|
||||
body: checked.body,
|
||||
text,
|
||||
hash,
|
||||
author,
|
||||
acceptedAt: at,
|
||||
},
|
||||
action,
|
||||
outgoingLinks(vaultNote),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user