@staix/vault-mem 0.0.0-bootstrap.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/vmem.js +12 -0
- package/package.json +33 -2
- package/src/amem/seam.ts +56 -0
- package/src/analysis-commands.ts +36 -0
- package/src/analysis-input.ts +82 -0
- package/src/analysis-reports.ts +69 -0
- package/src/analysis-types.ts +19 -0
- package/src/analysis-yaml.ts +178 -0
- package/src/cli.ts +101 -0
- package/src/commands-table.ts +88 -0
- package/src/config.ts +99 -0
- package/src/frontmatter.ts +154 -0
- package/src/home.ts +16 -0
- package/src/index.ts +44 -0
- package/src/links.ts +149 -0
- package/src/log-commands.ts +31 -0
- package/src/log-filter.ts +65 -0
- package/src/log-format.ts +57 -0
- package/src/log-types.ts +10 -0
- package/src/mcp-vault.ts +167 -0
- package/src/mcp.ts +110 -0
- package/src/org-commands.ts +31 -0
- package/src/org-promote.ts +259 -0
- package/src/platform.ts +1 -0
- package/src/promote-command.ts +77 -0
- package/src/promote.ts +167 -0
- package/src/read-commands.ts +43 -0
- package/src/registry.ts +170 -0
- package/src/setup.ts +214 -0
- package/src/source.ts +47 -0
- package/src/sync.ts +145 -0
- package/src/tidy.ts +386 -0
- package/src/vault-graph.ts +233 -0
- package/src/vault-index.ts +369 -0
- package/src/vault-lint.ts +135 -0
- package/src/vault-log.ts +229 -0
- package/src/write-commands.ts +93 -0
- package/src/write-operations.ts +181 -0
- package/src/write-types.ts +50 -0
- package/src/write-validation.ts +285 -0
- package/src/writer.ts +259 -0
- package/src/yaml-quoted.ts +141 -0
package/src/mcp-vault.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { analysisInput } from "./analysis-input.ts";
|
|
2
|
+
import { buildGraph, graphNeighbors } from "./vault-graph.ts";
|
|
3
|
+
import { lintVault } from "./vault-lint.ts";
|
|
4
|
+
import { writeAnalysisReports } from "./analysis-reports.ts";
|
|
5
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
6
|
+
import type { VaultSpec } from "./config.ts";
|
|
7
|
+
import { loadRegistry, registryStatus } from "./registry.ts";
|
|
8
|
+
import { VaultIndex, MAX_NOTE_BYTES } from "./vault-index.ts";
|
|
9
|
+
import { appendLog, readLog } from "./vault-log.ts";
|
|
10
|
+
import { applyWrite } from "./writer.ts";
|
|
11
|
+
import { notePath } from "./write-operations.ts";
|
|
12
|
+
import type { LogEvent } from "./log-types.ts";
|
|
13
|
+
import type { WriteOp, WriteReceipt, WriteRequest } from "./write-types.ts";
|
|
14
|
+
import type { McpHandler, McpTool } from "./mcp.ts";
|
|
15
|
+
|
|
16
|
+
const string = (maxLength: number, minLength = 1) => ({ type: "string", minLength, maxLength });
|
|
17
|
+
const integer = (minimum: number, maximum: number) => ({ type: "integer", minimum, maximum });
|
|
18
|
+
const object = (properties: Record<string, unknown>, required: string[] = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
19
|
+
const tool = (name: string, description: string, inputSchema: Record<string, unknown>, kind: "read" | "write" | "confirm" = "read"): McpTool => ({
|
|
20
|
+
name, description, inputSchema, annotations: { readOnlyHint: kind === "read", destructiveHint: kind === "confirm", idempotentHint: kind === "read", openWorldHint: false },
|
|
21
|
+
});
|
|
22
|
+
const RELATIONS = ["supports", "contradicts", "extends", "applies-to", "context-for"];
|
|
23
|
+
const LOG_TYPES = ["INGEST", "ROUTE", "EXTRACT", "CONNECT", "DIGEST", "LEARN", "LINT", "TASK", "GRAPH", "SYNC", "RETHINK", "SOURCE"];
|
|
24
|
+
const note = string(1024), sha = { type: "string", minLength: 64, maxLength: 64 };
|
|
25
|
+
const logEvent = object({ type: { type: "string", enum: LOG_TYPES }, project: string(64), source: string(512), dest: string(512), note: string(500) }, ["type", "project", "source", "note"]);
|
|
26
|
+
const scalar = { type: ["string", "number", "boolean", "null", "array"], maxLength: 4096, maxItems: 64, items: { type: ["string", "number", "boolean"], maxLength: 1024 } };
|
|
27
|
+
|
|
28
|
+
/** Tool names for the implemented read, write, log, analysis and promotion providers. */
|
|
29
|
+
export const VMEM_TOOL_NAMES = ["vmem_status", "vmem_search", "vmem_read", "vmem_read_many", "vmem_list", "vmem_links", "vmem_mocs", "vmem_check", "vmem_log_tail",
|
|
30
|
+
"vmem_create", "vmem_patch", "vmem_append", "vmem_set_frontmatter", "vmem_link", "vmem_supersede", "vmem_log_append", "vmem_move", "vmem_delete", "vmem_promote_from_amem", "vmem_lint", "vmem_graph_build", "vmem_graph_neighbors", "vmem_promote"] as const;
|
|
31
|
+
|
|
32
|
+
/** Tool definitions for a fixed vault set: `vault` is an enum of the registered ids. */
|
|
33
|
+
export function vaultTools(ids: readonly string[], orgIds: readonly string[] = []): McpTool[] {
|
|
34
|
+
const vault = ids.length ? { type: "string", enum: [...ids] } : string(32);
|
|
35
|
+
const twoStep = { confirm: { type: "boolean" }, fingerprint: sha };
|
|
36
|
+
const orgVault = orgIds.length ? { type: "string", enum: [...orgIds] } : string(32);
|
|
37
|
+
return [
|
|
38
|
+
tool("vmem_status", "Registered vaults with health, write policy and effective pii.", object({})),
|
|
39
|
+
tool("vmem_search", "Full-text (BM25) search over indexed notes. Literal AND terms.", object({ query: string(1000), vault, limit: integer(1, 50), type: string(64), domain: string(64) }, ["query"])),
|
|
40
|
+
tool("vmem_read", "Read one note by stem, alias or notes/ path: frontmatter, body, sha256. Bounded by maxBytes; section selects an exact H2, offset pages UTF-8 byte ranges, and truncated reads list H2 headings/nextOffset.", object({ path: note, vault, maxBytes: integer(1, 65536), section: string(1024), offset: integer(0, MAX_NOTE_BYTES) }, ["path"])),
|
|
41
|
+
tool("vmem_read_many", "Read up to 20 notes (8 KiB each).", object({ paths: { type: "array", minItems: 1, maxItems: 20, items: note }, vault }, ["paths"])),
|
|
42
|
+
tool("vmem_list", "List indexed notes under notes/, optionally by stem prefix.", object({ vault, prefix: string(256, 0), limit: integer(1, 500) })),
|
|
43
|
+
tool("vmem_links", "Outgoing links (with resolution) and/or incoming backlinks of a note.", object({ path: note, vault, direction: { type: "string", enum: ["out", "in", "both"] }, limit: integer(1, 200) }, ["path"])),
|
|
44
|
+
tool("vmem_mocs", "MOC notes with incoming link counts.", object({ vault })),
|
|
45
|
+
tool("vmem_check", "Validate a prospective note text without writing.", object({ vault, path: string(200), text: string(262144, 0) }, ["text"])),
|
|
46
|
+
tool("vmem_log_tail", "Read the newest vault log lines, optionally filtered.", object({ vault, n: integer(1, 200), type: { type: "string", enum: LOG_TYPES }, grep: string(256) })),
|
|
47
|
+
tool("vmem_create", "Create a validated note. Requires a vault with write=mcp.", object({ vault, stem: string(200), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), source: { type: ["string", "array"], minLength: 1, maxLength: 512, minItems: 1, maxItems: 32, items: string(512) }, confidence: string(32), status: string(32), extra: { type: "object", maxProperties: 32, additionalProperties: scalar }, log: logEvent }, ["stem", "type", "domain", "description"]), "write"),
|
|
48
|
+
tool("vmem_patch", "Replace one exact string in a note.", object({ vault, path: note, oldString: string(8192), newString: string(8192, 0), expectedSha: sha }, ["path", "oldString", "newString"]), "write"),
|
|
49
|
+
tool("vmem_append", "Append markdown to a section of a note.", object({ vault, path: note, section: string(120), markdown: string(16384), expectedSha: sha }, ["path", "section", "markdown"]), "write"),
|
|
50
|
+
tool("vmem_set_frontmatter", "Set or unset frontmatter keys of a note.", object({ vault, path: note, set: { type: "object", maxProperties: 32, additionalProperties: scalar }, unset: { type: "array", maxItems: 32, items: string(64) }, expectedSha: sha }, ["path"]), "write"),
|
|
51
|
+
tool("vmem_link", "Add a typed relation link between two notes (bidirectional by default).", object({ vault, from: note, to: note, relation: { type: "string", enum: RELATIONS }, context: string(500), bidirectional: { type: "boolean" }, log: logEvent }, ["from", "to", "relation"]), "write"),
|
|
52
|
+
tool("vmem_supersede", "Mark a note superseded by another.", object({ vault, old: note, next: note, log: logEvent }, ["old", "next"]), "write"),
|
|
53
|
+
tool("vmem_log_append", "Append one validated event line to the vault log.", object({ vault, type: { type: "string", enum: LOG_TYPES }, project: string(64), source: string(512), dest: string(512), note: string(500) }, ["type", "project", "source", "note"]), "write"),
|
|
54
|
+
tool("vmem_move", "Two-step rename with link rewrites: without confirm returns preview and fingerprint; then confirm=true with that fingerprint.", object({ vault, from: note, to: note, ...twoStep }, ["from", "to"]), "confirm"),
|
|
55
|
+
tool("vmem_delete", "Two-step delete: without confirm returns preview (or refusal), backlinks and fingerprint; then confirm=true with that fingerprint.", object({ vault, path: note, ...twoStep }, ["path"]), "confirm"),
|
|
56
|
+
tool("vmem_promote_from_amem", "Two-step promotion of one durable agent-mem entry (id from amem_vault_candidates) into a vault note: without confirm returns the full note text, possible duplicates and fingerprint; confirm=true with that fingerprint creates the note, logs LEARN and records the agent-mem vault link. linkTo links an existing note instead.",
|
|
57
|
+
object({ vault, id: { type: "string", minLength: 32, maxLength: 36 }, stem: string(120), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), linkTo: note, project: string(400), ...twoStep }, ["id"]), "confirm"),
|
|
58
|
+
tool("vmem_lint", "Lint a private derived snapshot; write=true writes a screened report.", object({ vault, write: { type: "boolean" }, dryRun: { type: "boolean" } }), "write"),
|
|
59
|
+
tool("vmem_graph_build", "Build a deterministic graph and screened reports; dryRun changes nothing.", object({ vault, dryRun: { type: "boolean" } }), "write"),
|
|
60
|
+
tool("vmem_graph_neighbors", "Read a bounded graph neighborhood.", object({ vault, path: note, hops: integer(1,2), limit: integer(1,500) }, ["path"])),
|
|
61
|
+
tool("vmem_promote", "Two-step promotion of a personal note into a local org vault clone (never pushes): without confirm returns the full transformed text, dropped links, screen result and fingerprint; confirm=true with that fingerprint writes the org note, source provenance and SYNC log lines. Publishing is the separate `vmem sync` CLI.",
|
|
62
|
+
object({ path: note, to: orgVault, topics: { type: "array", maxItems: 32, items: string(200) }, again: { type: "boolean" }, ...twoStep }, ["path", "to"]), "confirm"),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
type Issue = { code: string; severity: "error"; message: string };
|
|
66
|
+
const refusal = (code: string, message: string) => ({ refused: true as const, issues: [{ code, severity: "error", message } as Issue] });
|
|
67
|
+
const str = (value: unknown): string | undefined => typeof value === "string" ? value : undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handlers over the shared providers (VaultIndex, applyWrite, appendLog/readLog). The vault set and
|
|
71
|
+
* the default (first personal) are fixed when the server starts; later registry changes need a restart.
|
|
72
|
+
*/
|
|
73
|
+
export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.env): { tools: McpTool[]; handlers: Record<string, McpHandler>; vaults: VaultSpec[]; defaultVault: string | undefined } {
|
|
74
|
+
const vaults = loadRegistry(home).vaults, ids = vaults.map(v => v.id);
|
|
75
|
+
const defaultVault = (vaults.find(v => v.kind === "personal") ?? vaults[0])?.id;
|
|
76
|
+
const pick = (args: Record<string, unknown>): VaultSpec => {
|
|
77
|
+
const id = str(args.vault) ?? defaultVault;
|
|
78
|
+
const spec = vaults.find(v => v.id === id);
|
|
79
|
+
if (!spec) throw Object.assign(new Error(ids.length ? "unknown vault id" : "no vaults registered"), { code: "V04" });
|
|
80
|
+
return spec;
|
|
81
|
+
};
|
|
82
|
+
const read = <T>(args: Record<string, unknown>, body: (index: VaultIndex) => T): T => {
|
|
83
|
+
const spec = pick(args), index = new VaultIndex(home, spec, vaults.map(v => v.path));
|
|
84
|
+
try { index.scan(); return body(index); } finally { index.close(); }
|
|
85
|
+
};
|
|
86
|
+
const write = (args: Record<string, unknown>, operation: WriteOp, extra: Partial<WriteRequest> = {}): WriteReceipt | ReturnType<typeof refusal> & Partial<WriteReceipt> => {
|
|
87
|
+
const request: WriteRequest = { vault: pick(args).id, operation, channel: "mcp", ...extra, ...(args.log ? { log: args.log as LogEvent } : {}) };
|
|
88
|
+
const receipt = applyWrite(home, request, { env });
|
|
89
|
+
return receipt.ok ? receipt : { ...receipt, refused: true };
|
|
90
|
+
};
|
|
91
|
+
const twoStep = (args: Record<string, unknown>) => ({ confirm: args.confirm === true, ...(str(args.fingerprint) ? { fingerprint: str(args.fingerprint) } : {}) });
|
|
92
|
+
const document = (result: ReturnType<VaultIndex["show"]>) => {
|
|
93
|
+
const { path, content, hash, truncated, ...metadata } = result;
|
|
94
|
+
if (truncated || result.section !== undefined || result.offset !== undefined) return { path, sha256: hash, truncated, frontmatter: null, body: content, ...metadata };
|
|
95
|
+
const parsed = parseFrontmatter(content);
|
|
96
|
+
return { path, sha256: hash, truncated, frontmatter: parsed.data, body: parsed.body };
|
|
97
|
+
};
|
|
98
|
+
const handlers: Record<string, McpHandler> = {
|
|
99
|
+
vmem_promote: async args => {
|
|
100
|
+
const { promoteToOrg } = await import("./org-promote.ts");
|
|
101
|
+
const source = vaults.find(v => v.id === defaultVault && v.kind === "personal");
|
|
102
|
+
if (!source) return refusal("POLICY", "no personal vault registered");
|
|
103
|
+
const result = promoteToOrg(home, { from: source.id, note: str(args.path)!, to: str(args.to)!, channel: "mcp", again: args.again === true, confirm: args.confirm === true,
|
|
104
|
+
...(Array.isArray(args.topics) ? { topics: args.topics as string[] } : {}), ...(str(args.fingerprint) ? { fingerprint: str(args.fingerprint) } : {}) }, { env });
|
|
105
|
+
return result.ok ? result : { ...result, refused: true };
|
|
106
|
+
},
|
|
107
|
+
vmem_promote_from_amem: async args => {
|
|
108
|
+
const { promoteFromAmem } = await import("./promote.ts");
|
|
109
|
+
const id = str(args.id)!.replace(/^mem:/, "");
|
|
110
|
+
const result = await promoteFromAmem(home, { vault: pick(args).id, id, channel: "mcp", confirm: args.confirm === true,
|
|
111
|
+
...Object.fromEntries(["stem", "type", "domain", "description", "title", "body", "linkTo", "fingerprint"].filter(key => str(args[key]) !== undefined).map(key => [key, str(args[key])])),
|
|
112
|
+
...(Array.isArray(args.topics) ? { topics: args.topics as string[] } : {}) }, { env, ...(str(args.project) ? { project: str(args.project) } : {}) });
|
|
113
|
+
return result.ok ? result : { ...result, refused: true };
|
|
114
|
+
},
|
|
115
|
+
vmem_lint: args => args.write === true ? writeAnalysisReports(home,{vault:pick(args).id,kind:"lint",channel:"mcp",expectedVault:pick(args),dryRun:args.dryRun === true},env) : lintVault(analysisInput(home,pick(args).id,{expectedVault:pick(args)}).snapshot),
|
|
116
|
+
vmem_graph_build: args => writeAnalysisReports(home,{vault:pick(args).id,kind:"graph",channel:"mcp",expectedVault:pick(args),dryRun:args.dryRun === true},env),
|
|
117
|
+
vmem_graph_neighbors: args => graphNeighbors(buildGraph(analysisInput(home,pick(args).id,{expectedVault:pick(args)}).snapshot),str(args.path)!, (args.hops as 1|2|undefined) ?? 1, (args.limit as number|undefined) ?? 50),
|
|
118
|
+
vmem_status: () => ({ defaultVault: defaultVault ?? null, vaults: registryStatus(home).filter(v => ids.includes(v.id)) }),
|
|
119
|
+
vmem_search: args => read(args, index => ({ hits: index.search(str(args.query)!, { limit: (args.limit as number | undefined) ?? 20, type: str(args.type), domain: str(args.domain) }) })),
|
|
120
|
+
vmem_read: args => read(args, index => { const r = index.show(str(args.path)!, (args.maxBytes as number | undefined) ?? 16384, str(args.section), args.offset as number | undefined); return document(r); }),
|
|
121
|
+
vmem_read_many: args => read(args, index => ({ notes: (args.paths as string[]).map(path => {
|
|
122
|
+
try { const r = index.show(path, 8192); return document(r); }
|
|
123
|
+
catch (error) { return { path, ...refusal("V04", error instanceof Error ? error.message : "unreadable") }; }
|
|
124
|
+
}) })),
|
|
125
|
+
vmem_list: args => read(args, index => ({ notes: index.list(str(args.prefix) ?? "", (args.limit as number | undefined) ?? 100) })),
|
|
126
|
+
vmem_links: args => read(args, index => {
|
|
127
|
+
const direction = str(args.direction) ?? "both", limit = (args.limit as number | undefined) ?? 50;
|
|
128
|
+
const out = direction === "in" ? [] : index.links(str(args.path)!, limit);
|
|
129
|
+
return { out: out.filter(l => l.resolved), dead: out.filter(l => !l.resolved), in: direction === "out" ? [] : index.backlinks(str(args.path)!, limit) };
|
|
130
|
+
}),
|
|
131
|
+
vmem_mocs: args => read(args, index => ({ mocs: index.mocs() })),
|
|
132
|
+
vmem_check: args => write(args, { op: "check", path: notePath(str(args.path) ?? "checked-note"), text: str(args.text)! }),
|
|
133
|
+
vmem_log_tail: args => readLog(home, { vault: pick(args).id, limit: (args.n as number | undefined) ?? 50, ...(str(args.type) ? { type: str(args.type) } : {}), ...(str(args.grep) ? { grep: str(args.grep) } : {}) }),
|
|
134
|
+
vmem_create: args => {
|
|
135
|
+
const frontmatter: Record<string, unknown> = { ...(args.extra as Record<string, unknown> | undefined ?? {}) };
|
|
136
|
+
for (const key of ["type", "domain", "description", "title", "source", "confidence", "status"]) if (str(args[key]) !== undefined) frontmatter[key] = args[key];
|
|
137
|
+
if (Array.isArray(args.source)) frontmatter.source = args.source as string[];
|
|
138
|
+
if (Array.isArray(args.topics)) frontmatter.topics = (args.topics as string[]).map(v => v.startsWith("[[") ? v : `[[${v}]]`);
|
|
139
|
+
return write(args, { op: "create", path: str(args.stem)!, frontmatter, ...(str(args.body) !== undefined ? { body: str(args.body) } : {}) });
|
|
140
|
+
},
|
|
141
|
+
vmem_patch: args => write(args, { op: "patch", path: str(args.path)!, oldString: str(args.oldString)!, newString: str(args.newString)!, expectedSha: str(args.expectedSha) }),
|
|
142
|
+
vmem_append: args => write(args, { op: "append", path: str(args.path)!, section: str(args.section)!, markdown: str(args.markdown)!, expectedSha: str(args.expectedSha) }),
|
|
143
|
+
vmem_set_frontmatter: args => write(args, { op: "frontmatter", path: str(args.path)!, set: (args.set as Record<string, unknown> | undefined) ?? {}, ...(args.unset ? { unset: args.unset as string[] } : {}), expectedSha: str(args.expectedSha) }),
|
|
144
|
+
vmem_link: args => write(args, { op: "link", from: str(args.from)!, to: str(args.to)!, relation: args.relation as Extract<WriteOp, { op: "link" }>["relation"], ...(str(args.context) ? { context: str(args.context) } : {}), bidirectional: args.bidirectional !== false }),
|
|
145
|
+
vmem_supersede: args => write(args, { op: "supersede", old: str(args.old)!, next: str(args.next)! }),
|
|
146
|
+
vmem_log_append: args => {
|
|
147
|
+
const event: LogEvent = { type: str(args.type)!, project: str(args.project)!, source: str(args.source)!, note: str(args.note)!, ...(str(args.dest) ? { dest: str(args.dest) } : {}) };
|
|
148
|
+
const receipt = appendLog(home, { vault: pick(args).id, event, channel: "mcp" }, { env });
|
|
149
|
+
return receipt.ok ? receipt : { ...receipt, refused: true };
|
|
150
|
+
},
|
|
151
|
+
vmem_move: args => write(args, { op: "move", from: str(args.from)!, to: str(args.to)! }, twoStep(args)),
|
|
152
|
+
vmem_delete: args => {
|
|
153
|
+
const receipt = write(args, { op: "delete", path: str(args.path)! }, twoStep(args));
|
|
154
|
+
if (args.confirm === true) return receipt;
|
|
155
|
+
let backlinks: { source: string; line: number }[] = [];
|
|
156
|
+
try { backlinks = read(args, index => index.backlinks(str(args.path)!, 200).map(l => ({ source: l.source, line: l.line }))); } catch { /* preview stays valid without backlinks */ }
|
|
157
|
+
return { ...receipt, backlinks };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
// Every handler returns a structured value; provider errors become refusals, never protocol errors.
|
|
161
|
+
const guarded: Record<string, McpHandler> = {};
|
|
162
|
+
for (const [name, handler] of Object.entries(handlers)) guarded[name] = async args => {
|
|
163
|
+
try { return await handler(args); }
|
|
164
|
+
catch (error) { return refusal((error as { code?: unknown }).code === "V04" ? "V04" : "REFUSED", error instanceof Error ? error.message.slice(0, 500) : "operation refused"); }
|
|
165
|
+
};
|
|
166
|
+
return { tools: vaultTools(ids, vaults.filter(v => v.kind === "org").map(v => v.id)), handlers: guarded, vaults, defaultVault };
|
|
167
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/** Newline-delimited JSON-RPC stdio transport (MCP basic + tools). Sequential, bounded, no timers. */
|
|
5
|
+
export const FRAME_BYTES = 65_536, RESULT_BYTES = 262_144;
|
|
6
|
+
export interface McpTool { name: string; description: string; inputSchema: Record<string, unknown>; annotations: Record<string, boolean> }
|
|
7
|
+
export type McpHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
8
|
+
export interface McpServerOptions { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; input?: Readable; output?: Writable; name?: string; version?: string }
|
|
9
|
+
const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
10
|
+
const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
11
|
+
|
|
12
|
+
/** Runtime validation for the small schema vocabulary the tools use. Unknown keywords never widen acceptance. */
|
|
13
|
+
export function validArguments(value: unknown, raw: unknown): boolean {
|
|
14
|
+
const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
15
|
+
const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
|
|
16
|
+
if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
|
|
17
|
+
if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return false;
|
|
18
|
+
if (s.enum && !s.enum.includes(value)) return false;
|
|
19
|
+
if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false;
|
|
20
|
+
if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false;
|
|
23
|
+
return value.every(item => validArguments(item, s.items));
|
|
24
|
+
}
|
|
25
|
+
if (plain(value)) {
|
|
26
|
+
const keys = Object.keys(value);
|
|
27
|
+
if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
|
|
28
|
+
if ((s.required ?? []).some((key: string) => !Object.hasOwn(value, key))) return false;
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (["__proto__", "prototype", "constructor"].includes(key) || key.length > 200) return false;
|
|
31
|
+
if (Object.hasOwn(s.properties ?? {}, key)) { if (!validArguments(value[key], s.properties[key])) return false; }
|
|
32
|
+
else if (s.additionalProperties === false || !plain(s.additionalProperties) || !validArguments(value[key], s.additionalProperties)) return false;
|
|
33
|
+
}
|
|
34
|
+
} else if (type === "object") return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Serve until input ends. stdout carries protocol messages only; nothing else is written to `output`. */
|
|
39
|
+
export async function serveMcp(options: McpServerOptions): Promise<void> {
|
|
40
|
+
const input = options.input ?? process.stdin, output = options.output ?? process.stdout;
|
|
41
|
+
let phase: "new" | "initializing" | "ready" = "new";
|
|
42
|
+
const send = async (response: unknown) => { if (!output.write(JSON.stringify(response) + "\n")) await once(output, "drain"); };
|
|
43
|
+
const error = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
44
|
+
const handle = async (line: string) => {
|
|
45
|
+
let msg: unknown;
|
|
46
|
+
try { msg = JSON.parse(line); } catch { await error(null, -32700, "Parse error"); return; }
|
|
47
|
+
if (!plain(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") { await error(null, -32600, "Invalid request"); return; }
|
|
48
|
+
if (!Object.hasOwn(msg, "id")) {
|
|
49
|
+
if (msg.method === "notifications/initialized" && phase === "initializing") phase = "ready";
|
|
50
|
+
return; // Notifications never invoke a handler.
|
|
51
|
+
}
|
|
52
|
+
const id = msg.id;
|
|
53
|
+
if (!(typeof id === "string" && id.length <= 200 || typeof id === "number" && Number.isSafeInteger(id))) { await error(null, -32600, "Invalid request id"); return; }
|
|
54
|
+
if (msg.method === "ping") { await send({ jsonrpc: "2.0", id, result: {} }); return; }
|
|
55
|
+
if (msg.method === "initialize") {
|
|
56
|
+
if (phase !== "new" || !plain(msg.params) || typeof msg.params.protocolVersion !== "string" || !plain(msg.params.capabilities) || !plain(msg.params.clientInfo)) { await error(id, -32602, "Invalid initialization"); return; }
|
|
57
|
+
phase = "initializing";
|
|
58
|
+
await send({ jsonrpc: "2.0", id, result: { protocolVersion: protocols.includes(msg.params.protocolVersion) ? msg.params.protocolVersion : protocols[0],
|
|
59
|
+
capabilities: { tools: { listChanged: false } }, serverInfo: { name: options.name ?? "vmem", version: options.version ?? "unreleased" } } });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (phase !== "ready") { await error(id, -32002, "Initialize the session first"); return; }
|
|
63
|
+
if (msg.method === "tools/list") {
|
|
64
|
+
const params = msg.params;
|
|
65
|
+
if (params !== undefined && params !== null && (!plain(params) || Object.keys(params).some(key => key !== "cursor" && key !== "_meta") || params.cursor !== undefined && params.cursor !== null)) { await error(id, -32602, "Invalid tool listing arguments"); return; }
|
|
66
|
+
await send({ jsonrpc: "2.0", id, result: { tools: options.tools } }); return;
|
|
67
|
+
}
|
|
68
|
+
if (msg.method !== "tools/call") { await error(id, -32601, "Method not found"); return; }
|
|
69
|
+
const params = msg.params;
|
|
70
|
+
if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
|
|
71
|
+
const definition = options.tools.find(t => t.name === params.name), args = params.arguments ?? {};
|
|
72
|
+
const handler = definition && options.handlers[definition.name];
|
|
73
|
+
if (!definition || !handler || !validArguments(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
|
|
74
|
+
let result: unknown;
|
|
75
|
+
try {
|
|
76
|
+
const text = JSON.stringify(await handler(args as Record<string, unknown>));
|
|
77
|
+
if (text === undefined) throw new Error("tool returned no result");
|
|
78
|
+
result = Buffer.byteLength(text) > RESULT_BYTES
|
|
79
|
+
? { isError: true, content: [{ type: "text", text: JSON.stringify({ refused: true, issues: [{ code: "LIMIT", severity: "error", message: "result exceeds 256 KiB; narrow the request" }] }) }] }
|
|
80
|
+
: { content: [{ type: "text", text }] };
|
|
81
|
+
} catch (failure) {
|
|
82
|
+
// Handlers return structured refusals; anything thrown is reported without stack or content.
|
|
83
|
+
const message = failure instanceof Error ? failure.message.slice(0, 500) : "operation failed";
|
|
84
|
+
result = { isError: true, content: [{ type: "text", text: JSON.stringify({ refused: true, issues: [{ code: "ERROR", severity: "error", message }] }) }] };
|
|
85
|
+
}
|
|
86
|
+
await send({ jsonrpc: "2.0", id, result });
|
|
87
|
+
};
|
|
88
|
+
let pending = "", overflow = false;
|
|
89
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
90
|
+
const consume = async (chunk: string) => {
|
|
91
|
+
let start = 0;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const newline = chunk.indexOf("\n", start), part = chunk.slice(start, newline < 0 ? undefined : newline);
|
|
94
|
+
if (!overflow) {
|
|
95
|
+
if (Buffer.byteLength(pending) + Buffer.byteLength(part) > FRAME_BYTES) { overflow = true; pending = ""; }
|
|
96
|
+
else pending += part;
|
|
97
|
+
}
|
|
98
|
+
if (newline < 0) return;
|
|
99
|
+
if (overflow) await error(null, -32600, "Request exceeds the 64 KiB frame bound");
|
|
100
|
+
else if (pending.trim()) await handle(pending);
|
|
101
|
+
pending = ""; overflow = false; start = newline + 1;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
for await (const chunk of input) await consume(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
|
|
106
|
+
} catch { await error(null, -32700, "Invalid UTF-8 input"); return; }
|
|
107
|
+
await consume(decoder.decode());
|
|
108
|
+
// A truncated frame at EOF is never executed.
|
|
109
|
+
if (pending.trim() || overflow) await error(null, -32700, "Incomplete request at EOF");
|
|
110
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { promoteToOrg } from "./org-promote.ts";
|
|
2
|
+
import { syncVault } from "./sync.ts";
|
|
3
|
+
|
|
4
|
+
function parse(command: string, args: string[], values: string[], booleans: string[]) {
|
|
5
|
+
const flags: Record<string, string | boolean> = {}, positional: string[] = [];
|
|
6
|
+
for (let i = 0; i < args.length; i++) {
|
|
7
|
+
const arg = args[i]!;
|
|
8
|
+
if (!arg.startsWith("--")) { positional.push(arg); continue; }
|
|
9
|
+
if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option ${arg}`);
|
|
10
|
+
if (booleans.includes(arg)) { flags[arg] = true; continue; }
|
|
11
|
+
const value = args[++i];
|
|
12
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${arg}: value required`);
|
|
13
|
+
flags[arg] = value;
|
|
14
|
+
}
|
|
15
|
+
return { flags, positional, value: (name: string) => typeof flags[name] === "string" ? flags[name] as string : undefined };
|
|
16
|
+
}
|
|
17
|
+
/** `vmem promote <vault> <note> --to <org>` and `vmem sync <vault>`: both preview unless `--confirm --fingerprint`. */
|
|
18
|
+
export function runOrgCommand(command: string, args: string[], home: string, env: NodeJS.ProcessEnv): { text: string; code: number } {
|
|
19
|
+
if (command === "promote") {
|
|
20
|
+
const { flags, positional, value } = parse(command, args, ["--to", "--topics", "--fingerprint", "--log-project"], ["--again", "--confirm", "--json"]);
|
|
21
|
+
if (positional.length !== 2 || !value("--to")) throw new Error("promote: requires personal vault id, note and --to <org vault>");
|
|
22
|
+
const result = promoteToOrg(home, { from: positional[0]!, note: positional[1]!, to: value("--to")!, again: flags["--again"] === true, confirm: flags["--confirm"] === true,
|
|
23
|
+
...(value("--topics") ? { topics: value("--topics")!.split(",").map(t => t.trim()).filter(Boolean) } : {}),
|
|
24
|
+
...(value("--fingerprint") ? { fingerprint: value("--fingerprint") } : {}), ...(value("--log-project") ? { logProject: value("--log-project") } : {}), channel: "cli" }, { env });
|
|
25
|
+
return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code: result.ok ? 0 : 1 };
|
|
26
|
+
}
|
|
27
|
+
const { flags, positional, value } = parse(command, args, ["--fingerprint", "--message"], ["--confirm", "--json"]);
|
|
28
|
+
if (positional.length !== 1) throw new Error("sync: requires vault id");
|
|
29
|
+
const result = syncVault(home, { vault: positional[0]!, confirm: flags["--confirm"] === true, ...(value("--fingerprint") ? { fingerprint: value("--fingerprint") } : {}), ...(value("--message") ? { message: value("--message") } : {}) }, { env });
|
|
30
|
+
return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code: result.ok ? 0 : 1 };
|
|
31
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
5
|
+
import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
|
|
6
|
+
import { normalizeTarget, proseLines } from "./links.ts";
|
|
7
|
+
import { loadRegistry } from "./registry.ts";
|
|
8
|
+
import { VaultIndex } from "./vault-index.ts";
|
|
9
|
+
import { applyWrite } from "./writer.ts";
|
|
10
|
+
import { notePath } from "./write-operations.ts";
|
|
11
|
+
import { screenContent } from "./write-validation.ts";
|
|
12
|
+
import { appendLog } from "./vault-log.ts";
|
|
13
|
+
import type { WriteIssue, WriteReceipt } from "./write-types.ts";
|
|
14
|
+
import type { LogReceipt } from "./log-types.ts";
|
|
15
|
+
import { analysisInput } from "./analysis-input.ts";
|
|
16
|
+
import { lintVault } from "./vault-lint.ts";
|
|
17
|
+
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
18
|
+
|
|
19
|
+
export interface OrgPromoteRequest {
|
|
20
|
+
/** Personal vault id and note (stem, alias or notes/ path). */
|
|
21
|
+
from: string;
|
|
22
|
+
note: string;
|
|
23
|
+
/** Target org vault id. */
|
|
24
|
+
to: string;
|
|
25
|
+
/** Override target topics; each must be an existing MOC in the target. */
|
|
26
|
+
topics?: string[];
|
|
27
|
+
again?: boolean;
|
|
28
|
+
confirm?: boolean;
|
|
29
|
+
fingerprint?: string;
|
|
30
|
+
channel?: "cli" | "mcp";
|
|
31
|
+
logProject?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface OrgPromoteResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
applied: boolean;
|
|
36
|
+
preview?: boolean;
|
|
37
|
+
source: string | null;
|
|
38
|
+
target: string | null;
|
|
39
|
+
text?: string;
|
|
40
|
+
droppedLinks?: string[];
|
|
41
|
+
screen: { passed: boolean; issues: WriteIssue[] };
|
|
42
|
+
fingerprint?: string;
|
|
43
|
+
issues: WriteIssue[];
|
|
44
|
+
note?: WriteReceipt;
|
|
45
|
+
provenance?: WriteReceipt;
|
|
46
|
+
logs?: { source: LogReceipt | null; target: LogReceipt | null };
|
|
47
|
+
lint?: { passed: boolean; issues: WriteIssue[]; summary?: string };
|
|
48
|
+
/** Set when the org note already exists from this source and only the source provenance is missing. */
|
|
49
|
+
recovery?: "provenance-only";
|
|
50
|
+
}
|
|
51
|
+
const issue = (code: string, message: string, path?: string): WriteIssue => ({ code, severity: "error", message, ...(path ? { path } : {}) });
|
|
52
|
+
const sha = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
53
|
+
const strings = (value: unknown): string[] => typeof value === "string" ? [value] : Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
|
|
54
|
+
|
|
55
|
+
// ponytail: keyword heuristics from hub-contract §2 never_upload; the org clone's git diff before `vmem sync` is the review surface.
|
|
56
|
+
const CORRESPONDENCE = [/^inbox\//i, /(^|[\s/])inbox\//i, /^outlook:/i, /telegram\(/i, /kakao/i];
|
|
57
|
+
const MEETING = [/(^|\/)meetings\//i];
|
|
58
|
+
/**
|
|
59
|
+
* Fail-closed org screen over the transformed text and source frontmatter: secrets, PII under the
|
|
60
|
+
* target's policy (org default deny), person/people records, private correspondence and internal
|
|
61
|
+
* meeting sources. Returns error issues only; any issue refuses promotion.
|
|
62
|
+
*/
|
|
63
|
+
export function screenForOrg(text: string, data: Record<string, unknown>, target: VaultSpec, config: VmemConfig): WriteIssue[] {
|
|
64
|
+
const issues: WriteIssue[] = screenContent(text, target, config).map(found => ({ ...found, severity: "error" as const }));
|
|
65
|
+
if (data.type === "person" || data.domain === "people") issues.push(issue("SCREEN", "personal_data: person or people records are never promoted"));
|
|
66
|
+
const sources = [...strings(data.source), ...strings(data.source_ref)];
|
|
67
|
+
if (sources.some(value => MEETING.some(pattern => pattern.test(value)))) issues.push(issue("SCREEN", "internal_meeting_notes: source under meetings/"));
|
|
68
|
+
if (sources.some(value => CORRESPONDENCE.some(pattern => pattern.test(value)))) issues.push(issue("SCREEN", "private_correspondence: inbox, mail or chat source"));
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Categories that make a linked source-vault note unpublishable; its name must not reach the org vault either. */
|
|
73
|
+
export function sensitiveCategory(data: Record<string, unknown>): string | undefined {
|
|
74
|
+
if (data.type === "person" || data.domain === "people") return "personal_data";
|
|
75
|
+
const sources = [...strings(data.source), ...strings(data.source_ref)];
|
|
76
|
+
if (sources.some(value => MEETING.some(pattern => pattern.test(value)))) return "internal_meeting_notes";
|
|
77
|
+
if (sources.some(value => CORRESPONDENCE.some(pattern => pattern.test(value)))) return "private_correspondence";
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** Dropped links resolved in the source vault: any that point at a sensitive note refuse the promotion. */
|
|
81
|
+
function screenDroppedLinks(home: string, source: VaultSpec, all: VaultSpec[], dropped: string[]): WriteIssue[] {
|
|
82
|
+
if (!dropped.length) return [];
|
|
83
|
+
const index = new VaultIndex(home, source, all.map(v => v.path)), found = new Map<string, number>();
|
|
84
|
+
try {
|
|
85
|
+
index.scan();
|
|
86
|
+
for (const link of dropped) {
|
|
87
|
+
let category: string | undefined;
|
|
88
|
+
try {
|
|
89
|
+
const note = index.show(link, 65536);
|
|
90
|
+
category = note.truncated ? "unreadable_link_target" : sensitiveCategory(parseFrontmatter(note.content).data);
|
|
91
|
+
} catch { category = undefined; } // unresolved in the source too: plain text only
|
|
92
|
+
if (category) found.set(category, (found.get(category) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
} finally { index.close(); }
|
|
95
|
+
// Counts and categories only: the linked names are exactly what must not be echoed.
|
|
96
|
+
return [...found].map(([category, count]) => issue("SCREEN", `${category}: ${count} body link${count === 1 ? "" : "s"} point at notes that are never promoted; remove those links from the source note first`));
|
|
97
|
+
}
|
|
98
|
+
/** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
|
|
99
|
+
export function replaceTopicsSection(body: string, topics: string[]): string {
|
|
100
|
+
const prose = proseLines(body), raw = body.split("\n");
|
|
101
|
+
let start = -1;
|
|
102
|
+
for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/.test(prose[i]!)) { start = i; break; }
|
|
103
|
+
const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
|
|
104
|
+
if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
|
|
105
|
+
// The section ends at the next prose heading of level 1 or 2.
|
|
106
|
+
let end = raw.length;
|
|
107
|
+
for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
|
|
108
|
+
const tail = raw.slice(end);
|
|
109
|
+
return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
|
|
110
|
+
}
|
|
111
|
+
/** Replace visible wiki links whose target does not resolve in the target vault with their plain text. */
|
|
112
|
+
export function dropUnresolvedLinks(body: string, resolves: (target: string) => boolean): { body: string; dropped: string[] } {
|
|
113
|
+
const prose = proseLines(body), raw = body.split("\n"), dropped: string[] = [];
|
|
114
|
+
const out = raw.map((line, index) => {
|
|
115
|
+
const visible = (prose[index] ?? "").replace(/(`+)[^`]*?\1/g, match => " ".repeat(match.length));
|
|
116
|
+
let result = line;
|
|
117
|
+
for (const match of [...visible.matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)].reverse()) {
|
|
118
|
+
if (match.index! > 0 && visible[match.index! - 1] === "\\") continue;
|
|
119
|
+
const [address, ...labels] = match[2]!.split("|");
|
|
120
|
+
const hash = address!.indexOf("#"), target = (hash < 0 ? address! : address!.slice(0, hash)).trim();
|
|
121
|
+
if (!target || resolves(target)) continue;
|
|
122
|
+
dropped.push(target);
|
|
123
|
+
const plain = labels.length ? labels.join("|") : address!.replace("#", " ").trim();
|
|
124
|
+
result = result.slice(0, match.index!) + plain + result.slice(match.index! + match[0].length);
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
});
|
|
128
|
+
return { body: out.join("\n"), dropped: [...new Set(dropped)].sort() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Target-vault facts read once from its index: link resolution (path stems only, as Obsidian resolves), MOCs and existing paths. */
|
|
132
|
+
function targetFacts(home: string, spec: VaultSpec, all: VaultSpec[], links: string[], targetPath: string): { resolved: Set<string>; mocs: Set<string>; exists: boolean } {
|
|
133
|
+
const index = new VaultIndex(home, spec, all.map(v => v.path));
|
|
134
|
+
try {
|
|
135
|
+
index.scan();
|
|
136
|
+
const targets = index.linkTargets(), resolved = new Set([...new Set(links)].map(linkKey).filter(key => targets.has(key)));
|
|
137
|
+
// Path existence is checked without alias lookup, so another note's alias cannot hide the target.
|
|
138
|
+
return { resolved, mocs: new Set(index.mocs(500).map(moc => linkKey(moc.path))), exists: targets.has(linkKey(targetPath)) };
|
|
139
|
+
} finally { index.close(); }
|
|
140
|
+
}
|
|
141
|
+
function readSource(spec: VaultSpec, path: string): string {
|
|
142
|
+
const file = join(realpathSync(spec.path), path);
|
|
143
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
144
|
+
try {
|
|
145
|
+
const size = fstatSync(fd).size;
|
|
146
|
+
if (!fstatSync(fd).isFile() || size > 256 * 1024) throw new Error("source note: expected plain file <=256 KiB");
|
|
147
|
+
const buffer = Buffer.alloc(size); let read = 0;
|
|
148
|
+
while (read < size) { const n = readSync(fd, buffer, read, size - read, null); if (!n) break; read += n; }
|
|
149
|
+
return buffer.subarray(0, read).toString("utf8");
|
|
150
|
+
} finally { closeSync(fd); }
|
|
151
|
+
}
|
|
152
|
+
const linkKey = (value: string) => normalizeTarget(value.replace(/^\[\[|\]\]$/g, "").split(/[|#]/)[0]!.trim().replace(/^notes\//i, "").replace(/\.md$/i, ""));
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The single org-lint call site: the promoted text is overlaid on the org vault's analysis snapshot and
|
|
156
|
+
* `lintVault` is scoped to that note. It passes when the note has no lint errors (L01/L02/L13); vault-wide
|
|
157
|
+
* warnings are reported separately and do not fail the promotion.
|
|
158
|
+
*/
|
|
159
|
+
export function lintPromoted(home: string, target: VaultSpec, path: string, text: string): { passed: boolean; issues: WriteIssue[]; summary?: string } {
|
|
160
|
+
let snapshot: AnalysisSnapshot;
|
|
161
|
+
try { snapshot = analysisInput(home, target.id).snapshot; }
|
|
162
|
+
catch (error) { return { passed: false, issues: [issue("LINT", `org lint unavailable: ${error instanceof Error ? error.message.slice(0, 200) : "error"}`)] }; }
|
|
163
|
+
const relative = path.replace(/^notes\//, "");
|
|
164
|
+
snapshot = { ...snapshot, notes: [...snapshot.notes.filter(note => note.path !== relative), { path: relative, source: text }] };
|
|
165
|
+
const report = lintVault(snapshot, path);
|
|
166
|
+
const errors = (["L01", "L02", "L13"] as const).flatMap(code => report.findings[code].map(finding => issue(code, finding.slice(0, 500), path)));
|
|
167
|
+
return { passed: errors.length === 0, issues: errors, summary: report.summary };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Two-step personal-to-org promotion. Writes only local clones: never commits or pushes; publishing is `syncVault`.
|
|
172
|
+
* Preview and refusal write nothing; confirm needs the fingerprint of the current preview.
|
|
173
|
+
*/
|
|
174
|
+
export function promoteToOrg(home: string, request: OrgPromoteRequest, options: { env?: NodeJS.ProcessEnv; now?: Date } = {}): OrgPromoteResult {
|
|
175
|
+
const result: OrgPromoteResult = { ok: false, applied: false, source: null, target: null, screen: { passed: false, issues: [] }, issues: [] };
|
|
176
|
+
const fail = (found: WriteIssue) => ({ ...result, issues: [found] });
|
|
177
|
+
const config = loadRegistry(home), all = config.vaults;
|
|
178
|
+
const source = all.find(v => v.id === request.from), target = all.find(v => v.id === request.to);
|
|
179
|
+
if (!source || !target) return fail(issue("V04", "unknown source or target vault id"));
|
|
180
|
+
if (source.kind !== "personal") return fail(issue("POLICY", "promotion source must be a personal vault"));
|
|
181
|
+
if (target.kind !== "org" || target.write === "none") return fail(issue("POLICY", "promotion target must be a writable org vault"));
|
|
182
|
+
if (request.channel === "mcp" && target.write !== "mcp") return fail(issue("POLICY", "target vault write policy refuses MCP"));
|
|
183
|
+
if (request.channel === "mcp" && source.write !== "mcp") return fail(issue("POLICY", "source provenance write needs write=mcp on the personal vault"));
|
|
184
|
+
let path: string;
|
|
185
|
+
const index = new VaultIndex(home, source, all.map(v => v.path));
|
|
186
|
+
try { index.scan(); path = `notes/${index.show(request.note, 1).path}`; }
|
|
187
|
+
catch (error) { return fail(issue("V04", error instanceof Error ? error.message : "unknown source note")); }
|
|
188
|
+
finally { index.close(); }
|
|
189
|
+
result.source = `${source.id}:${path}`;
|
|
190
|
+
const original = readSource(source, path);
|
|
191
|
+
let parsed;
|
|
192
|
+
try { parsed = parseFrontmatter(original); } catch { return fail(issue("V01", "source frontmatter is not parseable", path)); }
|
|
193
|
+
const promotedTo = `${target.id}:${path}`;
|
|
194
|
+
const prior = strings(parsed.data.promoted_to);
|
|
195
|
+
if (prior.length && !request.again) return fail(issue("AGAIN", `already promoted to ${prior.join(", ")}; pass --again to promote again`, path));
|
|
196
|
+
const targetPath = notePath(path);
|
|
197
|
+
// Topics: explicit override, else the source topics; each must be an existing MOC in the target.
|
|
198
|
+
const topics = (request.topics ?? strings(parsed.data.topics)).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
|
|
199
|
+
const bodyLinks = [...parsed.body.matchAll(/\[\[([^\]\n|#]+)/g)].map(match => match[1]!.trim());
|
|
200
|
+
const fieldLinks = ["project", "superseded_by", "supersedes", "source_ref"].flatMap(key => strings(parsed.data[key]).filter(value => value.includes("[[")).map(linkKey));
|
|
201
|
+
const targetNotes = targetFacts(home, target, all, [...bodyLinks, ...fieldLinks], targetPath);
|
|
202
|
+
// The org note is never overwritten; a repeat promotion (--again) needs the earlier org copy moved or deleted first.
|
|
203
|
+
if (targetNotes.exists) {
|
|
204
|
+
// Recovery: an earlier confirm created this org note but the source provenance write failed.
|
|
205
|
+
let existing: Record<string, unknown> = {};
|
|
206
|
+
try { existing = parseFrontmatter(readSource(target, targetPath)).data; } catch { /* unreadable: treated as foreign */ }
|
|
207
|
+
if (existing.promoted_from !== result.source || prior.includes(promotedTo)) return fail(issue("V08", `${target.id} already has ${targetPath}; move or delete it in the org vault first`, targetPath));
|
|
208
|
+
const set = { promoted_to: prior.length ? [...prior, promotedTo] : promotedTo };
|
|
209
|
+
const plan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
|
|
210
|
+
const fingerprint = sha({ recover: plan.fingerprint, promotedTo });
|
|
211
|
+
const recovery: OrgPromoteResult = { ...result, target: `${target.id}:${targetPath}`, screen: { passed: true, issues: [] }, fingerprint, recovery: "provenance-only" };
|
|
212
|
+
if (!plan.ok) return { ...recovery, issues: plan.issues };
|
|
213
|
+
if (request.confirm !== true) return { ...recovery, ok: true, preview: true };
|
|
214
|
+
if (request.fingerprint !== fingerprint) return { ...recovery, issues: [issue("V09", "stale or missing preview fingerprint")] };
|
|
215
|
+
const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", log: false }, { env: options.env });
|
|
216
|
+
return { ...recovery, ok: provenance.ok, applied: provenance.applied, provenance, issues: provenance.ok ? [] : provenance.issues };
|
|
217
|
+
}
|
|
218
|
+
const missingTopics = topics.filter(topic => !targetNotes.mocs.has(linkKey(topic)));
|
|
219
|
+
if (!topics.length || missingTopics.length) return fail(issue("V02", `target topics must name existing MOCs in ${target.id}${missingTopics.length ? `: missing ${missingTopics.join(", ")}` : ""}; use --topics`));
|
|
220
|
+
const transformed = dropUnresolvedLinks(parsed.body, value => targetNotes.resolved.has(linkKey(value)));
|
|
221
|
+
// Body Topics section follows the target topics.
|
|
222
|
+
const body = replaceTopicsSection(transformed.body, topics);
|
|
223
|
+
const day = (options.now ?? new Date()).toISOString().slice(0, 10);
|
|
224
|
+
const frontmatter: Record<string, unknown> = { topics, promoted_from: result.source, promoted_at: day, promoted_to: undefined };
|
|
225
|
+
// Wiki-valued fields that point outside the target vault are dropped as well.
|
|
226
|
+
for (const key of ["project", "superseded_by", "supersedes", "source_ref"]) {
|
|
227
|
+
const values = strings(parsed.data[key]);
|
|
228
|
+
if (values.some(value => value.includes("[[") && !targetNotes.resolved.has(linkKey(value)))) { frontmatter[key] = undefined; transformed.dropped.push(`${key}:${values.join(",")}`); }
|
|
229
|
+
}
|
|
230
|
+
const text = emitFrontmatter(parseFrontmatter(parsed.source.slice(0, parsed.source.length - parsed.body.length) + body), frontmatter);
|
|
231
|
+
result.target = `${target.id}:${targetPath}`; result.text = text; result.droppedLinks = transformed.dropped;
|
|
232
|
+
const screen = [...screenForOrg(text, parsed.data, target, config), ...screenDroppedLinks(home, source, all, transformed.dropped.filter(link => !link.includes(":")))];
|
|
233
|
+
result.screen = { passed: screen.length === 0, issues: screen };
|
|
234
|
+
if (screen.length) return { ...result, issues: screen };
|
|
235
|
+
const planned = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
|
|
236
|
+
const provenanceSet = { promoted_to: prior.length ? [...prior.filter(p => p !== promotedTo), promotedTo] : promotedTo };
|
|
237
|
+
const provenanceText = emitFrontmatter(parsed, provenanceSet);
|
|
238
|
+
const provenancePlan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
|
|
239
|
+
result.fingerprint = sha({ target: planned.fingerprint, source: provenancePlan.fingerprint, text, provenanceText });
|
|
240
|
+
// Org lint runs on the preview too (the note overlaid on the org snapshot), so a note that would fail lint is never written.
|
|
241
|
+
const previewLint = lintPromoted(home, target, targetPath, text);
|
|
242
|
+
result.lint = previewLint;
|
|
243
|
+
const blocking = [...planned.issues, ...provenancePlan.issues, ...previewLint.issues].filter(found => found.severity === "error");
|
|
244
|
+
if (blocking.length) return { ...result, issues: blocking };
|
|
245
|
+
if (request.confirm !== true) return { ...result, ok: true, preview: true, issues: [...planned.issues, ...provenancePlan.issues] };
|
|
246
|
+
if (request.fingerprint !== result.fingerprint) return { ...result, issues: [issue("V09", "stale or missing preview fingerprint")] };
|
|
247
|
+
const event = { type: "SYNC", project: request.logProject ?? "-", source: `${source.id}:${path}`, dest: `${target.id}:${targetPath}`, note: `promoted: ${typeof parsed.data.type === "string" ? parsed.data.type : "note"}` };
|
|
248
|
+
const note = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", log: false }, { env: options.env });
|
|
249
|
+
if (!note.ok || !note.applied) return { ...result, note, issues: note.issues };
|
|
250
|
+
const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", log: false }, { env: options.env });
|
|
251
|
+
const logs = { source: null as LogReceipt | null, target: null as LogReceipt | null };
|
|
252
|
+
for (const [key, vault] of [["target", target], ["source", source]] as const) {
|
|
253
|
+
try { logs[key] = appendLog(home, { vault: vault.id, event, channel: request.channel ?? "cli" }, { env: options.env }); }
|
|
254
|
+
catch { logs[key] = { ok: false, appended: null, synced: false, bytesWritten: null, issues: [issue("LOG_IO", "log append failed")] }; }
|
|
255
|
+
}
|
|
256
|
+
const lint = lintPromoted(home, target, targetPath, text);
|
|
257
|
+
const issues = [...(provenance.ok ? [] : provenance.issues), ...(logs.source?.ok ? [] : logs.source?.issues ?? []), ...(logs.target?.ok ? [] : logs.target?.issues ?? []), ...lint.issues];
|
|
258
|
+
return { ...result, ok: issues.length === 0, applied: true, note, provenance, logs, lint, issues };
|
|
259
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { realPath, canonicalPath, isDenied, DENY_SEGMENTS, memoryGitArgv, memoryGitEnv } from "@staix/agent-mem/platform";
|