@staix/vault-mem 0.2.3 → 0.3.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 +19 -0
- package/README.md +21 -8
- package/package.json +5 -3
- package/src/amem/seam.ts +5 -3
- package/src/analysis-input.ts +2 -1
- package/src/cli.ts +22 -8
- package/src/commands-table.ts +2 -1
- package/src/config.ts +56 -19
- package/src/local-date.ts +9 -0
- package/src/log-format.ts +6 -4
- package/src/org-promote.ts +2 -1
- package/src/promote.ts +2 -1
- package/src/registry.ts +4 -1
- package/src/setup.ts +94 -15
- package/src/skills-mine.ts +3 -1
- package/src/tidy.ts +3 -0
- package/src/ui/panel.css +16 -0
- package/src/ui/panel.js +137 -0
- package/src/ui/plugin.ts +292 -0
- package/src/vault-index.ts +9 -4
- package/src/vault-log.ts +3 -0
- package/src/write-validation.ts +1 -1
- package/src/writer.ts +3 -0
package/src/ui/plugin.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console plugin (api 1) for acon: STAIxBWLB/agent-console contract, UX per vault-mem#48.
|
|
3
|
+
* The provider reuses the `vmem mcp` handlers in process, so the console acts with exactly the MCP rights and checks.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import type { ActionResult, Change, ConsolePlugin, ConsoleProvider, InboxItem } from "@staix/agent-console/contract";
|
|
7
|
+
import { analysisInput } from "../analysis-input.ts";
|
|
8
|
+
import { resolveHome } from "../home.ts";
|
|
9
|
+
import { createVaultMcp } from "../mcp-vault.ts";
|
|
10
|
+
import { validArguments } from "../mcp.ts";
|
|
11
|
+
import { listPromotionCandidates } from "../promote.ts";
|
|
12
|
+
import { loadRegistry } from "../registry.ts";
|
|
13
|
+
import { lintVault } from "../vault-lint.ts";
|
|
14
|
+
import type { LintCode } from "../analysis-types.ts";
|
|
15
|
+
|
|
16
|
+
const asset = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
|
|
17
|
+
const VERSION = (JSON.parse(asset("../../package.json")) as { version: string }).version;
|
|
18
|
+
const LINT_ERRORS: LintCode[] = ["L01", "L02", "L13"];
|
|
19
|
+
const CACHE_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
type Args = Record<string, unknown>;
|
|
22
|
+
const str = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 ? v : undefined;
|
|
23
|
+
const rec = (v: unknown): Record<string, unknown> => (v && typeof v === "object" ? v as Record<string, unknown> : {});
|
|
24
|
+
/**
|
|
25
|
+
* The console's answer to a confirmed write. An applied write is done, together with any later step that failed
|
|
26
|
+
* (a log line, the agent-mem link, SYNC lines). A successful no-op (already linked, nothing to change, a retried
|
|
27
|
+
* confirmation) is success too, never a refusal. Anything else is refused with its first issue.
|
|
28
|
+
*/
|
|
29
|
+
export function confirmedOutcome(done: Args, verb: string): ActionResult {
|
|
30
|
+
if (done.applied === true) {
|
|
31
|
+
const missing = followUpProblems(done);
|
|
32
|
+
return { ok: true, message: missing.length ? `${verb}: applied, but ${missing.join("; ")}` : `${verb}: done` };
|
|
33
|
+
}
|
|
34
|
+
const issues = Array.isArray(done.issues) ? done.issues as { severity?: string }[] : [];
|
|
35
|
+
if (done.ok === true && !issues.some(i => i.severity !== "warning")) return { ok: true, message: `${verb}: already done, nothing changed` };
|
|
36
|
+
return { ok: false, error: firstIssue(done) };
|
|
37
|
+
}
|
|
38
|
+
const firstIssue = (r: Args): string => {
|
|
39
|
+
const issues = Array.isArray(r.issues) ? r.issues as { message?: string; severity?: string }[] : [];
|
|
40
|
+
return (issues.find(i => i.severity !== "warning") ?? issues[0])?.message ?? "refused";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** `- 2026-10-31 09:12 LEARN proj src → dest — note (vmem:agent)` as a row; unparsable lines keep their text. */
|
|
44
|
+
export function parseLogLine(line: string): { at: string; type: string; project: string; source: string; dest?: string; note: string; agent: boolean } | { raw: string } {
|
|
45
|
+
const m = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}) (\S+) (\S+) (.+?)(?: → (.+?))? — (.*?)( \(vmem:agent\))?$/.exec(line.replace(/\n$/, ""));
|
|
46
|
+
if (!m) return { raw: line };
|
|
47
|
+
return { at: m[1]!, type: m[2]!, project: m[3]!, source: m[4]!, ...(m[5] ? { dest: m[5] } : {}), note: m[6]!, agent: m[7] !== undefined };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Lint strings carry the note path in the first backtick span; this turns them into typed findings. */
|
|
51
|
+
export function typedFindings(findings: Record<string, string[]>): { code: string; severity: "error" | "warning"; path?: string; message: string }[] {
|
|
52
|
+
return Object.entries(findings).flatMap(([code, list]) => list.map(message => {
|
|
53
|
+
const path = /`(notes\/[^`]+\.md)`/.exec(message)?.[1];
|
|
54
|
+
return { code, severity: LINT_ERRORS.includes(code as LintCode) ? "error" as const : "warning" as const, ...(path ? { path } : {}), message };
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Failed steps after an applied write: the log receipt(s) of a write, a promotion or an org promotion, and the result's own issues. */
|
|
59
|
+
export function followUpProblems(r: Args): string[] {
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
const failed = (x: unknown) => { const l = rec(x); if (l.ok === false) out.push(firstIssue(l)); };
|
|
62
|
+
const logs = rec(r.logs);
|
|
63
|
+
for (const receipt of [r.log, rec(r.note).log, logs.source, logs.target, r.provenance]) failed(receipt);
|
|
64
|
+
if (r.ok === false) out.push(firstIssue(r));
|
|
65
|
+
return [...new Set(out)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Planned file changes of a vmem preview, as the shell's "What will change" list. */
|
|
69
|
+
function changesOf(receipt: Args): Change[] {
|
|
70
|
+
const list = Array.isArray(receipt.changes) ? receipt.changes as { path: string; beforeSha: string | null; sha256: string | null }[] : [];
|
|
71
|
+
return list.map(c => ({ op: c.beforeSha === null ? "create" : c.sha256 === null ? "delete" : "modify", target: c.path }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const plugin: ConsolePlugin = {
|
|
75
|
+
manifest: {
|
|
76
|
+
api: 1, tool: "vmem", version: VERSION, title: "Vault", icon: "vault", pollMs: 15_000,
|
|
77
|
+
panels: [
|
|
78
|
+
{ id: "overview", title: "Overview", order: 1 },
|
|
79
|
+
{ id: "notes", title: "Search", order: 2 },
|
|
80
|
+
{ id: "note", title: "Note", order: 3 },
|
|
81
|
+
{ id: "promote", title: "Promote", order: 4 },
|
|
82
|
+
{ id: "lint", title: "Lint", order: 5 },
|
|
83
|
+
{ id: "log", title: "Log", order: 6 },
|
|
84
|
+
],
|
|
85
|
+
capabilities: { actions: ["delete", "move", "promote", "promote-from-amem"], confirm: true, inbox: true, search: true },
|
|
86
|
+
},
|
|
87
|
+
async open(ctx): Promise<ConsoleProvider> {
|
|
88
|
+
const home = resolveHome().home;
|
|
89
|
+
const mcp = createVaultMcp(home);
|
|
90
|
+
const call = async (name: string, args: Args): Promise<Args> => rec(await mcp.handlers[name]!(args));
|
|
91
|
+
// The shell's tabs navigate without params, so the vault chosen on one panel is kept for the others.
|
|
92
|
+
// ponytail: one choice per provider; fine for H0's single person, per session once H1 serves several.
|
|
93
|
+
let chosen: string | undefined;
|
|
94
|
+
// An explicit vault must exist (undefined otherwise, like MCP's own check); only an omitted one falls back.
|
|
95
|
+
const vaultOf = (args: Args) => {
|
|
96
|
+
const asked = str(args.vault);
|
|
97
|
+
if (asked) { const spec = mcp.vaults.find(v => v.id === asked); if (spec) chosen = spec.id; return spec; }
|
|
98
|
+
return mcp.vaults.find(v => v.id === (chosen ?? mcp.defaultVault));
|
|
99
|
+
};
|
|
100
|
+
// The vault argument for an MCP call: an explicit id goes through unchanged so the handler refuses an unknown one.
|
|
101
|
+
const vaultArg = (args: Args) => str(args.vault) ?? vaultOf(args)?.id;
|
|
102
|
+
const pass = (args: Args) => { vaultOf(args); const v = vaultArg(args); return v ? { vault: v } : {}; };
|
|
103
|
+
/**
|
|
104
|
+
* Every option the MCP tool declares reaches it: the console never hand-picks arguments, so a supported
|
|
105
|
+
* operation cannot be lost on the way. Form text for array options is split on commas; the result is checked
|
|
106
|
+
* against the tool's own schema before anything runs.
|
|
107
|
+
*/
|
|
108
|
+
const forward = (tool: string, args: Args, fixed: Args = {}): Args | string => {
|
|
109
|
+
const schema = mcp.tools.find(t => t.name === tool)?.inputSchema as { properties?: Record<string, { type?: unknown }> } | undefined;
|
|
110
|
+
const out: Args = {};
|
|
111
|
+
for (const [key, spec] of Object.entries(schema?.properties ?? {})) {
|
|
112
|
+
if (key === "confirm" || key === "fingerprint") continue;
|
|
113
|
+
let value = key in fixed ? fixed[key] : key === "vault" ? vaultArg(args) : args[key];
|
|
114
|
+
if (value === undefined || value === "") continue;
|
|
115
|
+
const types = Array.isArray(spec.type) ? spec.type : [spec.type];
|
|
116
|
+
if (typeof value === "string" && types.includes("array") && !types.includes("string")) value = value.split(",").map(v => v.trim()).filter(Boolean);
|
|
117
|
+
if (value === "true" && types.includes("boolean")) value = true;
|
|
118
|
+
out[key] = value;
|
|
119
|
+
}
|
|
120
|
+
return validArguments(out, schema ?? { type: "object" }) ? out : `invalid input for ${tool}`;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ponytail: lint reads every note, so inbox polls reuse one result per vault for 60 s; invalidate on writes if it lags.
|
|
124
|
+
const lintCache = new Map<string, { at: number; report: ReturnType<typeof lintVault> }>();
|
|
125
|
+
const lint = (id: string) => {
|
|
126
|
+
const hit = lintCache.get(id);
|
|
127
|
+
if (hit && Date.now() - hit.at < CACHE_MS) return hit.report;
|
|
128
|
+
const spec = mcp.vaults.find(v => v.id === id)!;
|
|
129
|
+
const report = lintVault(analysisInput(home, id, { expectedVault: spec }).snapshot);
|
|
130
|
+
lintCache.set(id, { at: Date.now(), report });
|
|
131
|
+
return report;
|
|
132
|
+
};
|
|
133
|
+
const projectOf = new Map<string, string>();
|
|
134
|
+
const candidates = async () => {
|
|
135
|
+
try {
|
|
136
|
+
const { projects } = await listPromotionCandidates({ allProjects: true, limit: 50, piiPatterns: loadRegistry(home).piiPatterns });
|
|
137
|
+
for (const p of projects) for (const c of p.candidates) projectOf.set(c.id, c.project);
|
|
138
|
+
return { projects };
|
|
139
|
+
}
|
|
140
|
+
catch (error) { ctx.log(`vmem: promotion candidates unavailable: ${error instanceof Error ? error.message : String(error)}`); return { projects: [], unavailable: "agent-mem candidates are unavailable; see System" }; }
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The shell keys snooze and oldest-first order on `at`, so an item keeps the time it was first seen.
|
|
144
|
+
const firstSeen = new Map<string, string>();
|
|
145
|
+
const since = (id: string) => { const at = firstSeen.get(id) ?? new Date().toISOString(); firstSeen.set(id, at); return at; };
|
|
146
|
+
async function inbox(): Promise<InboxItem[]> {
|
|
147
|
+
const items: InboxItem[] = [];
|
|
148
|
+
for (const p of (await candidates()).projects) for (const c of p.candidates) items.push({
|
|
149
|
+
id: `mem:${c.id}`, group: "candidate", kind: "promotion", title: c.title ?? c.kind, detail: `${c.kind} memory to promote into a vault note`,
|
|
150
|
+
project: c.project, at: new Date(c.at).toISOString(),
|
|
151
|
+
// Promotion needs a domain and topics, so the item opens the Promote panel instead of offering a one-key verb.
|
|
152
|
+
verbs: [],
|
|
153
|
+
link: { panel: "promote", params: { id: c.id, project: c.project } },
|
|
154
|
+
});
|
|
155
|
+
for (const v of mcp.vaults.filter(x => x.kind === "personal")) {
|
|
156
|
+
let report;
|
|
157
|
+
try { report = lint(v.id); } catch (error) { ctx.log(`vmem: lint ${v.id} failed: ${error instanceof Error ? error.message : String(error)}`); continue; }
|
|
158
|
+
if (report.errors > 0) items.push({ id: `lint:${v.id}`, group: "alert", kind: "lint", title: `${report.errors} lint errors in ${v.id}`, detail: report.summary, at: since(`lint:${v.id}`),
|
|
159
|
+
chip: { text: `${report.errors} errors`, tone: "err" }, verbs: [], link: { panel: "lint", params: { vault: v.id } } });
|
|
160
|
+
const stale = report.findings.L11 ?? [];
|
|
161
|
+
if (stale.length) items.push({ id: `stale:${v.id}`, group: "alert", kind: "lint", title: `Graph report is stale in ${v.id}`, detail: stale[0]!, at: since(`stale:${v.id}`),
|
|
162
|
+
chip: { text: "stale", tone: "warn" }, verbs: [], link: { panel: "lint", params: { vault: v.id } } });
|
|
163
|
+
}
|
|
164
|
+
for (const id of firstSeen.keys()) if (!items.some(i => i.id === id)) firstSeen.delete(id);
|
|
165
|
+
return items;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One two-step operation: preview, and with the console's confirmation recompute and write only on the same fingerprint. */
|
|
169
|
+
async function twoStep(tool: string, args: Args | string, confirmed: boolean, fingerprint: string | undefined, describe: (preview: Args) => { verb: string; preview: string[]; changes: Change[]; warnings: string[] }): Promise<ActionResult> {
|
|
170
|
+
if (typeof args === "string") return { ok: false, error: args };
|
|
171
|
+
const preview = await call(tool, args);
|
|
172
|
+
if (preview.refused || preview.ok === false || typeof preview.fingerprint !== "string") return { ok: false, error: firstIssue(preview) };
|
|
173
|
+
if (!confirmed || fingerprint !== preview.fingerprint) {
|
|
174
|
+
const d = describe(preview);
|
|
175
|
+
return { ok: false, needsConfirm: true, fingerprint: preview.fingerprint, verb: d.verb, preview: d.preview, changes: d.changes, ...(d.warnings.length ? { warnings: d.warnings } : {}) };
|
|
176
|
+
}
|
|
177
|
+
const done = await call(tool, { ...args, confirm: true, fingerprint });
|
|
178
|
+
if (done.applied === true) lintCache.clear();
|
|
179
|
+
return confirmedOutcome(done, describe(preview).verb);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
assets: { js: asset("./panel.js"), css: asset("./panel.css") },
|
|
184
|
+
async snapshot(input) {
|
|
185
|
+
const args = input as Args;
|
|
186
|
+
switch (input.panel) {
|
|
187
|
+
case "inbox": return { items: await inbox() };
|
|
188
|
+
case "search": {
|
|
189
|
+
const q = str(args.q);
|
|
190
|
+
if (!q) return { hits: [], total: 0 };
|
|
191
|
+
const limit = Math.min(10, Math.max(1, Number(args.limit) || 10));
|
|
192
|
+
const r = await call("vmem_search", { query: q, limit, ...pass(args) });
|
|
193
|
+
const hits = (Array.isArray(r.hits) ? r.hits as { path: string; title: string; description: string }[] : []).map(h => ({
|
|
194
|
+
id: h.path, kind: "note", title: h.title, detail: h.description, link: { panel: "note", params: { path: h.path } } }));
|
|
195
|
+
return { hits, total: hits.length };
|
|
196
|
+
}
|
|
197
|
+
case "overview": {
|
|
198
|
+
const status = await call("vmem_status", {});
|
|
199
|
+
const vault = vaultOf(args);
|
|
200
|
+
if (!vault) return { status, vault: null };
|
|
201
|
+
const [notes, mocs, log] = await Promise.all([call("vmem_list", { vault: vault.id, limit: 500 }), call("vmem_mocs", { vault: vault.id }), call("vmem_log_tail", { vault: vault.id, n: 8 })]);
|
|
202
|
+
const count = Array.isArray(notes.notes) ? notes.notes.length : 0;
|
|
203
|
+
return { status, vault: vault.id, noteCount: count, noteCountCapped: count >= 500, mocs: mocs.mocs ?? [], log: (Array.isArray(log.lines) ? log.lines as string[] : []).map(parseLogLine) };
|
|
204
|
+
}
|
|
205
|
+
case "notes": {
|
|
206
|
+
const q = str(args.q);
|
|
207
|
+
const r = q ? await call("vmem_search", { query: q, limit: 50, ...pass(args) }) : await call("vmem_list", { limit: 100, ...pass(args) });
|
|
208
|
+
return { q: q ?? "", notes: r.hits ?? r.notes ?? [], ...(r.refused ? { error: firstIssue(r) } : {}) };
|
|
209
|
+
}
|
|
210
|
+
case "note": {
|
|
211
|
+
const path = str(args.path);
|
|
212
|
+
if (!path) return { note: null };
|
|
213
|
+
const note = await call("vmem_read", { path, ...pass(args) });
|
|
214
|
+
if (note.refused) return { note: null, error: firstIssue(note) };
|
|
215
|
+
const links = await call("vmem_links", { path, ...pass(args) });
|
|
216
|
+
// vmem_promote always reads from the default personal vault, so offer it only there.
|
|
217
|
+
const promotable = vaultOf(args)?.id === mcp.defaultVault && vaultOf(args)?.kind === "personal";
|
|
218
|
+
return { note, links, orgVaults: promotable ? mcp.vaults.filter(v => v.kind === "org").map(v => v.id) : [] };
|
|
219
|
+
}
|
|
220
|
+
case "promote": return { ...(await candidates()), selected: str(args.id) ?? null };
|
|
221
|
+
case "lint": {
|
|
222
|
+
const vault = vaultOf(args);
|
|
223
|
+
if (!vault) return { findings: [] };
|
|
224
|
+
const report = lint(vault.id);
|
|
225
|
+
return { vault: vault.id, errors: report.errors, warnings: report.warnings, summary: report.summary, findings: typedFindings(report.findings) };
|
|
226
|
+
}
|
|
227
|
+
case "log": {
|
|
228
|
+
const vault = vaultOf(args);
|
|
229
|
+
if (!vault) return { rows: [] };
|
|
230
|
+
const r = await call("vmem_log_tail", { vault: vault.id, n: 100, ...(str(args.type) ? { type: args.type } : {}) });
|
|
231
|
+
return { vault: vault.id, rows: (Array.isArray(r.lines) ? r.lines as string[] : []).map(parseLogLine).reverse(), ...(r.ok === false ? { error: firstIssue(r) } : {}) };
|
|
232
|
+
}
|
|
233
|
+
default: return { error: `unknown panel ${input.panel}` };
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
async action(input) {
|
|
237
|
+
const args = input as Args, confirmed = input.confirmed === true, fingerprint = str(input.fingerprint);
|
|
238
|
+
switch (input.action) {
|
|
239
|
+
case "delete": {
|
|
240
|
+
const path = str(args.path) ?? str(args.id);
|
|
241
|
+
if (!path) return { ok: false, error: "path is required" };
|
|
242
|
+
return twoStep("vmem_delete", forward("vmem_delete", args, { path }), confirmed, fingerprint, p => {
|
|
243
|
+
const backlinks = Array.isArray(p.backlinks) ? p.backlinks as { source: string }[] : [];
|
|
244
|
+
return { verb: "Delete note", preview: [`delete ${path}`], changes: changesOf(p),
|
|
245
|
+
warnings: backlinks.length ? [`${backlinks.length} links point here: ${[...new Set(backlinks.map(b => b.source))].slice(0, 5).join(", ")}`] : [] };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
case "move": {
|
|
249
|
+
const from = str(args.from), to = str(args.to);
|
|
250
|
+
if (!from || !to) return { ok: false, error: "from and to are required" };
|
|
251
|
+
return twoStep("vmem_move", forward("vmem_move", args), confirmed, fingerprint, p => ({ verb: "Move note", preview: [`move ${from} → ${to}`], changes: changesOf(p), warnings: [] }));
|
|
252
|
+
}
|
|
253
|
+
case "promote": {
|
|
254
|
+
const path = str(args.path), to = str(args.to);
|
|
255
|
+
if (!path || !to) return { ok: false, error: "path and to are required" };
|
|
256
|
+
if (vaultOf(args)?.id !== mcp.defaultVault) return { ok: false, error: `promotion to an org vault reads from ${mcp.defaultVault}; open the note there` };
|
|
257
|
+
return twoStep("vmem_promote", forward("vmem_promote", args), confirmed, fingerprint, p => {
|
|
258
|
+
const screen = rec(p.screen), dropped = Array.isArray(p.droppedLinks) ? p.droppedLinks as string[] : [];
|
|
259
|
+
const target = String(p.target ?? `${to}:${path}`);
|
|
260
|
+
// A rerun after an interrupted promotion only restores the source provenance.
|
|
261
|
+
const changes: Change[] = p.recovery === "provenance-only"
|
|
262
|
+
? [{ op: "modify", target: path, note: `provenance → ${target}` }]
|
|
263
|
+
: [{ op: "create", target }, { op: "modify", target: path, note: "provenance" }, { op: "log", target: "log", note: "SYNC lines in both vaults" }];
|
|
264
|
+
return { verb: p.recovery === "provenance-only" ? "Restore provenance" : "Promote note", preview: String(p.text ?? "").split("\n"),
|
|
265
|
+
changes,
|
|
266
|
+
warnings: [...(dropped.length ? [`${dropped.length} links become plain text: ${dropped.slice(0, 5).join(", ")}`] : []), ...(screen.passed === false ? ["screen reported issues"] : [])] };
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
case "promote-from-amem": {
|
|
270
|
+
const id = (str(args.id) ?? "").replace(/^mem:/, "");
|
|
271
|
+
if (!/^[0-9a-f-]{32,36}$/i.test(id)) return { ok: false, error: "a memory id is required" };
|
|
272
|
+
if (!projectOf.has(id)) await candidates();
|
|
273
|
+
const project = str(args.project) ?? projectOf.get(id);
|
|
274
|
+
return twoStep("vmem_promote_from_amem", forward("vmem_promote_from_amem", args, { id, ...(project ? { project } : {}) }), confirmed, fingerprint, p => {
|
|
275
|
+
const dupes = Array.isArray(p.possibleDuplicates) ? p.possibleDuplicates as string[] : [];
|
|
276
|
+
// Linking an existing note writes no vault file; it records the link in agent-mem only.
|
|
277
|
+
const changes: Change[] = p.action === "link"
|
|
278
|
+
? [{ op: "modify", target: `agent-mem mem:${id}`, note: `vault link → ${String(p.path)}` }]
|
|
279
|
+
: [{ op: "create", target: String(p.path ?? "note") }, { op: "log", target: "log", note: "LEARN line" }, { op: "modify", target: `agent-mem mem:${id}`, note: "vault link" }];
|
|
280
|
+
return { verb: p.action === "link" ? "Link memory" : "Promote memory", preview: String(p.text ?? `link mem:${id} → ${String(p.path)}`).split("\n"),
|
|
281
|
+
changes,
|
|
282
|
+
warnings: dupes.length ? [`possible duplicates: ${dupes.slice(0, 5).join(", ")}`] : [] };
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
default: return { ok: false, error: `unknown action ${input.action}` };
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export default plugin;
|
package/src/vault-index.ts
CHANGED
|
@@ -9,8 +9,13 @@ import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type
|
|
|
9
9
|
|
|
10
10
|
export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
|
|
11
11
|
export const MAX_RESULTS = 500;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Bump when link resolution changes. 2 = path-only. The index file is named per value, so two installed versions
|
|
14
|
+
* that resolve links differently keep separate indexes instead of rebuilding each other's links. 0.2.4 and older
|
|
15
|
+
* share the unversioned `<digest>.sqlite`; the first run of a newer version builds its own file once.
|
|
16
|
+
*/
|
|
17
|
+
export const LINK_RESOLUTION = 2;
|
|
18
|
+
const indexName = (root: string) => `${digest(root)}-links${LINK_RESOLUTION}.sqlite`;
|
|
14
19
|
export const MAX_READ_BYTES = 64 * 1024;
|
|
15
20
|
export interface IndexedNote extends LinkNote { hash: string; description: string; type: string; domain: string }
|
|
16
21
|
export interface NoteReadResult {
|
|
@@ -118,7 +123,7 @@ export class VaultIndex {
|
|
|
118
123
|
if (!options.memory) {
|
|
119
124
|
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
120
125
|
if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
|
|
121
|
-
this.file = join(cache,
|
|
126
|
+
this.file = join(cache, indexName(this.root));
|
|
122
127
|
try {
|
|
123
128
|
const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
124
129
|
closeSync(fd);
|
|
@@ -136,7 +141,7 @@ export class VaultIndex {
|
|
|
136
141
|
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
137
142
|
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
138
143
|
if (options.memory) {
|
|
139
|
-
const file = join(cache,
|
|
144
|
+
const file = join(cache, indexName(this.root));
|
|
140
145
|
let exists = false;
|
|
141
146
|
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
|
|
142
147
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
package/src/vault-log.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { formatLogEvent, LOG_TYPES } from "./log-format.ts";
|
|
|
8
8
|
import type { LogBinding, LogContext, LogReadOptions, LogReadResult, LogReceipt, LogRequest } from "./log-types.ts";
|
|
9
9
|
import type { WriteIssue } from "./write-types.ts";
|
|
10
10
|
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
11
|
+
import { unenforceablePii } from "./config.ts";
|
|
11
12
|
export const LOG_LOCK = "log.lock";
|
|
12
13
|
export const MAX_LOG_READ_BYTES = 32 * 1024 * 1024;
|
|
13
14
|
export const LARGE_LOG_LINES = 20000;
|
|
@@ -60,6 +61,8 @@ function context(home: string, id: string, channel: "cli" | "mcp" | undefined, w
|
|
|
60
61
|
if (!vault) refuse("L09", "unknown vault id");
|
|
61
62
|
if (channel !== undefined && channel !== "cli" && channel !== "mcp") refuse("POLICY", "unknown log channel");
|
|
62
63
|
if (write && (vault.write === "none" || (channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses log append");
|
|
64
|
+
const unenforceable = write ? unenforceablePii(vault) : undefined;
|
|
65
|
+
if (unenforceable) refuse("PII_POLICY", unenforceable);
|
|
63
66
|
if (realpathSync(vault.path) !== vault.path || !lstatSync(vault.path).isDirectory()) refuse("L09", "registered vault path changed");
|
|
64
67
|
if (isDenied(join(vault.path, "log"), config.denyPaths)) refuse("V06", "log path is denied");
|
|
65
68
|
return { vault, config, channel, ...(write && vault.registry ? { projectIds: registryIds(vault.registry, config.denyPaths) } : {}) };
|
package/src/write-validation.ts
CHANGED
|
@@ -28,7 +28,7 @@ const KEYS = new Set([
|
|
|
28
28
|
export const TYPED_KEYS: Readonly<Record<string, string>> = { decided_at: "decision", decided_by: "decision", email: "person", phone: "person" };
|
|
29
29
|
export const SCHEMA_KEYS: ReadonlySet<string> = KEYS;
|
|
30
30
|
const WIKI_FIELDS = ["topics", "project", "superseded_by", "supersedes", "source_ref"];
|
|
31
|
-
// Exact regex set from @staix/agent-mem v0.
|
|
31
|
+
// Exact regex set from @staix/agent-mem v0.17.0 src/memory/screen.ts; parity-tested against its root export.
|
|
32
32
|
const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
|
|
33
33
|
/\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
|
|
34
34
|
export const SECRET_PATTERNS: readonly RegExp[] = SECRETS;
|
package/src/writer.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { canonicalPath, isDenied } from "./platform.ts";
|
|
|
12
12
|
import { moveSourcePath, notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
|
|
13
13
|
import { MAX_REPORT_BYTES, validateWrite } from "./write-validation.ts";
|
|
14
14
|
import type { PiiSurface } from "./config.ts";
|
|
15
|
+
import { unenforceablePii } from "./config.ts";
|
|
15
16
|
import type { LogBinding } from "./log-types.ts";
|
|
16
17
|
import type { ChangeReceipt, PlannedChange, WriteIssue, WriteReceipt, WriteRequest } from "./write-types.ts";
|
|
17
18
|
export const MUTATION_LOCK = "mutation.lock";
|
|
@@ -129,6 +130,8 @@ export function applyWrite(home: string, request: WriteRequest, options: { env?:
|
|
|
129
130
|
const config = loadRegistry(home), vault = config.vaults.find(value => value.id === request.vault);
|
|
130
131
|
if (options.binding && !matchesBinding(config, request.vault, options.binding)) refuse("VAULT_DRIFT", "vault identity or policy changed since the run started; nothing written");
|
|
131
132
|
if (!vault) refuse("V04", "unknown vault id");
|
|
133
|
+
const unenforceable = unenforceablePii(vault);
|
|
134
|
+
if (unenforceable) refuse("PII_POLICY", unenforceable);
|
|
132
135
|
if (request.operation.op !== "check" && (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses this channel");
|
|
133
136
|
root = realpathSync(vault.path);
|
|
134
137
|
if (root !== vault.path || isDenied(root, config.denyPaths)) refuse("V06", "vault path denied or changed");
|