@staix/agent-mem 0.14.0 → 0.16.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 +19 -3
- package/package.json +3 -2
- package/src/cli-agent-reads.ts +27 -13
- package/src/cli.ts +48 -9
- package/src/commands-table.ts +34 -7
- package/src/hook-spool.ts +12 -4
- package/src/hooks.ts +28 -5
- package/src/mcp-memory.ts +8 -5
- package/src/memory/candidate-overlaps.ts +87 -0
- package/src/memory/capture-input.ts +13 -1
- package/src/memory/capture.ts +29 -8
- package/src/memory/commands.ts +10 -1
- package/src/memory/consolidation-clusters.ts +6 -6
- package/src/memory/doctor.ts +11 -1
- package/src/memory/external-import.ts +15 -3
- package/src/memory/forget-nearby.ts +107 -0
- package/src/memory/search-fts.ts +4 -2
- package/src/memory/store.ts +8 -0
- package/src/session-brief.ts +2 -0
- package/src/setup-gemini.ts +31 -0
- package/src/setup-opencode.ts +15 -0
- package/src/setup-owned.ts +4 -0
- package/src/setup.ts +50 -10
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Candidate, CandidateInput } from "./candidates.ts";
|
|
2
|
+
import { overlapWords, wordOverlap } from "./consolidation-clusters.ts";
|
|
3
|
+
import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts";
|
|
4
|
+
import { stripPrivateDeep } from "./private-spans.ts";
|
|
5
|
+
import { recallViewIsLive } from "./recall.ts";
|
|
6
|
+
import { resolveAlias } from "./shard.ts";
|
|
7
|
+
import type { MemoryStore, View } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export interface CandidateOverlap { id: string; title: string | null; score: number }
|
|
10
|
+
interface OverlapOptions {
|
|
11
|
+
project: string;
|
|
12
|
+
policy: () => StandalonePolicy;
|
|
13
|
+
policyVersion: () => string;
|
|
14
|
+
assertCurrent?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type OverlapCandidate = Pick<Candidate, "status" | "input">;
|
|
18
|
+
interface PreparedCandidate {
|
|
19
|
+
proposed: Pick<View, "title" | "text">;
|
|
20
|
+
terms: Set<string>;
|
|
21
|
+
selected: { row: View; score: number }[];
|
|
22
|
+
target?: View;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function candidateText(input: CandidateInput, target?: View): Pick<View, "title" | "text"> | undefined {
|
|
26
|
+
if (input.kind === "promotion") return target;
|
|
27
|
+
return { title: input.kind === "memory" ? input.title ?? null : null,
|
|
28
|
+
text: input.kind === "correction" ? input.proposedText : input.kind === "decay" ? input.reason
|
|
29
|
+
: input.kind === "fail" ? [input.approach, input.symptom, input.cause, ...input.evidenceRefs, input.retryWhen].join("\n") : input.text };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One policy-screened, tokenized note pass for the whole batch. Keep only three winners per candidate, not the corpus. */
|
|
33
|
+
export async function candidateOverlapsBatch(candidates: readonly OverlapCandidate[], store: MemoryStore, options: OverlapOptions): Promise<CandidateOverlap[][]> {
|
|
34
|
+
const inputs = candidates.map(candidate => candidate.status === "pending" && resolveAlias(store.profile.home, candidate.input.project) === options.project ? stripPrivateDeep(candidate.input) : undefined);
|
|
35
|
+
if (!inputs.some(Boolean)) return candidates.map(() => []);
|
|
36
|
+
const revision = options.policyVersion(), policy = options.policy(), sourceCurrent = store.beginRecallRead();
|
|
37
|
+
const check = () => {
|
|
38
|
+
options.assertCurrent?.(); sourceCurrent();
|
|
39
|
+
if (options.policyVersion() !== revision) throw Error("overlap policy changed");
|
|
40
|
+
};
|
|
41
|
+
check();
|
|
42
|
+
// Promotion text lives in its target. Screen each distinct target once, reusing that verdict in the note pass.
|
|
43
|
+
const targets = new Map<string, View | undefined>(), targetTerms = new Map<string, Set<string>>();
|
|
44
|
+
for (const input of inputs) {
|
|
45
|
+
if (input?.kind !== "promotion" || targets.has(input.target.id)) continue;
|
|
46
|
+
const target = memoryPolicySnapshot(store.get(input.target.id, options.project));
|
|
47
|
+
const allowed = target && target.project === options.project && recallViewIsLive(target, store.readNow()) && await policy.screen(target);
|
|
48
|
+
check(); targets.set(input.target.id, allowed ? target : undefined);
|
|
49
|
+
if (allowed && target) targetTerms.set(target.id, overlapWords(target));
|
|
50
|
+
}
|
|
51
|
+
const prepared = inputs.map((input): PreparedCandidate | undefined => {
|
|
52
|
+
if (!input) return undefined;
|
|
53
|
+
const target = input.kind === "promotion" ? targets.get(input.target.id) : undefined, proposed = candidateText(input, target);
|
|
54
|
+
return proposed ? { proposed, terms: target ? targetTerms.get(target.id)! : overlapWords(proposed), selected: [], ...(target ? { target } : {}) } : undefined;
|
|
55
|
+
});
|
|
56
|
+
if (!prepared.some(Boolean)) { check(); return candidates.map(() => []); }
|
|
57
|
+
let after: Parameters<MemoryStore["readPage"]>[0]["after"];
|
|
58
|
+
for (;;) {
|
|
59
|
+
check();
|
|
60
|
+
const page = store.withDeferredPromotions(() => store.readPage({ project: options.project, limit: 128, after }));
|
|
61
|
+
for (const value of page.rows) {
|
|
62
|
+
const row = memoryPolicySnapshot(value);
|
|
63
|
+
if (!row || row.project !== options.project || !recallViewIsLive(row, store.readNow())) continue;
|
|
64
|
+
const allowed = targets.has(row.id) ? targets.get(row.id) !== undefined : await policy.screen(row); check();
|
|
65
|
+
if (!allowed || !recallViewIsLive(row, store.readNow())) continue;
|
|
66
|
+
const terms = targetTerms.get(row.id) ?? overlapWords(row);
|
|
67
|
+
for (const candidate of prepared) {
|
|
68
|
+
if (!candidate) continue;
|
|
69
|
+
const score = candidate.proposed.text === row.text ? 1 : wordOverlap(candidate.terms, terms), selected = candidate.selected, last = selected.at(-1);
|
|
70
|
+
if (score <= 0 || selected.length === 3 && last && (score < last.score || score === last.score && row.id.localeCompare(last.row.id) >= 0)) continue;
|
|
71
|
+
selected.push({ row, score });
|
|
72
|
+
selected.sort((a, b) => b.score - a.score || a.row.id.localeCompare(b.row.id));
|
|
73
|
+
if (selected.length > 3) selected.pop();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!page.next) break;
|
|
77
|
+
after = page.next;
|
|
78
|
+
}
|
|
79
|
+
check();
|
|
80
|
+
return prepared.map(candidate => !candidate || candidate.target && !recallViewIsLive(candidate.target, store.readNow()) ? []
|
|
81
|
+
: candidate.selected.filter(({ row }) => recallViewIsLive(row, store.readNow())).map(({ row, score }) => ({ id: row.id, title: row.title, score })));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Single-candidate callers share the same screening and source/policy fences as JSON list batches. */
|
|
85
|
+
export async function candidateOverlaps(candidate: OverlapCandidate, store: MemoryStore, options: OverlapOptions): Promise<CandidateOverlap[]> {
|
|
86
|
+
return (await candidateOverlapsBatch([candidate], store, options))[0]!;
|
|
87
|
+
}
|
|
@@ -5,16 +5,21 @@ import { stripPrivateSpans } from "./private-spans.ts";
|
|
|
5
5
|
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
6
6
|
export const CAPTURE_MAX_PATHS = 32;
|
|
7
7
|
const ROW_OVERHEAD_BYTES = 96;
|
|
8
|
+
/** The session key is a fixed-width hash prefix, so it adds a bounded amount covered by the row overhead. */
|
|
9
|
+
export const SESSION_KEY = /^[a-f0-9]{16}$/;
|
|
8
10
|
|
|
9
11
|
export interface CaptureFields {
|
|
10
12
|
agent: string; event: string;
|
|
11
13
|
tool?: string; paths?: string[]; status?: string; excerpt?: string;
|
|
14
|
+
/** #173: a 16-hex hash of the native session id (never the raw id), and a subagent marker. Both optional. */
|
|
15
|
+
session?: string; subagent?: boolean;
|
|
12
16
|
/** Tests and the spool replay pin the time; hooks leave it to the store clock. */
|
|
13
17
|
at?: string;
|
|
14
18
|
}
|
|
15
19
|
export interface NormalizedCaptureFields {
|
|
16
20
|
at: string; agent: string; event: string;
|
|
17
21
|
tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
|
|
22
|
+
session?: string; subagent?: true;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
/** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
|
|
@@ -31,6 +36,13 @@ export function normalizeCaptureFields(input: CaptureFields, now: () => number =
|
|
|
31
36
|
// `<private>` spans go first: they are never screened, counted or stored. The cap applies to what is left.
|
|
32
37
|
let excerpt = stripPrivateSpans(input.excerpt ?? "").slice(0, maxExcerpt);
|
|
33
38
|
if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
|
|
39
|
+
const session = typeof input.session === "string" && SESSION_KEY.test(input.session) ? input.session : undefined;
|
|
40
|
+
const subagent = input.subagent === true;
|
|
34
41
|
return { at, agent: input.agent, event: input.event, tool, paths, status, excerpt,
|
|
35
|
-
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES
|
|
42
|
+
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES,
|
|
43
|
+
...(session ? { session } : {}), ...(subagent ? { subagent: true as const } : {}) };
|
|
36
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Shared by every capture reader: the #173 columns are optional, so a shard without them reads unchanged. */
|
|
47
|
+
export const captureAttribution = (r: { session?: string | null; subagent?: number | null }): { session?: string; subagent?: true } =>
|
|
48
|
+
({ ...(typeof r.session === "string" && SESSION_KEY.test(r.session) ? { session: r.session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) });
|
package/src/memory/capture.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { looksSecret } from "./screen.ts";
|
|
6
|
-
import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
|
|
6
|
+
import { captureAttribution, normalizeCaptureFields, SESSION_KEY, type CaptureFields } from "./capture-input.ts";
|
|
7
7
|
export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
|
|
8
8
|
import { memoryBase } from "./home.ts";
|
|
9
9
|
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
@@ -63,6 +63,8 @@ export interface CaptureInput extends CaptureFields { project: string }
|
|
|
63
63
|
export interface CaptureRow {
|
|
64
64
|
id: number; at: string; project: string; agent: string; event: string;
|
|
65
65
|
tool: string | null; paths: string[]; status: string | null; excerpt: string;
|
|
66
|
+
/** #173 attribution, absent on older rows: a 16-hex session hash and the subagent marker. */
|
|
67
|
+
session?: string; subagent?: true;
|
|
66
68
|
}
|
|
67
69
|
export interface CaptureConfig { capMb: number; retentionDays: number }
|
|
68
70
|
export interface CaptureOptions {
|
|
@@ -104,6 +106,7 @@ export type CaptureAppend = { id: number; already?: true } | { spooled: true };
|
|
|
104
106
|
interface NormalizedCapture {
|
|
105
107
|
at: string; project: string; agent: string; event: string;
|
|
106
108
|
tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
|
|
109
|
+
session?: string; subagent?: true;
|
|
107
110
|
/** Entropy per spooled line, so two byte-identical hook events are two rows; a replayed line still dedupes. */
|
|
108
111
|
nonce?: string;
|
|
109
112
|
}
|
|
@@ -159,8 +162,23 @@ export function writeCaptureConfig(dir: string, config: Partial<CaptureConfig>):
|
|
|
159
162
|
|
|
160
163
|
const lockError = (error: unknown): boolean => /lock|busy/i.test((error as Error)?.message ?? "");
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
/**
|
|
166
|
+
* #173 migration, safe under concurrent first opens after an upgrade. Another writer can add a column between our
|
|
167
|
+
* table_info read and our ALTER; SQLite then reports "duplicate column name", which means the column exists. Lock
|
|
168
|
+
* and busy errors still propagate, so the constructor's contended path spools as before.
|
|
169
|
+
*/
|
|
170
|
+
export function addAttributionColumns(db: Database, beforeAlter?: (column: string) => void): void {
|
|
171
|
+
const has = () => new Set((db.query("PRAGMA table_info(rows)").all() as { name: string }[]).map(c => c.name));
|
|
172
|
+
for (const [column, type] of [["session", "TEXT"], ["subagent", "INTEGER"]] as const) {
|
|
173
|
+
if (has().has(column)) continue;
|
|
174
|
+
beforeAlter?.(column);
|
|
175
|
+
try { db.run(`ALTER TABLE rows ADD COLUMN ${column} ${type}`); }
|
|
176
|
+
catch (error) { if (!/duplicate column name/i.test((error as Error)?.message ?? "") || !has().has(column)) throw error; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface RawRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; bytes: number; session?: string | null; subagent?: number | null }
|
|
181
|
+
const toRow = (r: RawRow): CaptureRow => ({ id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt, ...captureAttribution(r) });
|
|
164
182
|
|
|
165
183
|
export class CaptureStore {
|
|
166
184
|
readonly dir: string;
|
|
@@ -268,6 +286,8 @@ export class CaptureStore {
|
|
|
268
286
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS rows_spool ON rows (spool_id) WHERE spool_id IS NOT NULL");
|
|
269
287
|
db.run("CREATE INDEX IF NOT EXISTS rows_at ON rows (at)");
|
|
270
288
|
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
289
|
+
// #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
|
|
290
|
+
addAttributionColumns(db);
|
|
271
291
|
this.ensureFts();
|
|
272
292
|
} catch (error) {
|
|
273
293
|
if (!lockError(error)) { db.close(); throw error; }
|
|
@@ -437,8 +457,8 @@ export class CaptureStore {
|
|
|
437
457
|
|
|
438
458
|
/** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
|
|
439
459
|
private normalize(input: CaptureInput): NormalizedCapture {
|
|
440
|
-
const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
|
|
441
|
-
return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
|
|
460
|
+
const { at, agent, event, tool, paths, status, excerpt, bytes, session, subagent } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
|
|
461
|
+
return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes, ...(session ? { session } : {}), ...(subagent ? { subagent } : {}) };
|
|
442
462
|
}
|
|
443
463
|
|
|
444
464
|
private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
|
|
@@ -485,7 +505,8 @@ export class CaptureStore {
|
|
|
485
505
|
&& typeof row.agent === "string" && row.agent.length > 0 && typeof row.event === "string" && row.event.length > 0
|
|
486
506
|
&& (row.tool === null || typeof row.tool === "string") && Array.isArray(row.paths) && row.paths.every((p) => typeof p === "string")
|
|
487
507
|
&& (row.status === null || typeof row.status === "string") && typeof row.excerpt === "string"
|
|
488
|
-
&& typeof row.bytes === "number" && Number.isFinite(row.bytes) && row.bytes >= 0
|
|
508
|
+
&& typeof row.bytes === "number" && Number.isFinite(row.bytes) && row.bytes >= 0
|
|
509
|
+
&& (row.session === undefined || typeof row.session === "string" && SESSION_KEY.test(row.session)) && (row.subagent === undefined || row.subagent === true);
|
|
489
510
|
candidates.sort((a, b) => a.name.localeCompare(b.name));
|
|
490
511
|
for (const source of candidates) {
|
|
491
512
|
if (budget <= 0 || byteBudget <= 0 || performance.now() - started >= SPOOL_MAX_MS) break;
|
|
@@ -557,8 +578,8 @@ export class CaptureStore {
|
|
|
557
578
|
// it), so the replay check is a plain indexed lookup, never the statement's change count.
|
|
558
579
|
if (spoolId !== null && this.db.query("SELECT 1 FROM rows WHERE spool_id = ?").get(spoolId)) return undefined;
|
|
559
580
|
try {
|
|
560
|
-
const id = Number(this.db.query("INSERT INTO rows (at, project, agent, event, tool, paths, status, excerpt, bytes, spool_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
561
|
-
.run(row.at, row.project, row.agent, row.event, row.tool, JSON.stringify(row.paths), row.status, row.excerpt, row.bytes, spoolId).lastInsertRowid);
|
|
581
|
+
const id = Number(this.db.query("INSERT INTO rows (at, project, agent, event, tool, paths, status, excerpt, bytes, spool_id, session, subagent) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
582
|
+
.run(row.at, row.project, row.agent, row.event, row.tool, JSON.stringify(row.paths), row.status, row.excerpt, row.bytes, spoolId, row.session ?? null, row.subagent ? 1 : null).lastInsertRowid);
|
|
562
583
|
this.addBytes(row.bytes);
|
|
563
584
|
return id;
|
|
564
585
|
} catch (error) {
|
package/src/memory/commands.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { GoalPromotionReport } from "./goal-promotion.ts";
|
|
|
14
14
|
export interface CommandNames { memory: string; goal: string }
|
|
15
15
|
export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
|
|
16
16
|
|
|
17
|
+
import type { ForgetNearby } from "./forget-nearby.ts";
|
|
18
|
+
import { forgetNearbyText } from "./forget-nearby.ts";
|
|
17
19
|
export interface MemoryCommandOptions {
|
|
18
20
|
/** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
|
|
19
21
|
person?: boolean;
|
|
@@ -35,6 +37,8 @@ export interface MemoryCommandOptions {
|
|
|
35
37
|
importPolicy: () => (text: string) => boolean;
|
|
36
38
|
/** Explicit person-status receipt; model-facing hosts omit this callback. */
|
|
37
39
|
onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
|
|
40
|
+
/** #188: standalone CLI only. Lists same-project machine-local rows near a forgotten note (ids, counts). */
|
|
41
|
+
forgetNearby?: (note: { project: string; text: string; title: string | null }) => Promise<ForgetNearby>;
|
|
38
42
|
}
|
|
39
43
|
export interface GoalCommandOptions {
|
|
40
44
|
/** Defaults to amem's own names. */
|
|
@@ -185,7 +189,12 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
185
189
|
case "demote": { const v = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
|
|
186
190
|
case "forget": { const target = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!);
|
|
187
191
|
if (cliAttribution()?.actor === "agent") for (const member of store.forgetTargets(target.id, project)) visibleTarget(member, member.id);
|
|
188
|
-
const v = need(store.forget(target.id, actor, project), rest[0]!);
|
|
192
|
+
const v = need(store.forget(target.id, actor, project), rest[0]!);
|
|
193
|
+
const remote = v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/purge.md)";
|
|
194
|
+
const nearby = opts.forgetNearby ? await opts.forgetNearby({ project: target.project, text: target.text, title: target.title ?? null }).catch(() => undefined) : undefined;
|
|
195
|
+
// Hosts that do not opt in keep the existing single-line receipt (with or without --json).
|
|
196
|
+
if (json && opts.forgetNearby) return JSON.stringify({ forgot: v.id, local: !!v.local, ...(nearby ? { nearby } : {}) }, null, 2);
|
|
197
|
+
return [`forgot ${v.id.slice(0, 8)}${remote}`, ...(nearby ? forgetNearbyText(nearby) : [])].join("\n"); }
|
|
189
198
|
case "supersede": {
|
|
190
199
|
if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
|
|
191
200
|
if (cliAttribution()?.actor === "agent") for (const id of rest) visibleTarget(store.get(id, project), id);
|
|
@@ -6,7 +6,7 @@ import type { View } from "./store.ts";
|
|
|
6
6
|
export type ConsolidationAction = "merge" | "supersession" | "recurring-fix";
|
|
7
7
|
export interface ConsolidationCluster { action: ConsolidationAction; sources: View[]; reason: string }
|
|
8
8
|
export const CONSOLIDATION_SOURCE_LIMIT = 256, CONSOLIDATION_PROPOSAL_LIMIT = 32;
|
|
9
|
-
const
|
|
9
|
+
export const overlapWords = (row: Pick<View, "title" | "text">) => new Set((`${row.title ?? ""} ${row.text}`.toLowerCase().match(/[\p{L}\p{N}_]{3,}/gu) ?? []).filter(word => !["the", "and", "for", "with", "from", "this", "that", "approach", "symptom", "cause", "evidence", "retry", "when"].includes(word)).slice(0, 128));
|
|
10
10
|
/** A note written by accepting a consolidation candidate. Proposing over one would be a summary of summaries. */
|
|
11
11
|
export function isAcceptedConsolidation(row: Pick<View, "text">): boolean {
|
|
12
12
|
const end = row.text.indexOf("\n\n"); if (end < 0) return false;
|
|
@@ -18,7 +18,7 @@ export function isAcceptedConsolidation(row: Pick<View, "text">): boolean {
|
|
|
18
18
|
}
|
|
19
19
|
const paths = (row: View) => row.about.filter(node => node.startsWith("path:"));
|
|
20
20
|
const sharedPath = (a: View, b: View) => paths(a).some(node => paths(b).includes(node));
|
|
21
|
-
const
|
|
21
|
+
export const wordOverlap = (a: Set<string>, b: Set<string>) => [...a].filter(word => b.has(word)).length / Math.max(1, Math.min(a.size, b.size));
|
|
22
22
|
|
|
23
23
|
/** Bounded deterministic proposals over already-screened rows. Never mutates a note or runs a model. */
|
|
24
24
|
export function consolidationClusters(input: readonly View[], project: string, now: number): { clusters: ConsolidationCluster[]; truncated: boolean; engine: "fts5" | "substring" } {
|
|
@@ -37,12 +37,12 @@ export function consolidationClusters(input: readonly View[], project: string, n
|
|
|
37
37
|
try {
|
|
38
38
|
index.resync(() => rows.map(row => ({ id: row.id, at: row.at, project, tier: row.tier, scope: row.scope, body: `${row.title ?? ""}\n${row.text}` })));
|
|
39
39
|
if (index.available && index.needsResync(rows.length)) throw Error("consolidation index unavailable");
|
|
40
|
-
const terms = new Map(rows.map(row => [row.id,
|
|
40
|
+
const terms = new Map(rows.map(row => [row.id, overlapWords(row)]));
|
|
41
41
|
for (let i = 0; i < rows.length; i++) {
|
|
42
42
|
const row = rows[i]!, candidates = index.available ? new Set(index.query([...terms.get(row.id)!].slice(0, 32).join(" "), rows.length, project).map(hit => hit.ref)) : undefined;
|
|
43
43
|
for (const peer of rows.slice(i + 1)) {
|
|
44
44
|
if (row.kind !== peer.kind) continue;
|
|
45
|
-
const lexical =
|
|
45
|
+
const lexical = wordOverlap(terms.get(row.id)!, terms.get(peer.id)!);
|
|
46
46
|
if (row.text === peer.text) add("supersession", [row, peer], "Identical text; review one replacement for both originals.");
|
|
47
47
|
else if ((!candidates || candidates.has(peer.id)) && lexical >= 0.5 && sharedPath(row, peer)) add("merge", [row, peer], "Shared path and overlapping indexed terms; no equivalence is assumed.");
|
|
48
48
|
}
|
|
@@ -52,8 +52,8 @@ export function consolidationClusters(input: readonly View[], project: string, n
|
|
|
52
52
|
const failRows = rows.filter(row => row.kind === "fail"), decisions = rows.filter(row => row.kind === "decision" || row.kind === "contract");
|
|
53
53
|
for (let i = 0; i < failRows.length; i++) for (const peer of failRows.slice(i + 1)) {
|
|
54
54
|
const fail = failRows[i]!;
|
|
55
|
-
if (!sharedPath(fail, peer) ||
|
|
56
|
-
const fix = decisions.find(row => sharedPath(row, fail) &&
|
|
55
|
+
if (!sharedPath(fail, peer) || wordOverlap(terms.get(fail.id)!, terms.get(peer.id)!) < 0.5) continue;
|
|
56
|
+
const fix = decisions.find(row => sharedPath(row, fail) && wordOverlap(terms.get(row.id)!, terms.get(fail.id)!) >= 0.25);
|
|
57
57
|
if (fix) add("recurring-fix", [fail, peer, fix], "Repeated fail notes and a related decision on the same path; review whether the decision addresses the failures.");
|
|
58
58
|
}
|
|
59
59
|
return { clusters, truncated, engine: index.available ? "fts5" : "substring" };
|
package/src/memory/doctor.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface DoctorReport {
|
|
|
57
57
|
duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
|
|
58
58
|
/** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
|
|
59
59
|
drops: Section;
|
|
60
|
+
/** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
|
|
61
|
+
masked?: Section;
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -64,6 +66,13 @@ const worst = (values: SectionStatus[]): SectionStatus => values.includes("atten
|
|
|
64
66
|
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).split("\n")[0]!.slice(0, 200);
|
|
65
67
|
const unavailable = (reason: string): Section => ({ status: "unavailable", reason });
|
|
66
68
|
const skipped = (reason: string): Section => ({ status: "skipped", reason });
|
|
69
|
+
/** #188: counts only, from the curated copy the doctor already opened. Never raises attention by itself. */
|
|
70
|
+
function maskedSection(curated: MemoryStore | undefined): { masked?: Section } {
|
|
71
|
+
if (!curated) return {};
|
|
72
|
+
const { forgets, prunes } = curated.maskingCounts();
|
|
73
|
+
if (!forgets && !prunes) return {};
|
|
74
|
+
return { masked: { status: "ok", forgets, prunes, note: "masked entries stay in the local log and in sync history; see docs/purge.md" } };
|
|
75
|
+
}
|
|
67
76
|
const ids = (list: string[]): string[] => list.slice(0, MAX_IDS).map(id => id.slice(0, 8));
|
|
68
77
|
const regularDir = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } };
|
|
69
78
|
const regularFile = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isFile() && !stat.isSymbolicLink(); } catch { return false; } };
|
|
@@ -422,7 +431,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
422
431
|
let sections: DoctorReport["sections"];
|
|
423
432
|
try {
|
|
424
433
|
await tick();
|
|
425
|
-
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) };
|
|
434
|
+
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), ...maskedSection(curated) };
|
|
426
435
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
427
436
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
428
437
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -432,6 +441,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
432
441
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
433
442
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
434
443
|
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
444
|
+
masked: "forgotten or pruned entries (informational)",
|
|
435
445
|
};
|
|
436
446
|
|
|
437
447
|
function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
|
|
@@ -22,9 +22,11 @@ export interface ExternalImportResult {
|
|
|
22
22
|
limitReached: boolean;
|
|
23
23
|
skipped: { rules: number; policy: number; invalid: number; truncated: number };
|
|
24
24
|
pending: PendingSummary;
|
|
25
|
+
/** Set only when no source layout exists: the absolute locations that were checked (#177). */
|
|
26
|
+
checked?: string[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
type SourceFile = { path: string; relative: string };
|
|
29
|
+
type SourceFile = { path: string; relative: string; root?: string };
|
|
28
30
|
type RawRecord = { text: string; title?: string; line?: number };
|
|
29
31
|
type Discovery = { files: SourceFile[]; rules: number };
|
|
30
32
|
|
|
@@ -231,12 +233,22 @@ export async function importExternalMemory(opts: {
|
|
|
231
233
|
const root = sourceRoot(source), project = resolveAlias(opts.base, opts.project), aliases = aliasedKeys(opts.base, project);
|
|
232
234
|
const projectDirectory = opts.from === undefined && opts.source === "claude-auto" ? claudeProjectDirectory(opts.cwd ?? process.cwd()) : undefined;
|
|
233
235
|
const discovery = root ? opts.source === "claude-auto" ? discoverClaude(root, projectDirectory) : discoverCodex(root) : { files: [], rules: 0 };
|
|
234
|
-
|
|
236
|
+
// #177: Codex memories v2 (default since 0.165.0) is the sibling root `memories_v2/`. Only the default location adds it;
|
|
237
|
+
// an explicit --from stays one root. v1 discovery, titles and order are unchanged; v2 titles carry a `memories_v2/` prefix.
|
|
238
|
+
const v2Source = opts.from === undefined && opts.source === "codex" ? join(dirname(source), "memories_v2") : undefined;
|
|
239
|
+
const v2Root = v2Source ? sourceRoot(v2Source) : undefined;
|
|
240
|
+
if (v2Root && discovery.files.length < EXTERNAL_IMPORT_LIMITS.maxFiles) {
|
|
241
|
+
const v2 = discoverCodex(v2Root);
|
|
242
|
+
discovery.rules += v2.rules;
|
|
243
|
+
for (const file of v2.files.slice(0, EXTERNAL_IMPORT_LIMITS.maxFiles - discovery.files.length)) discovery.files.push({ ...file, relative: `memories_v2/${file.relative}`, root: v2Root });
|
|
244
|
+
}
|
|
245
|
+
const result: ExternalImportResult = { source: opts.source, project, files: 0, read: 0, added: 0, queueFull: false, limitReached: false, skipped: { rules: discovery.rules, policy: 0, invalid: 0, truncated: 0 }, pending: { count: 0, oldestExpiresInDays: null, dueWithin7Days: 0, unshown: 0 },
|
|
246
|
+
...(!root && !v2Root ? { checked: [source, ...(v2Source ? [v2Source] : [])] } : {}) };
|
|
235
247
|
const store = new CandidateStore({ memoryDir: opts.base, reconcile: { canonical: project, aliases } });
|
|
236
248
|
try {
|
|
237
249
|
for (const file of discovery.files) {
|
|
238
250
|
result.files++;
|
|
239
|
-
const input = readBounded(file.path, root!);
|
|
251
|
+
const input = readBounded(file.path, file.root ?? root!);
|
|
240
252
|
if (!input) { result.skipped.invalid++; continue; }
|
|
241
253
|
if (input.truncated) result.skipped.truncated++;
|
|
242
254
|
for (const sourceRecord of records(file, input.text)) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { machineId } from "./machine.ts";
|
|
6
|
+
import { captureShardDir, shardDir } from "./shard.ts";
|
|
7
|
+
import { CandidateStore, type Candidate } from "./candidates.ts";
|
|
8
|
+
import { overlapWords, wordOverlap } from "./consolidation-clusters.ts";
|
|
9
|
+
import { aliasedKeys } from "./aliases.ts";
|
|
10
|
+
import { WorkListStore } from "./work-lists.ts";
|
|
11
|
+
|
|
12
|
+
/** #188: ids per tier in a forget receipt; the total keeps counting past this. */
|
|
13
|
+
export const FORGET_NEARBY_CAP = 20;
|
|
14
|
+
/** The #174 candidate-overlap floor: half of the smaller word set shared. */
|
|
15
|
+
const CANDIDATE_OVERLAP = 0.5;
|
|
16
|
+
const CAPTURE_PAGE = 256, CAPTURE_PAGES = 8;
|
|
17
|
+
|
|
18
|
+
export interface ForgetNearbyTier { ids: string[]; total: number; truncated: boolean }
|
|
19
|
+
export interface ForgetNearby { capture: ForgetNearbyTier; candidates: ForgetNearbyTier; work: ForgetNearbyTier }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Listing only, for the purge procedure (docs/purge.md step 4): same-project machine-local rows that contain or
|
|
23
|
+
* overlap a forgotten note's text. Ids and counts only, never text. Deterministic, no model or vectors:
|
|
24
|
+
* - capture: a read-only scan of the newest 2048 rows of the project's shard, matching the note text's longest line as a
|
|
25
|
+
* case-insensitive substring of the excerpt (no migration or index build);
|
|
26
|
+
* - candidates: the #174 word-overlap measure (`overlapWords`/`wordOverlap`, >= 0.5) over each candidate's text;
|
|
27
|
+
* - work items: case-insensitive substring of that longest line in the task name or any string field. A task name can
|
|
28
|
+
* itself hold the leak, and so can a list name, so a work item is listed as `work:<sha256(list)[:12]>#<sha256(task)[:12]>`,
|
|
29
|
+
* never by name; a list name holding the text matches every item in it.
|
|
30
|
+
* Rows the screen refuses are neither listed nor counted. Each tier fails open to an empty result.
|
|
31
|
+
*/
|
|
32
|
+
export async function forgetNearby(opts: { base: string; project: string; text: string; title?: string | null; screen: (value: unknown) => boolean | Promise<boolean> }): Promise<ForgetNearby> {
|
|
33
|
+
const needle = probe(opts.text), words = overlapWords({ title: opts.title ?? null, text: opts.text });
|
|
34
|
+
const tier = (): ForgetNearbyTier & { add(id: string): void } => {
|
|
35
|
+
const result = { ids: [] as string[], total: 0, truncated: false, add(id: string) { result.total++; if (result.ids.length < FORGET_NEARBY_CAP) result.ids.push(id); else result.truncated = true; } };
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
const capture = tier(), candidates = tier(), work = tier();
|
|
39
|
+
const captureFile = needle ? join(captureShardDir(opts.base, opts.project, machineId(opts.base).id), "capture.db") : "";
|
|
40
|
+
if (needle && existsSync(captureFile)) {
|
|
41
|
+
try {
|
|
42
|
+
// Listing only: a read-only connection, so a forget never migrates the shard or builds an index as a side effect.
|
|
43
|
+
const db = new Database(captureFile, { readonly: true });
|
|
44
|
+
try {
|
|
45
|
+
db.run("PRAGMA busy_timeout = 200");
|
|
46
|
+
const projects = JSON.stringify([opts.project, ...aliasedKeys(opts.base, opts.project)]);
|
|
47
|
+
// SQLite lower() folds ASCII only; JavaScript's toLowerCase confirms the match for every script.
|
|
48
|
+
const limit = CAPTURE_PAGE * CAPTURE_PAGES, lower = needle.toLowerCase();
|
|
49
|
+
const rows = db.query("SELECT id, at, project, agent, event, tool, paths, status, excerpt FROM rows WHERE project IN (SELECT value FROM json_each(?)) ORDER BY id DESC LIMIT ?").all(projects, limit + 1) as { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }[];
|
|
50
|
+
if (rows.length > limit) capture.truncated = true;
|
|
51
|
+
for (const raw of rows.slice(0, limit).reverse()) {
|
|
52
|
+
if (!raw.excerpt.toLowerCase().includes(lower)) continue;
|
|
53
|
+
let paths: unknown = []; try { paths = JSON.parse(raw.paths); } catch { /* malformed paths are screened as empty */ }
|
|
54
|
+
if (await opts.screen({ ...raw, paths })) capture.add(`capture:${raw.id}`);
|
|
55
|
+
}
|
|
56
|
+
} finally { db.close(); }
|
|
57
|
+
} catch { /* unreadable or contended: nothing to list */ }
|
|
58
|
+
}
|
|
59
|
+
if (words.size && existsSync(join(opts.base, "candidates.db"))) {
|
|
60
|
+
try {
|
|
61
|
+
const store = new CandidateStore({ memoryDir: opts.base, reconcile: { canonical: opts.project, aliases: aliasedKeys(opts.base, opts.project) } });
|
|
62
|
+
try {
|
|
63
|
+
for (const row of store.list({ project: opts.project, status: "all" }).sort((a, b) => a.id.localeCompare(b.id))) {
|
|
64
|
+
const text = candidateText(row); if (!text) continue;
|
|
65
|
+
if (wordOverlap(words, overlapWords(text)) >= CANDIDATE_OVERLAP && await opts.screen(row.input)) candidates.add(row.id);
|
|
66
|
+
}
|
|
67
|
+
} finally { store.close(); }
|
|
68
|
+
} catch { /* no candidates database */ }
|
|
69
|
+
}
|
|
70
|
+
if (needle && [opts.project, ...aliasedKeys(opts.base, opts.project)].some(key => existsSync(join(shardDir(opts.base, key), "work", "log")))) {
|
|
71
|
+
try {
|
|
72
|
+
const store = new WorkListStore({ base: opts.base, project: opts.project, aliases: aliasedKeys(opts.base, opts.project), policy: async row => !!await opts.screen(row) });
|
|
73
|
+
try {
|
|
74
|
+
const lower = needle.toLowerCase();
|
|
75
|
+
for (const list of await store.read(undefined, true)) for (const item of list.items) {
|
|
76
|
+
const haystack = [list.list, item.task, ...Object.values(item.fields).filter((value): value is string => typeof value === "string")].join("\n").toLowerCase();
|
|
77
|
+
if (haystack.includes(lower)) work.add(`work:${shortHash(list.list)}#${shortHash(item.task)}`);
|
|
78
|
+
}
|
|
79
|
+
} finally { store.close(); }
|
|
80
|
+
} catch { /* no work lists */ }
|
|
81
|
+
}
|
|
82
|
+
const plain = (t: ForgetNearbyTier) => ({ ids: t.ids, total: t.total, truncated: t.truncated });
|
|
83
|
+
return { capture: plain(capture), candidates: plain(candidates), work: plain(work) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const shortHash = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
87
|
+
|
|
88
|
+
/** The longest non-empty line, trimmed and bounded: the most specific fragment to look for. */
|
|
89
|
+
function probe(text: string): string {
|
|
90
|
+
const line = text.split(/\r?\n/).map(part => part.trim()).sort((a, b) => b.length - a.length || a.localeCompare(b))[0] ?? "";
|
|
91
|
+
return line.length >= 8 ? line.slice(0, 200) : "";
|
|
92
|
+
}
|
|
93
|
+
function candidateText(row: Candidate): { title: string | null; text: string } | undefined {
|
|
94
|
+
const input = row.input;
|
|
95
|
+
if (input.kind === "memory") return { title: input.title ?? null, text: input.text };
|
|
96
|
+
if (input.kind === "correction") return { title: null, text: input.proposedText };
|
|
97
|
+
if (input.kind === "decay") return { title: null, text: input.reason };
|
|
98
|
+
if (input.kind === "fail") return { title: null, text: [input.approach, input.symptom, input.cause, input.retryWhen].join("\n") };
|
|
99
|
+
if (input.kind === "model-answer" || input.kind === "consolidation") return { title: null, text: input.text };
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Human receipt lines; ids only. */
|
|
103
|
+
export function forgetNearbyText(nearby: ForgetNearby): string[] {
|
|
104
|
+
const line = (label: string, t: ForgetNearbyTier) => t.total ? [`${label}: ${t.total}${t.truncated ? "+" : ""} (${t.ids.join(", ")}${t.total > t.ids.length ? ", ..." : ""})`] : [];
|
|
105
|
+
const lines = [...line("capture rows", nearby.capture), ...line("candidates", nearby.candidates), ...line("work items", nearby.work)];
|
|
106
|
+
return lines.length ? ["Machine-local rows in this project that contain or overlap the forgotten text (not deleted; see docs/purge.md step 4):", ...lines.map(text => ` ${text}`)] : [];
|
|
107
|
+
}
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -249,7 +249,7 @@ export class ScreenedSearchIndex<T> {
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
252
|
+
interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; session?: string | null; subagent?: number | null }
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
|
|
@@ -269,7 +269,9 @@ export function captureSearch(dir: string, q: string, limit: number, notBefore?:
|
|
|
269
269
|
const rows = (notBefore === undefined
|
|
270
270
|
? db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? ORDER BY rank LIMIT ?`).all(match, limit)
|
|
271
271
|
: db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? AND r.at >= ? ORDER BY rank LIMIT ?`).all(match, notBefore, limit)) as (RawCaptureRow & { rank: number })[];
|
|
272
|
-
return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt
|
|
272
|
+
return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
|
|
273
|
+
// #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
|
|
274
|
+
...(typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? { session: r.session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) }, rank: r.rank }));
|
|
273
275
|
} catch { return []; }
|
|
274
276
|
finally { db?.close(); }
|
|
275
277
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -1553,6 +1553,14 @@ export class MemoryStore {
|
|
|
1553
1553
|
return { short: all.filter((v) => v.tier === "short").length, long: all.filter((v) => v.tier === "long").length, segments, machine: this.machine };
|
|
1554
1554
|
}
|
|
1555
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* #188: counts only, for `amem doctor`. Forget operations in the index and, on a generation profile, prune rows.
|
|
1558
|
+
* Masked entries stay in the local log and in sync history; nothing here reads or returns their text.
|
|
1559
|
+
*/
|
|
1560
|
+
maskingCounts(): { forgets: number; prunes: number } {
|
|
1561
|
+
const count = (sql: string) => { try { return Number((this.db.query(sql).get() as { n: number } | null)?.n ?? 0); } catch { return 0; } };
|
|
1562
|
+
return { forgets: count("SELECT COUNT(*) AS n FROM ops WHERE op = 'forget'"), prunes: count("SELECT COUNT(*) AS n FROM generation_prunes") };
|
|
1563
|
+
}
|
|
1556
1564
|
/** The index's rows in a fixed order: what a rebuild must reproduce. */
|
|
1557
1565
|
dump(): string {
|
|
1558
1566
|
return JSON.stringify(["entries", "ops", "edges", "goals"].map((t) => this.db.query(`SELECT * FROM ${t} ORDER BY 1, 2`).all()));
|
package/src/session-brief.ts
CHANGED
|
@@ -199,6 +199,8 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
199
199
|
const page = read({ project, limit: PAGE_ROWS, after });
|
|
200
200
|
for (const row of page.rows) {
|
|
201
201
|
if ((row as { project?: unknown }).project !== project) { coverage[section] = "partial"; continue; }
|
|
202
|
+
// #173: subagent capture stays searchable but never enters the session brief.
|
|
203
|
+
if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
|
|
202
204
|
if (!await screen(row)) continue;
|
|
203
205
|
if (selected++ >= SECTION_RECORDS) { coverage[section] = "partial"; return; }
|
|
204
206
|
await add(section, display(row), section === "curated");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
// Gemini CLI 0.61.0 (google-gemini/gemini-cli v0.61.0; docs/gemini-cli.md).
|
|
4
|
+
// User settings: <GEMINI_CLI_HOME or HOME>/.gemini/settings.json (packages/core/src/utils/paths.ts homedir,
|
|
5
|
+
// packages/core/src/config/storage.ts getGlobalSettingsPath). The Antigravity file
|
|
6
|
+
// <home>/.gemini/config/mcp_config.json is a different client's and is never read or written here.
|
|
7
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GEMINI_CLI_HOME replaces the home directory for all Gemini CLI state. Never throws: the shared setup context
|
|
11
|
+
* computes every target's paths, so a relative value falls back to the home here and is refused only by
|
|
12
|
+
* assertGeminiHome when gemini is actually selected.
|
|
13
|
+
*/
|
|
14
|
+
export function geminiSettingsPath(env: Env, home: string): string {
|
|
15
|
+
const configured = env.GEMINI_CLI_HOME;
|
|
16
|
+
return join(configured && isAbsolute(configured) ? resolve(configured) : home, ".gemini", "settings.json");
|
|
17
|
+
}
|
|
18
|
+
/** A relative GEMINI_CLI_HOME would resolve against Gemini CLI's own cwd, so setup refuses it rather than guess. */
|
|
19
|
+
export function assertGeminiHome(env: Env): void {
|
|
20
|
+
const configured = env.GEMINI_CLI_HOME;
|
|
21
|
+
if (configured !== undefined && configured !== "" && !isAbsolute(configured)) throw Error("setup requires an absolute GEMINI_CLI_HOME; unset it to use the home directory");
|
|
22
|
+
}
|
|
23
|
+
/** Gemini CLI expands $VAR, ${VAR} and ${VAR:-default} inside settings strings (packages/cli/src/utils/envVarResolver.ts). */
|
|
24
|
+
export function assertGeminiRuntime(bun: string, entry: string): void {
|
|
25
|
+
if ([bun, entry].some(value => value.includes("$"))) throw Error("Gemini CLI expands $VAR placeholders in settings strings; setup cannot register this runtime exactly");
|
|
26
|
+
}
|
|
27
|
+
/** Native registration: user scope, stdio, in the private staging HOME (setup.ts stagingEnvironment). */
|
|
28
|
+
export function geminiAddCommand(bun: string, entry: string): string[] {
|
|
29
|
+
return ["gemini", "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
|
|
30
|
+
}
|
|
31
|
+
export const GEMINI_REMOVE: readonly string[] = ["gemini", "mcp", "remove", "--scope", "user", "amem"];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
|
+
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
|
|
4
|
+
// OpenCode v1.18.35 keeps MCP servers under `mcp.<name>` in its global config
|
|
5
|
+
// (packages/opencode/src/config/config.ts loadGlobal; docs/opencode.md).
|
|
6
|
+
// ponytail: strict JSON only. A global opencode.jsonc is refused because its later merge could
|
|
7
|
+
// override the entry; a comment-aware lexical view is the upgrade path.
|
|
8
|
+
const codec = createJsonMcpCodec({ canonicalKey: "mcp", label: "OpenCode strict JSON" });
|
|
9
|
+
export function readOpencodeMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
10
|
+
export function putOpencodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
11
|
+
export function restoreOpencodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
12
|
+
/** The local stdio server record OpenCode starts in the session's project directory (no `cwd`). */
|
|
13
|
+
export function opencodeServer(bun: string, entry: string): Record<string, unknown> {
|
|
14
|
+
return { type: "local", command: [bun, "--smol", entry, "mcp"], enabled: true };
|
|
15
|
+
}
|
package/src/setup-owned.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
|
|
3
3
|
import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
|
|
4
4
|
import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
|
|
5
|
+
import { readOpencodeMcp, putOpencodeMcp, restoreOpencodeMcp } from "./setup-opencode.ts";
|
|
5
6
|
export interface ConfigImage { bytes: string | null; mode: number }
|
|
6
7
|
export const sameImage = (a: ConfigImage, b: ConfigImage): boolean => a.bytes === b.bytes && a.mode === b.mode;
|
|
7
8
|
const text = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
|
|
@@ -18,12 +19,14 @@ function finishJson(value: Record<string, unknown>, current: ConfigImage, origin
|
|
|
18
19
|
}
|
|
19
20
|
export function mcpRecord(target: string, image: ConfigImage): unknown {
|
|
20
21
|
if (target === "agy") return readAgyMcp(image);
|
|
22
|
+
if (target === "opencode") return readOpencodeMcp(image);
|
|
21
23
|
if (target === "jcode") return readJcodeMcp(image);
|
|
22
24
|
const doc = target === "codex" ? toml(image) : json(image), servers = doc[target === "codex" ? "mcp_servers" : "mcpServers"];
|
|
23
25
|
return servers === undefined ? undefined : object(servers).amem;
|
|
24
26
|
}
|
|
25
27
|
export function putMcpRecord(target: string, current: ConfigImage, value: unknown): ConfigImage {
|
|
26
28
|
if (target === "agy") return putAgyMcp(current, value);
|
|
29
|
+
if (target === "opencode") return putOpencodeMcp(current, value);
|
|
27
30
|
if (target === "jcode") return putJcodeMcp(current, value);
|
|
28
31
|
if (target === "codex") return rendered(setTomlPath(text(current), ["mcp_servers", "amem"], value), current);
|
|
29
32
|
const doc = json(current), servers = { ...object(doc.mcpServers ?? {}) };
|
|
@@ -114,6 +117,7 @@ function restoreMcpOwned(target: string, before: ConfigImage, after: ConfigImage
|
|
|
114
117
|
/** Exact restore when untouched; otherwise reverse only this receipt's managed entry/rows. */
|
|
115
118
|
export function restoreOwned(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
116
119
|
if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
|
|
120
|
+
if (target === "opencode" && index === 0) return restoreOpencodeMcp(before, after, current);
|
|
117
121
|
if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
|
|
118
122
|
if (sameImage(current, after)) return before;
|
|
119
123
|
if (sameImage(current, before)) return current;
|