@staix/agent-mem 0.11.0 → 0.13.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/.claude-plugin/plugin.json +1 -1
- package/README.md +51 -42
- package/package.json +4 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +220 -58
- package/src/commands-table.ts +9 -9
- package/src/index.ts +1 -0
- package/src/ingest.ts +21 -4
- package/src/mcp.ts +8 -8
- package/src/memory/candidates.ts +15 -13
- package/src/memory/capacity-split.ts +4 -1
- package/src/memory/capacity.ts +48 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +59 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +63 -10
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +30 -0
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune.ts +28 -1
- package/src/memory/shard-migration.ts +38 -0
- package/src/memory/status-text.ts +47 -0
- package/src/memory/store.ts +5 -4
- package/src/memory/style.ts +40 -0
- package/src/memory/vault.ts +31 -0
- package/src/setup-toml.ts +5 -5
- package/src/setup.ts +130 -40
|
@@ -28,14 +28,15 @@ export class ConsolidationProposalError extends Error {
|
|
|
28
28
|
const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
|
|
29
29
|
/** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
|
|
30
30
|
export async function proposeConsolidation(store: MemoryStore, candidates: CandidateStore, options: ConsolidationOptions): Promise<ConsolidationReport> {
|
|
31
|
-
|
|
31
|
+
const actor = options.actor;
|
|
32
|
+
if (actor !== "person" && actor !== "agent") throw Error("invalid consolidation actor");
|
|
32
33
|
if (options.allowed?.() === false) throw Error("consolidation unavailable");
|
|
33
34
|
if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("consolidation requires its own candidate home");
|
|
34
35
|
const project = options.project, version = options.policyVersion(), policy = options.policy();
|
|
35
36
|
if (!project.trim() || typeof version !== "string") throw Error("consolidation context unavailable");
|
|
36
37
|
const sourceCurrent = store.beginRecallRead();
|
|
37
38
|
const check = () => {
|
|
38
|
-
if (options.actor !==
|
|
39
|
+
if (options.actor !== actor || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
|
|
39
40
|
options.assertCurrent?.(); sourceCurrent();
|
|
40
41
|
};
|
|
41
42
|
check();
|
|
@@ -74,7 +74,7 @@ export function decayPreview(store: MemoryStore, project: string, options: Decay
|
|
|
74
74
|
return { project, profile: hashJSON(store.profile), days, at: new Date(now).toISOString(), cutoff: new Date(cutoff).toISOString(), demoteExpiresAt: store.shortExpiry(), entries: entries.slice(0, DECAY_PREVIEW_LIMIT), withheld, citedExemptions: counters === undefined ? null : citedExemptions, truncated: entries.length > DECAY_PREVIEW_LIMIT, citationHistory: "unknown" };
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
-
/** Duplicate-group forgetting requires a separate
|
|
77
|
+
/** Duplicate-group forgetting requires a separate group review, never an implicit decay expansion. */
|
|
78
78
|
export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void {
|
|
79
79
|
store.withDeferredPromotions(() => {
|
|
80
80
|
for (const id of ids) {
|
|
@@ -104,10 +104,10 @@ export class DecayApplyError extends Error {
|
|
|
104
104
|
}
|
|
105
105
|
/** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */
|
|
106
106
|
export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } {
|
|
107
|
-
if (options.actor !== "person") throw Error("decay apply requires
|
|
107
|
+
if (options.actor !== "person" && options.actor !== "agent") throw Error("decay apply requires a valid actor");
|
|
108
108
|
options.assertCurrent?.();
|
|
109
109
|
const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids);
|
|
110
|
-
if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit
|
|
110
|
+
if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit confirmation");
|
|
111
111
|
if (hashJSON(store.profile) !== plan.profile || ids.some(id => !plan.entries.some(entry => entry.target.id === id))) throw Error("decay preview binding changed or target was not previewed");
|
|
112
112
|
const action = options.forget ? "forget" : "demote";
|
|
113
113
|
if (!ids.length) return { action, ids };
|
|
@@ -126,7 +126,7 @@ export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayAp
|
|
|
126
126
|
for (let index = 0; index < ids.length; index++) {
|
|
127
127
|
const id = ids[index]!;
|
|
128
128
|
try {
|
|
129
|
-
const result = options.forget ? store.forget(id,
|
|
129
|
+
const result = options.forget ? store.forget(id, options.actor, plan.project, true) : store.demote(id, options.actor, plan.project, plan.demoteExpiresAt);
|
|
130
130
|
if (!result) throw Error("decay target outcome unavailable");
|
|
131
131
|
applied.push(id);
|
|
132
132
|
} catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); }
|
package/src/memory/doctor.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { join } from "node:path";
|
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
import { sessionStartBrief } from "../session-brief.ts";
|
|
6
6
|
import { pendingSummary } from "./candidate-pending.ts";
|
|
7
|
+
import { readCaptureDrops } from "./metrics.ts";
|
|
7
8
|
import { walkShards } from "./catalog.ts";
|
|
8
9
|
import { loadAmemConfig, repoConfigRoot } from "./config.ts";
|
|
9
10
|
import { readSelector } from "./generation.ts";
|
|
10
11
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
11
12
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
12
13
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
14
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
13
15
|
import { shardDir } from "./shard.ts";
|
|
14
16
|
import type { MemoryStore, View } from "./store.ts";
|
|
15
17
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
@@ -25,7 +27,7 @@ import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } fro
|
|
|
25
27
|
export type SectionStatus = "ok" | "attention" | "skipped" | "unavailable";
|
|
26
28
|
export type Section<T extends object = Record<string, unknown>> = { status: SectionStatus } & T;
|
|
27
29
|
export const DOCTOR_SCHEMA = "amem.doctor/v1";
|
|
28
|
-
export const CLIS = ["claude", "codex", "kimi", "pi"] as const;
|
|
30
|
+
export const CLIS = ["claude", "codex", "kimi", "pi", "qwen"] as const;
|
|
29
31
|
export type Cli = typeof CLIS[number];
|
|
30
32
|
/** A CLI whose last capture is older than this is called stale. */
|
|
31
33
|
export const CAPTURE_STALE_MS = 7 * 86400_000;
|
|
@@ -53,6 +55,8 @@ export interface DoctorReport {
|
|
|
53
55
|
project: string;
|
|
54
56
|
sections: {
|
|
55
57
|
duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
|
|
58
|
+
/** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
|
|
59
|
+
drops: Section;
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -218,7 +222,7 @@ function captureSection(options: DoctorOptions): Section {
|
|
|
218
222
|
const needs = registered ? state !== "fresh" : state === "stale";
|
|
219
223
|
if (needs) attention.push(cli);
|
|
220
224
|
clis[cli] = { hook: registered ? "registered" : "manual", ...(registered ? { hookEvidence: "setup receipt" } : {}), ...(phase === "prepared" ? { setup: "prepared, not applied" } : {}), capture: state, lastCaptureAt: last, rows: entry?.rows ?? 0,
|
|
221
|
-
...(needs || (!registered && state === "never") ? { hint: registered ? `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
|
|
225
|
+
...(needs || (!registered && state === "never") ? { hint: registered ? cli === "codex" && state === "never" ? "the codex hook is registered but has never captured: approve it in Codex's /hooks trust review (setup never grants hook trust), then open a session" : `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
|
|
222
226
|
}
|
|
223
227
|
return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis };
|
|
224
228
|
}
|
|
@@ -270,6 +274,17 @@ function policySection(options: DoctorOptions): Section {
|
|
|
270
274
|
} catch (error) { return unavailable(message(error)); }
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
/** #132: capture rows hooks dropped, by reason, from the bounded rolling metrics tail (counts only; no row text is ever recorded there). */
|
|
278
|
+
function dropsSection(options: DoctorOptions): Section {
|
|
279
|
+
const read = readCaptureDrops(options.home);
|
|
280
|
+
if (!read.readable) return unavailable(read.reason); // an existing file doctor cannot read is never a healthy zero
|
|
281
|
+
// One torn line (an interrupted append) is reported, not fatal; a tail with lines of which none parses is a file doctor cannot decode.
|
|
282
|
+
if (read.lines > 0 && read.malformedLines === read.lines) return unavailable("metrics.jsonl has no decodable record in its recent tail");
|
|
283
|
+
const counts = read.counts, total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
|
|
284
|
+
return { status: total ? "attention" : "ok", window: "recent metrics tail", total, counts, ...(read.malformedLines ? { malformedLines: read.malformedLines } : {}),
|
|
285
|
+
...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
|
|
286
|
+
}
|
|
287
|
+
|
|
273
288
|
/** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
|
|
274
289
|
function cadenceSection(options: DoctorOptions): Section {
|
|
275
290
|
const ingest: Record<string, string> = {};
|
|
@@ -397,19 +412,57 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
397
412
|
let sections: DoctorReport["sections"];
|
|
398
413
|
try {
|
|
399
414
|
await tick();
|
|
400
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options) };
|
|
415
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options) };
|
|
401
416
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
402
417
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
403
418
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
404
419
|
}
|
|
405
420
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
421
|
+
/** Section order and one-line descriptions for the person-facing report (#125). */
|
|
422
|
+
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
423
|
+
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
424
|
+
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
|
|
428
|
+
if (name === "capture" && detail.clis && typeof detail.clis === "object") {
|
|
429
|
+
return Object.entries(detail.clis as Record<string, Record<string, unknown>>).map(([cli, row]) => {
|
|
430
|
+
const capture = String(row.capture), mark = capture === "fresh" ? s.ok(capture) : capture === "stale" ? s.attention(capture) : s.dim(capture);
|
|
431
|
+
return `${cli.padEnd(7)} hook ${row.hook}${row.setup ? ` (${row.setup})` : ""}, capture ${mark}${row.lastCaptureAt ? ` (last ${row.lastCaptureAt})` : ""}, ${row.rows} rows`;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (name === "cadence") {
|
|
435
|
+
const sync = (detail.sync ?? {}) as Record<string, unknown>, ingest = (detail.ingest ?? {}) as Record<string, string>;
|
|
436
|
+
return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
|
|
437
|
+
`sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
|
|
438
|
+
}
|
|
439
|
+
if (name === "drops") {
|
|
440
|
+
if (detail.counts === undefined) return []; // unavailable: only its reason is shown
|
|
441
|
+
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
442
|
+
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
443
|
+
}
|
|
444
|
+
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
445
|
+
const { examples, ...rest } = detail;
|
|
446
|
+
const line = facts(rest);
|
|
447
|
+
return [...(line ? [line] : []), ...(Array.isArray(examples) && examples.length ? [`examples ${examples.map(item => Array.isArray(item) ? item.join("=") : String(item)).join(", ")}`] : [])];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The person-facing report: overall state, one labelled row per section, then the next actions. `amem doctor --json` keeps the full report. */
|
|
451
|
+
export function doctorText(report: DoctorReport, s: Style = PLAIN): string {
|
|
452
|
+
const entries = Object.entries(report.sections) as [keyof DoctorReport["sections"], Section & { hint?: string; reason?: string }][];
|
|
453
|
+
const count = (status: SectionStatus) => entries.filter(([, section]) => section.status === status).length;
|
|
454
|
+
const summary = facts({ attention: count("attention"), unavailable: count("unavailable"), skipped: count("skipped") });
|
|
455
|
+
const lines = [`${s.bold("amem doctor:")} ${report.status === "ok" ? s.ok(report.status) : report.status === "attention" ? s.attention(report.status) : s.error(report.status)}${summary ? ` (${summary})` : ` (all ${entries.length} checks ok)`}`,
|
|
456
|
+
`home ${report.home.path} ${s.dim(`(${report.home.source})`)}`, `project ${report.project}`, ""];
|
|
457
|
+
const actions: string[] = [];
|
|
458
|
+
for (const [name, section] of entries) {
|
|
459
|
+
const { status, hint, reason, ...detail } = section;
|
|
460
|
+
lines.push(`${label(s, status)} ${name}: ${s.dim(SECTION_TITLE[name])}`);
|
|
461
|
+
for (const line of sectionDetail(name, detail, s)) lines.push(` ${line}`);
|
|
462
|
+
if (reason) lines.push(` ${s.dim(`reason: ${reason}`)}`);
|
|
463
|
+
if (hint) actions.push(`${name}: ${hint}`);
|
|
464
|
+
if (name === "capture" && detail.clis) for (const [cli, row] of Object.entries(detail.clis as Record<string, { hint?: string }>)) if (row.hint) actions.push(`capture ${cli}: ${row.hint}`);
|
|
413
465
|
}
|
|
466
|
+
lines.push("", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`));
|
|
414
467
|
return lines.join("\n");
|
|
415
468
|
}
|
package/src/memory/metrics.ts
CHANGED
|
@@ -38,26 +38,38 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
38
38
|
|
|
39
39
|
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
|
|
40
40
|
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
|
|
41
|
-
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
|
|
41
|
+
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
42
42
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
|
+
const read = readCaptureDrops(base);
|
|
44
|
+
return read.readable ? read.counts : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same bounded read, but it says when an existing metrics file could not be read (a person-facing report must not show that as zero, #132).
|
|
49
|
+
* An absent file is readable with no counts. `reason` is a fixed phrase, never file content.
|
|
50
|
+
*/
|
|
51
|
+
export function readCaptureDrops(base: string): { readable: true; counts: Partial<Record<CaptureDropReason, number>>; lines: number; malformedLines: number } | { readable: false; reason: string } {
|
|
43
52
|
const counts: Partial<Record<CaptureDropReason, number>> = {};
|
|
44
|
-
let fd: number | undefined;
|
|
53
|
+
let fd: number | undefined, lines = 0, malformedLines = 0;
|
|
45
54
|
try {
|
|
46
|
-
fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
47
|
-
|
|
55
|
+
try { fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
56
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { readable: true, counts, lines: 0, malformedLines: 0 }; return { readable: false, reason: "metrics.jsonl exists but cannot be opened" }; }
|
|
57
|
+
const stat = fstatSync(fd); if (!stat.isFile()) return { readable: false, reason: "metrics.jsonl is not a plain file" };
|
|
48
58
|
const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
|
|
49
59
|
const length = readSync(fd, bytes, 0, bytes.length, start);
|
|
50
60
|
let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
51
61
|
if (start) text = text.slice(text.indexOf("\n") + 1);
|
|
52
|
-
|
|
62
|
+
const tail = text.split("\n").filter(line => line.trim() !== "").slice(-METRICS_KEEP);
|
|
63
|
+
lines = tail.length;
|
|
64
|
+
for (const line of tail) {
|
|
53
65
|
try {
|
|
54
66
|
const sample = JSON.parse(line) as MetricSample;
|
|
55
67
|
if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
|
|
56
68
|
const reason = sample.captureDropReason!;
|
|
57
69
|
counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
|
|
58
|
-
} catch { /* A malformed metric is never a notice. */ }
|
|
70
|
+
} catch { malformedLines++; /* A malformed metric is never a notice; doctor reports how many were skipped. */ }
|
|
59
71
|
}
|
|
60
|
-
} catch {
|
|
72
|
+
} catch { return { readable: false, reason: "metrics.jsonl cannot be read or is not UTF-8" }; }
|
|
61
73
|
finally { if (fd !== undefined) closeSync(fd); }
|
|
62
|
-
return counts;
|
|
74
|
+
return { readable: true, counts, lines, malformedLines };
|
|
63
75
|
}
|
package/src/memory/migrate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { legacyEntries, legacyHead, readLegacySnapshot, type LegacyCheckpoint }
|
|
|
5
5
|
import { acquireMutation } from "./mutation.ts";
|
|
6
6
|
import { canonicalPath, realPath } from "./platform.ts";
|
|
7
7
|
import { canonicalJSON, fsyncDirectory, generationDescriptor, generationDir, hashJSON, readProfileJSON, readSelector, resolveProfile, validGeneration, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
8
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
8
9
|
|
|
9
10
|
export interface SourcePrefix extends LegacyCheckpoint { tailBytes: number }
|
|
10
11
|
interface MigrationJournal {
|
|
@@ -222,3 +223,32 @@ export async function migrate(options: MigrateOptions): Promise<MigrationResult>
|
|
|
222
223
|
} finally { releaseGeneration(); }
|
|
223
224
|
});
|
|
224
225
|
}
|
|
226
|
+
|
|
227
|
+
/** Render a migration result for a person; JSON output remains owned by the CLI. */
|
|
228
|
+
export function migrationText(result: MigrationResult, s: Style = PLAIN): string {
|
|
229
|
+
const active = result.profile.kind === "generation";
|
|
230
|
+
const needsResume = result.mode === "resume" && !active;
|
|
231
|
+
const state = needsResume ? "attention" : result.mode === "dry-run" ? "preview" : "complete";
|
|
232
|
+
const groups = (value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${singleLine(key)}=${count}`).join(", ");
|
|
233
|
+
const countRows = result.counts ? [
|
|
234
|
+
["valid", result.counts.validRows], ["skipped", result.counts.skippedRows], ["tailBytes", result.counts.tailBytes],
|
|
235
|
+
["expired", result.counts.expiredRows], ["eligible", result.counts.eligibleRows],
|
|
236
|
+
].map(([key, value]) => `${label(s, "ok")} ${key} ${value}`) : [`${label(s, "skipped")} counts not returned for this mode`];
|
|
237
|
+
const operationCounts = result.counts ? groups(result.counts.byOp) : "", tierCounts = result.counts ? groups(result.counts.byTier) : "";
|
|
238
|
+
if (operationCounts) countRows.push(`${label(s, "ok")} operations ${operationCounts}`);
|
|
239
|
+
if (tierCounts) countRows.push(`${label(s, "ok")} tiers ${tierCounts}`);
|
|
240
|
+
let action = "none";
|
|
241
|
+
if (result.mode === "dry-run" && !active) action = "apply migration: amem migrate --apply";
|
|
242
|
+
else if (needsResume) action = "resume migration: amem migrate --resume";
|
|
243
|
+
else if (result.mode === "deactivate" && active) action = "deactivate generation: amem migrate --deactivate";
|
|
244
|
+
const lines = [
|
|
245
|
+
`${s.bold("amem migrate:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
|
|
246
|
+
`${label(s, "ok")} profile ${result.profile.kind}${result.profile.generation ? ` (generation ${result.profile.generation.slice(0, 8)})` : ""}`,
|
|
247
|
+
`${label(s, "ok")} home ${singleLine(result.profile.home)}`,
|
|
248
|
+
`${label(s, "ok")} current directory ${singleLine(result.profile.dir)}`,
|
|
249
|
+
...countRows,
|
|
250
|
+
`${label(s, "ok")} effect ${singleLine(result.consequence)}`,
|
|
251
|
+
`Next action: ${action}`,
|
|
252
|
+
];
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
}
|
package/src/memory/note-add.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { KINDS, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "./store.ts";
|
|
2
|
+
import { cliAttribution } from "./cli-attribution.ts";
|
|
2
3
|
import type { StandalonePolicy } from "./policy.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* F6 (agent-mem#40):
|
|
6
|
-
* person (`peer: "person"`, `source: "person"`), which is why the command is person-only like every ordinary command (an agent marker is refused before it runs).
|
|
6
|
+
* F6 (agent-mem#40): note authoring writes one curated note through the configured policy. The invocation peer is recorded for attribution; source remains `person`.
|
|
7
7
|
*
|
|
8
8
|
* A `fail` note carries structured fields. Its body uses the same labelled lines an accepted `fail` candidate produces, so a note written here and one accepted
|
|
9
9
|
* from `amem_remember` read alike and `fail` search and the plan guard see one shape.
|
|
@@ -59,7 +59,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
|
|
|
59
59
|
if (text.includes("\0")) refuse("the note text holds a NUL byte");
|
|
60
60
|
}
|
|
61
61
|
if (text.length > NOTE_MAX_CHARS) refuse(`the note is ${text.length} characters; the limit is ${NOTE_MAX_CHARS}`);
|
|
62
|
-
return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: "person", source: "person", ...(title ? { title } : {}), text, about: [] };
|
|
62
|
+
return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: cliAttribution()?.peer ?? "person", source: "person", ...(title ? { title } : {}), text, about: [] };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -68,7 +68,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
|
|
|
68
68
|
*/
|
|
69
69
|
export function noteAdd(store: MemoryStore, project: string, input: NoteAddInput, policy: StandalonePolicy): NoteAddResult {
|
|
70
70
|
const note = buildNote(input, project);
|
|
71
|
-
if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about
|
|
71
|
+
if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about, peer: note.peer })) refuse("the note is refused by the configured privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json); edit it or the policy and try again");
|
|
72
72
|
return { id: store.note(note, input.tier === "short") };
|
|
73
73
|
}
|
|
74
74
|
|
package/src/memory/prune.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalJSON, hashJSON, validGeneration } from "./generation.ts";
|
|
2
2
|
import { entryId, type Entry, type NoteEntry } from "./store.ts";
|
|
3
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
3
4
|
|
|
4
5
|
export const PRUNE_ROW_MAX_BYTES = 16_384, PRUNE_PLAN_MAX_BYTES = 262_144;
|
|
5
6
|
export const PRUNE_ROW_MAX_TARGETS = 32, PRUNE_PLAN_MAX_ROWS = 16, PRUNE_PLAN_MAX_TARGETS = 256;
|
|
@@ -128,7 +129,7 @@ export function reducePruneRows(rows: readonly { row: unknown; placement: PruneP
|
|
|
128
129
|
}
|
|
129
130
|
function authenticate(port: PruneStorePort, auth: PruneAuthorization): void {
|
|
130
131
|
guard(validGeneration(port.generation) && /^[a-f0-9]{16}$/.test(port.machine) && revision(port.selectorRevision()));
|
|
131
|
-
guard(auth.actor === "person" && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
|
+
guard((auth.actor === "person" || auth.actor === "agent") && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
133
|
}
|
|
133
134
|
function policyRevision(auth: PruneAuthorization): string {
|
|
134
135
|
if (!auth.policy) return "local-only";
|
|
@@ -257,3 +258,29 @@ export function applyPrune(port: PruneStorePort, input: unknown, auth: PruneAuth
|
|
|
257
258
|
});
|
|
258
259
|
});
|
|
259
260
|
}
|
|
261
|
+
|
|
262
|
+
/** Human-readable preview or apply result. A preview is descriptive output, not a serialized sealed plan. */
|
|
263
|
+
export function pruneText(result: PrunePlan | { completed: string[] }, s: Style = PLAIN): string {
|
|
264
|
+
if ("completed" in result) {
|
|
265
|
+
return [
|
|
266
|
+
`amem prune: ${s.ok("complete")}`,
|
|
267
|
+
`${label(s, result.completed.length ? "ok" : "skipped")} completed prune rows: ${result.completed.length}`,
|
|
268
|
+
...result.completed.map(id => ` ${label(s, "ok")} ${id}`),
|
|
269
|
+
"Next action: none",
|
|
270
|
+
].join("\n");
|
|
271
|
+
}
|
|
272
|
+
const targetIds = result.rows.flatMap(row => row.payload.targets.map(target => target.id));
|
|
273
|
+
const next = targetIds.length
|
|
274
|
+
? "save this preview with --json, then amem prune --apply --plan <file>; keep --user if used in the preview"
|
|
275
|
+
: "none; the sealed plan contains no prune rows";
|
|
276
|
+
return [
|
|
277
|
+
`amem prune: ${s.attention("preview")}`,
|
|
278
|
+
`${label(s, "ok")} project: ${singleLine(result.project)}`,
|
|
279
|
+
...(result.rows.length ? [`${label(s, "ok")} cutoff: ${result.rows[0]!.payload.cutoff}`] : []),
|
|
280
|
+
`${label(s, targetIds.length ? "attention" : "skipped")} selection: eligible ${result.counts.eligible}, selected ${result.counts.selected}, local ${result.counts.local}, synced ${result.counts.synced}`,
|
|
281
|
+
...(result.hasMore ? ["more eligible entries remain beyond this plan"] : []),
|
|
282
|
+
...result.rows.flatMap(row => row.payload.targets.map(target => ` ${label(s, "attention")} ${row.placement}: ${target.id} (expired ${target.expiredAt})`)),
|
|
283
|
+
`Next action: ${next}`,
|
|
284
|
+
...(targetIds.length ? ["The readable preview is not a sealed JSON plan."] : []),
|
|
285
|
+
].join("\n");
|
|
286
|
+
}
|
|
@@ -11,6 +11,7 @@ import { assertNoLiveReaders } from "./reader-lease.ts";
|
|
|
11
11
|
import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
|
|
12
12
|
import { buildGenerationIndex, type Entry } from "./store.ts";
|
|
13
13
|
import { readMigrationSources, type SourcePrefix } from "./migrate.ts";
|
|
14
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
14
15
|
|
|
15
16
|
const USER = Symbol("shared-user-shard");
|
|
16
17
|
type Destination = string | typeof USER;
|
|
@@ -560,3 +561,40 @@ export async function migrateShards(options: ShardMigrationOptions): Promise<Sha
|
|
|
560
561
|
return { mode: options.mode, generation, active: true, ...(alreadyActive ? { alreadyActive: true } : {}), counts: counts(plan), ...refusalSummary(plan), consequence: CONSEQUENCE, ...removalEvidence(journal) };
|
|
561
562
|
});
|
|
562
563
|
}
|
|
564
|
+
|
|
565
|
+
/** Render a shard migration result for a person; JSON output remains owned by the CLI. */
|
|
566
|
+
export function shardMigrationText(result: ShardMigrationResult, s: Style = PLAIN): string {
|
|
567
|
+
const counts = result.counts;
|
|
568
|
+
const groupRows = (name: string, value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
|
|
569
|
+
.map(([key, count]) => `${label(s, "ok")} ${name} ${singleLine(key)} ${count}`);
|
|
570
|
+
const countRows = [
|
|
571
|
+
["rows", counts.rows], ["skipped", counts.skippedRows], ["userRows", counts.userRows], ["tailBytes", counts.tailBytes],
|
|
572
|
+
["legacyRows", counts.legacyRows], ["generationRows", counts.generationRows], ["generationLocalRows", counts.generationLocalRows],
|
|
573
|
+
["legacyLocalRows", counts.legacyLocalRows], ["legacyMainRows", counts.legacyMainRows], ["pruneRows", counts.pruneRows],
|
|
574
|
+
["translatedPruneRows", counts.translatedPruneRows], ["refusedRows", counts.refusedRows],
|
|
575
|
+
["sharedSupersededByPrivate", counts.sharedSupersededByPrivate], ["crossPrivateReplacement", counts.crossPrivateReplacement],
|
|
576
|
+
["danglingRows", counts.danglingRows],
|
|
577
|
+
].map(([key, value]) => {
|
|
578
|
+
const flagged = key === "refusedRows" && counts.refusedRows > 0 || key === "sharedSupersededByPrivate" && counts.sharedSupersededByPrivate > 0
|
|
579
|
+
|| key === "crossPrivateReplacement" && counts.crossPrivateReplacement > 0 || key === "danglingRows" && counts.danglingRows > 0;
|
|
580
|
+
return `${label(s, flagged ? "attention" : "ok")} ${key} ${value}`;
|
|
581
|
+
});
|
|
582
|
+
const warnings = result.refusals ?? [];
|
|
583
|
+
const attention = warnings.length > 0 || counts.refusedRows > 0 || counts.danglingRows > 0 || (!result.active && result.mode === "resume");
|
|
584
|
+
const state = attention ? "attention" : result.mode === "dry-run" ? "preview" : result.active ? "complete" : "attention";
|
|
585
|
+
const action = warnings.length || counts.refusedRows > 0 || counts.danglingRows > 0 ? "resolve the listed refusals or flagged rows before retrying"
|
|
586
|
+
: result.mode === "dry-run" && (!result.active || counts.rows > 0) ? "apply shard migration: amem migrate --shards --apply"
|
|
587
|
+
: result.mode === "resume" && !result.active ? "resume shard migration: amem migrate --shards --resume"
|
|
588
|
+
: "none";
|
|
589
|
+
return [
|
|
590
|
+
`${s.bold("amem migrate --shards:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
|
|
591
|
+
`${label(s, "ok")} generation ${result.generation.slice(0, 8)}`,
|
|
592
|
+
...countRows,
|
|
593
|
+
...groupRows("shard rows", counts.byShard),
|
|
594
|
+
...groupRows("project rows", counts.byProject),
|
|
595
|
+
...(result.deadLeasesRemoved?.length ? [`${label(s, "ok")} dead reader leases removed ${result.deadLeasesRemoved.length}`] : []),
|
|
596
|
+
...(warnings.length ? ["Refusals:", ...warnings.map(reason => ` ${label(s, "attention")} ${singleLine(reason)}`)] : []),
|
|
597
|
+
`${label(s, "ok")} effect ${singleLine(result.consequence)}`,
|
|
598
|
+
`Next action: ${action}`,
|
|
599
|
+
].join("\n");
|
|
600
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
2
|
+
|
|
3
|
+
/** The `amem status` object as the CLI builds it; `amem status --json` prints it unchanged. */
|
|
4
|
+
export interface StatusView {
|
|
5
|
+
home: { path: string; source: string };
|
|
6
|
+
config: { machine: string | null; repo: string | null; denyPaths: number; piiPatterns: number } & Record<string, unknown>;
|
|
7
|
+
candidates: { unavailable: string } | ({ count: number; oldestExpiresInDays: number | null; dueWithin7Days: number; unshown: number; warning?: string });
|
|
8
|
+
active: boolean; generation?: string; phase?: string;
|
|
9
|
+
pending: Record<string, unknown> & { rows: number };
|
|
10
|
+
deadLeasesRemoved?: { lease: string; at: string }[]; refusals?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Person-facing `amem status` (#125): overall state, the effective home and policy, the counts that matter, then next actions. Zero counters are summarized, never listed. */
|
|
14
|
+
export function statusText(status: StatusView, s: Style = PLAIN): string {
|
|
15
|
+
const actions: string[] = [], warnings: string[] = [];
|
|
16
|
+
const candidates = status.candidates;
|
|
17
|
+
let candidateLine: string, candidateState: "ok" | "attention" | "unavailable";
|
|
18
|
+
if ("unavailable" in candidates) { candidateState = "unavailable"; candidateLine = candidates.unavailable; warnings.push(candidates.unavailable); }
|
|
19
|
+
else {
|
|
20
|
+
candidateState = candidates.dueWithin7Days ? "attention" : "ok";
|
|
21
|
+
candidateLine = candidates.count ? `${candidates.count} pending${candidates.unshown ? ` (${candidates.unshown} never shown)` : ""}${candidates.oldestExpiresInDays !== null ? `, oldest expires in ${candidates.oldestExpiresInDays} days` : ""}` : "none pending";
|
|
22
|
+
if (candidates.warning) actions.push(candidates.warning);
|
|
23
|
+
else if (candidates.count) actions.push("review pending candidates: amem review walk");
|
|
24
|
+
}
|
|
25
|
+
const refusals = status.refusals ?? [];
|
|
26
|
+
const interrupted = status.phase === "captured" && !status.active; // authoring refuses until activation (generation-index.ts)
|
|
27
|
+
const migrationState: "ok" | "attention" = refusals.length || status.pending.rows || interrupted ? "attention" : "ok";
|
|
28
|
+
if (interrupted) actions.push("a shard migration stopped after capture and authoring is refused until it activates: amem migrate --shards --resume");
|
|
29
|
+
if (refusals.length) warnings.push(...refusals.map(reason => `migration refusal: ${reason}`));
|
|
30
|
+
if (status.pending.rows && !interrupted) actions.push(`${status.pending.rows} old-layout rows wait for the shard migration: amem migrate --shards --resume carries them (amem migrate --shards --dry-run previews first)`);
|
|
31
|
+
const overall = candidateState === "unavailable" ? "unavailable" : candidateState === "attention" || migrationState === "attention" ? "attention" : "ok";
|
|
32
|
+
const { machine, repo, denyPaths, piiPatterns, ...limits } = status.config;
|
|
33
|
+
const pendingCounts = facts(Object.fromEntries(Object.entries(status.pending).filter(([key]) => !key.endsWith("Ids"))));
|
|
34
|
+
const lines = [
|
|
35
|
+
`${s.bold("amem status:")} ${overall === "ok" ? s.ok(overall) : overall === "attention" ? s.attention(overall) : s.error(overall)}`,
|
|
36
|
+
`home ${status.home.path} ${s.dim(`(${status.home.source})`)}`,
|
|
37
|
+
"",
|
|
38
|
+
`${label(s, "ok")} policy: machine ${machine ?? "none (defaults)"}, repo ${repo ?? "none"}; ${denyPaths} deny paths, ${piiPatterns} PII patterns${facts(limits) ? `; ${facts(limits)}` : ""}`,
|
|
39
|
+
`${label(s, candidateState)} candidates: ${candidateLine}`,
|
|
40
|
+
`${label(s, migrationState)} layout: ${status.active ? `shards active${status.generation ? ` (generation ${status.generation.slice(0, 8)})` : ""}` : status.phase ? `shard migration ${status.phase}${interrupted ? ", not active" : ""}` : "single home"}; ${pendingCounts ? `pending ${pendingCounts}` : "no pending old-layout appends"}`,
|
|
41
|
+
...(status.deadLeasesRemoved?.length ? [` ${s.dim(`${status.deadLeasesRemoved.length} dead reader leases removed`)}`] : []),
|
|
42
|
+
...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` - ${s.attention(warning)}`)] : []),
|
|
43
|
+
"", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`),
|
|
44
|
+
s.dim("Full report: amem status --json; health checks: amem doctor"),
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
package/src/memory/store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cliAttribution } from "./cli-attribution.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -193,7 +194,7 @@ export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: numbe
|
|
|
193
194
|
lifecycle?: "enforce" | "ignore";
|
|
194
195
|
/** #27: read the `aliases` keys' curated rows as part of the `canonical` project (CLI and MCP only); the library default reads the raw key. Rows are presented under `canonical`. */
|
|
195
196
|
reconcile?: { canonical: string; aliases: readonly string[] };
|
|
196
|
-
/**
|
|
197
|
+
/** Explicit project import may initialize configured alias shards; read-only reconciliation still never creates them. */
|
|
197
198
|
createAliases?: boolean;
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -589,7 +590,7 @@ export class MemoryStore {
|
|
|
589
590
|
let applied = 0;
|
|
590
591
|
if (apply) for (const group of groups) for (const head of group.heads) {
|
|
591
592
|
if (head === group.target) continue;
|
|
592
|
-
if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
|
|
593
|
+
if (!this.supersede(head, group.target, cliAttribution()?.actor ?? "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
|
|
593
594
|
applied++;
|
|
594
595
|
}
|
|
595
596
|
return { groups, applied };
|
|
@@ -1587,7 +1588,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1587
1588
|
const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
|
|
1588
1589
|
const aliasStores: MemoryStore[] = [];
|
|
1589
1590
|
try {
|
|
1590
|
-
// #27: read-only reconciliation never creates an alias shard.
|
|
1591
|
+
// #27: read-only reconciliation never creates an alias shard. An explicit project import can.
|
|
1591
1592
|
if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
|
|
1592
1593
|
for (const alias of reconcile.aliases) {
|
|
1593
1594
|
if (alias === opts.project || !opts.createAliases && !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
|
|
@@ -1600,7 +1601,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1600
1601
|
} catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
|
|
1601
1602
|
}
|
|
1602
1603
|
|
|
1603
|
-
/**
|
|
1604
|
+
/** Administrative migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
|
1604
1605
|
export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
|
|
1605
1606
|
const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
|
|
1606
1607
|
let malformed = 0;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free text style for person-facing CLI output (#125).
|
|
3
|
+
* Color is decoration only: every caller also prints a textual label, so plain output carries the same facts.
|
|
4
|
+
* JSON output never passes through this module.
|
|
5
|
+
*/
|
|
6
|
+
export interface Style {
|
|
7
|
+
readonly color: boolean;
|
|
8
|
+
ok(text: string): string;
|
|
9
|
+
attention(text: string): string;
|
|
10
|
+
error(text: string): string;
|
|
11
|
+
dim(text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Color only for an interactive stdout, and never with NO_COLOR (any value, per no-color.org) or TERM=dumb. */
|
|
16
|
+
export function colorEnabled(env: Readonly<Record<string, string | undefined>>, isTTY: boolean | undefined): boolean {
|
|
17
|
+
return isTTY === true && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const wrap = (on: boolean, open: number, close: number) => (text: string) => on ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
21
|
+
export function style(color: boolean): Style {
|
|
22
|
+
return { color, ok: wrap(color, 32, 39), attention: wrap(color, 33, 39), error: wrap(color, 31, 39), dim: wrap(color, 2, 22), bold: wrap(color, 1, 22) };
|
|
23
|
+
}
|
|
24
|
+
export const PLAIN: Style = style(false);
|
|
25
|
+
|
|
26
|
+
/** Data stays on one human-output row and cannot inject terminal or bidirectional controls. */
|
|
27
|
+
export const singleLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ");
|
|
28
|
+
|
|
29
|
+
/** Status label, padded so the columns line up; the word is always present. */
|
|
30
|
+
export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
|
|
31
|
+
const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
|
|
32
|
+
return state === "ok" ? s.ok(word) : state === "attention" ? s.attention(word) : state === "skipped" ? s.dim(word) : s.error(word);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "key value" pairs, leaving out zero, empty and null values; returns "" when nothing material is left. */
|
|
36
|
+
export function facts(values: Record<string, unknown>): string {
|
|
37
|
+
return Object.entries(values).filter(([, value]) => value !== 0 && value !== null && value !== undefined && value !== false && value !== ""
|
|
38
|
+
&& !(Array.isArray(value) && value.length === 0) && !(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0))
|
|
39
|
+
.map(([key, value]) => `${key} ${typeof value === "object" ? Array.isArray(value) ? value.join(", ") : JSON.stringify(value) : String(value)}`).join(", ");
|
|
40
|
+
}
|
package/src/memory/vault.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validVaultPath } from "./generation.ts";
|
|
2
2
|
import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
|
|
3
3
|
import type { MemoryStore, View, VaultEntry } from "./store.ts";
|
|
4
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
4
5
|
|
|
5
6
|
export interface VaultSelection { project: string; policy: MemoryPolicy; limit?: number }
|
|
6
7
|
export type { VaultEntry } from "./store.ts";
|
|
@@ -29,6 +30,36 @@ export async function vaultCandidates(store: MemoryStore, options: VaultSelectio
|
|
|
29
30
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, options.limit ?? 50);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Human-readable list of screened vault candidates; raw note text is intentionally omitted. */
|
|
34
|
+
export function vaultCandidatesText(rows: readonly View[], s: Style = PLAIN): string {
|
|
35
|
+
return [
|
|
36
|
+
`amem vault-candidates: ${rows.length ? s.attention("attention") : s.ok("complete")}`,
|
|
37
|
+
`${label(s, rows.length ? "attention" : "skipped")} candidates: ${rows.length}`,
|
|
38
|
+
...rows.map(row => {
|
|
39
|
+
const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
|
|
40
|
+
return ` ${label(s, "attention")} ${singleLine(row.project)}: ${row.id} ${row.kind} ${title}`;
|
|
41
|
+
}),
|
|
42
|
+
`Next action: ${rows.length ? "open the source repository, then review a candidate with amem show <id> before recording it in your vault" : "none; no screened durable candidates are available"}`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Human-readable cross-project list, preserving project grouping and omitting note bodies. */
|
|
47
|
+
export function vaultCandidatesAllText(result: {
|
|
48
|
+
crossedProjects: boolean;
|
|
49
|
+
projects: readonly { project: string; candidates: readonly View[] }[];
|
|
50
|
+
}, s: Style = PLAIN): string {
|
|
51
|
+
const count = result.projects.reduce((sum, group) => sum + group.candidates.length, 0);
|
|
52
|
+
return [
|
|
53
|
+
`amem vault-candidates: ${count ? s.attention("attention") : s.ok("complete")}${result.crossedProjects ? " (across projects)" : ""}`,
|
|
54
|
+
`${label(s, count ? "attention" : "skipped")} candidates: ${count}`,
|
|
55
|
+
...result.projects.flatMap(group => group.candidates.map(row => {
|
|
56
|
+
const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
|
|
57
|
+
return ` ${label(s, "attention")} ${singleLine(group.project)}: ${row.id} ${row.kind} ${title}`;
|
|
58
|
+
})),
|
|
59
|
+
`Next action: ${count ? (result.crossedProjects ? "open the candidate's source repository, then review it with amem show <id> before recording it in your vault" : "open the source repository, then review a candidate with amem show <id> before recording it in your vault") : "none; no screened durable candidates are available"}`,
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
/** Person-facing hosts authenticate intent before calling; no MCP vault-link tool is provided. */
|
|
33
64
|
export async function recordVaultLink(store: MemoryStore, id: string, path: string, options: VaultSelection): Promise<string> {
|
|
34
65
|
const policy = selection(options);
|
package/src/setup-toml.ts
CHANGED
|
@@ -162,11 +162,11 @@ function editTomlPath(text: string, path: readonly string[], value: unknown | un
|
|
|
162
162
|
changes.push({ start: position, end: position, text: `${newline}${key} = ${inline(value)}${eol}` });
|
|
163
163
|
}
|
|
164
164
|
output = replace(text, changes);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
165
|
+
}
|
|
166
|
+
// Removing the only child of an implicit table must retain its parsed empty parent value.
|
|
167
|
+
for (let depth = path.length - 1; depth > 0; depth--) {
|
|
168
|
+
const parent = path.slice(0, depth), wanted = at(expected, parent);
|
|
169
|
+
if (plain(wanted) && !Object.keys(wanted).length && at(parse(output), parent) === undefined) output += `${output && !output.endsWith("\n") ? eol : ""}[${parent.map(part => JSON.stringify(part)).join(".")}]${eol}`;
|
|
170
170
|
}
|
|
171
171
|
if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
|
|
172
172
|
return output;
|