@staix/agent-mem 0.9.1 → 0.11.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 +17 -5
- package/package.json +1 -1
- package/src/cli.ts +22 -9
- package/src/commands-table.ts +5 -4
- package/src/index.ts +1 -0
- package/src/ingest.ts +98 -7
- package/src/mcp-memory.ts +47 -35
- package/src/mcp.ts +1 -1
- package/src/memory/candidates.ts +44 -1
- package/src/memory/capture.ts +24 -6
- package/src/memory/commands.ts +23 -4
- package/src/memory/consolidate.ts +9 -6
- package/src/memory/consolidation-clusters.ts +12 -7
- package/src/memory/eval-fixture.ts +7 -3
- package/src/memory/eval.ts +17 -8
- package/src/memory/external-import.ts +8 -1
- package/src/memory/platform.ts +8 -8
- package/src/memory/policy.ts +62 -3
- package/src/memory/project-import.ts +31 -14
- package/src/memory/search-fts.ts +68 -3
- package/src/memory/search-score.ts +66 -0
- package/src/memory/store.ts +40 -13
- package/src/session-brief-worker.ts +5 -1
- package/src/setup-agy.ts +9 -0
- package/src/setup-jcode.ts +7 -0
- package/src/setup-json.ts +151 -0
- package/src/setup-owned.ts +40 -19
- package/src/setup.ts +147 -34
|
@@ -15,7 +15,7 @@ const NOTE_KEYS = new Set(["schema", "id", "at", "machine", "op", "project", "sc
|
|
|
15
15
|
const OPERATIONS_WARNING = "Supersede, demote, settle and forget operations are not exported; re-import restores authored note rows only.";
|
|
16
16
|
|
|
17
17
|
type ImportedNote = { id: string; at: string; machine: string; note: NoteInput };
|
|
18
|
-
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number> };
|
|
18
|
+
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number>; machine: Record<string, number> };
|
|
19
19
|
export interface ProjectImportResult {
|
|
20
20
|
applied: boolean;
|
|
21
21
|
project: string;
|
|
@@ -46,13 +46,18 @@ function increment(counts: Map<string, number>, value: string | undefined): void
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function provenance(notes: Iterable<ImportedNote>): Provenance {
|
|
49
|
-
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>();
|
|
50
|
-
for (const { note } of notes) {
|
|
49
|
+
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>(), machine = new Map<string, number>();
|
|
50
|
+
for (const { note, machine: origin } of notes) {
|
|
51
51
|
increment(source, note.source);
|
|
52
52
|
increment(peer, note.peer);
|
|
53
53
|
increment(scope, note.scope);
|
|
54
|
+
increment(machine, origin);
|
|
54
55
|
}
|
|
55
|
-
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope) };
|
|
56
|
+
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope), machine: Object.fromEntries(machine) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function relationTargets(imported: ImportedNote): string[] {
|
|
60
|
+
return [imported.note.supersedes, imported.note.promotedFrom].filter((target): target is string => typeof target === "string");
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function unchangedAfterNormalization(note: NoteInput): boolean {
|
|
@@ -71,10 +76,10 @@ export async function importProject(store: MemoryStore, opts: {
|
|
|
71
76
|
const fd = openSync(opts.from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
72
77
|
const stream = createReadStream(opts.from, { fd, encoding: "utf8" });
|
|
73
78
|
const result: ProjectImportResult = { applied: opts.confirm === true, project: opts.project, read: 0, added: 0, wouldAdd: 0,
|
|
74
|
-
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {} }, warnings: [OPERATIONS_WARNING] };
|
|
79
|
+
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {}, machine: {} }, warnings: [OPERATIONS_WARNING] };
|
|
75
80
|
let header = false, bytes = 0;
|
|
76
81
|
let aliases: string[] = [];
|
|
77
|
-
const pending = new Map<string, ImportedNote>(), accepted
|
|
82
|
+
const pending = new Map<string, ImportedNote>(), accepted = new Map<string, ImportedNote>();
|
|
78
83
|
try {
|
|
79
84
|
const stat = fstatSync(fd);
|
|
80
85
|
if (!stat.isFile() || stat.size > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import needs a regular JSONL file of at most 64 MiB");
|
|
@@ -121,22 +126,34 @@ export async function importProject(store: MemoryStore, opts: {
|
|
|
121
126
|
if (!header) throw new Error("project import has no header");
|
|
122
127
|
|
|
123
128
|
for (const imported of pending.values()) {
|
|
124
|
-
if (
|
|
125
|
-
const target = imported.note[field as "supersedes" | "promotedFrom"] as string | null | undefined;
|
|
126
|
-
return target !== undefined && target !== null && !pending.has(target);
|
|
127
|
-
})) {
|
|
129
|
+
if (relationTargets(imported).some(target => !pending.has(target))) {
|
|
128
130
|
result.skipped.invalid++;
|
|
129
131
|
continue;
|
|
130
132
|
}
|
|
131
133
|
const screened = stripPrivateDeep(imported.note);
|
|
132
134
|
if (looksSecret(JSON.stringify(screened)) || !await opts.policy(screened)) { result.skipped.policy++; continue; }
|
|
133
|
-
accepted.
|
|
135
|
+
accepted.set(imported.id, imported);
|
|
136
|
+
}
|
|
137
|
+
// A policy-refused target cannot authorize its dependents. Propagate removals before any append,
|
|
138
|
+
// regardless of file order, in linear time even when a long relation chain is refused.
|
|
139
|
+
const dependents = new Map<string, string[]>(), refused: string[] = [];
|
|
140
|
+
for (const imported of accepted.values()) for (const target of relationTargets(imported)) {
|
|
141
|
+
const rows = dependents.get(target) ?? [];
|
|
142
|
+
rows.push(imported.id); dependents.set(target, rows);
|
|
143
|
+
if (!accepted.has(target)) refused.push(imported.id);
|
|
144
|
+
}
|
|
145
|
+
for (let at = 0; at < refused.length; at++) {
|
|
146
|
+
const id = refused[at]!;
|
|
147
|
+
if (!accepted.delete(id)) continue;
|
|
148
|
+
result.skipped.invalid++;
|
|
149
|
+
for (const dependent of dependents.get(id) ?? []) refused.push(dependent);
|
|
134
150
|
}
|
|
135
|
-
result.provenance = provenance(accepted);
|
|
151
|
+
result.provenance = provenance(accepted.values());
|
|
136
152
|
const existing = new Set(store.portableNotes(opts.project).map(note => note.id));
|
|
137
|
-
result.wouldAdd = accepted.filter(
|
|
153
|
+
result.wouldAdd = [...accepted.keys()].filter(id => !existing.has(id)).length;
|
|
138
154
|
if (opts.confirm === true) {
|
|
139
|
-
|
|
155
|
+
store.assertImportable([...accepted.values()].map(imported => imported.note));
|
|
156
|
+
for (const imported of accepted.values()) {
|
|
140
157
|
const written = store.importNote(imported.note, imported.at, { machine: imported.machine, expectedId: imported.id });
|
|
141
158
|
if (written.added) result.added++;
|
|
142
159
|
}
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { chmodSync, existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -187,6 +188,67 @@ export class SearchIndex {
|
|
|
187
188
|
close(): void { this.db?.close(); }
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
const SCREENED_SCORE = rankedScoreSql(), SCREENED_SUBSTRING_SCORE = rankedScoreSql("-1.0");
|
|
192
|
+
|
|
193
|
+
/** Screened query-local corpus in SQLite-owned TEMP storage, removed even when the process exits. */
|
|
194
|
+
export class ScreenedSearchIndex<T> {
|
|
195
|
+
readonly available = fts5Available();
|
|
196
|
+
private readonly db: Database;
|
|
197
|
+
private closed = false;
|
|
198
|
+
|
|
199
|
+
constructor() {
|
|
200
|
+
let db: Database | undefined;
|
|
201
|
+
try {
|
|
202
|
+
db = new Database(":memory:");
|
|
203
|
+
// SQLite owns the anonymous TEMP files; no named plaintext cache survives a killed MCP child.
|
|
204
|
+
if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
|
|
205
|
+
db.run("PRAGMA temp_store = FILE");
|
|
206
|
+
db.run(this.available
|
|
207
|
+
? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
|
|
208
|
+
: "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
|
|
209
|
+
db.run("PRAGMA temp.cache_size = -2048");
|
|
210
|
+
// A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
|
|
211
|
+
// Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
|
|
212
|
+
db.run("PRAGMA temp.cache_spill = 1");
|
|
213
|
+
this.db = db;
|
|
214
|
+
} catch (error) { db?.close(); throw error; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
|
|
218
|
+
const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
|
|
219
|
+
this.db.transaction(() => {
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
|
|
222
|
+
// Invalid deadlines fail closed; this sentinel predates every representable Date.
|
|
223
|
+
insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
|
|
224
|
+
}
|
|
225
|
+
})();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
best(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
|
|
229
|
+
if (useFts && !ftsTerms(query)) return [];
|
|
230
|
+
const score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
|
|
231
|
+
const instant = new Date(now).toISOString();
|
|
232
|
+
const where = "WHERE (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
|
|
233
|
+
const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
|
|
234
|
+
const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
|
|
235
|
+
const statement = this.db.query(`SELECT payload, ${score} AS score${columns} FROM docs ${where} ORDER BY score DESC, ref LIMIT ?`);
|
|
236
|
+
const rows = (explain ? statement.all(instant, instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)
|
|
237
|
+
: statement.all(instant, now, ...(useFts ? [ftsTerms(query)] : []), limit)) as { payload: string; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }[];
|
|
238
|
+
return rows.map(row => {
|
|
239
|
+
const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
|
|
240
|
+
if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
|
|
241
|
+
return hit;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
close(): void {
|
|
246
|
+
if (this.closed) return;
|
|
247
|
+
this.closed = true;
|
|
248
|
+
this.db.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
190
252
|
interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
191
253
|
|
|
192
254
|
/**
|
|
@@ -222,20 +284,23 @@ export interface ReadWindow {
|
|
|
222
284
|
anchor?: string;
|
|
223
285
|
prefix?: string;
|
|
224
286
|
id?: string;
|
|
287
|
+
/** Exhaustive screened scans need stable identity order, not a chronological relevance window. */
|
|
288
|
+
order?: "id";
|
|
225
289
|
after?: { position: number; id: string };
|
|
226
290
|
}
|
|
227
291
|
export interface ReadRef { id: string; at: string; position: number }
|
|
228
292
|
/** Fixed column names supplied by the three derived stores; all caller values remain SQL parameters. */
|
|
229
|
-
export function readWindowSql(window: ReadWindow, id: "id" | "ref") {
|
|
293
|
+
export function readWindowSql(window: ReadWindow, id: "id" | "ref", identity: "text" | "integer" = "text") {
|
|
230
294
|
if (!Number.isSafeInteger(window.limit) || window.limit < 1 || window.limit > 256) throw new Error("invalid read page limit");
|
|
231
295
|
const bind: (string | number)[] = [];
|
|
232
|
-
const position = window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
|
|
296
|
+
const position = window.order === "id" ? (identity === "integer" ? id : "0") : window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
|
|
233
297
|
const columns = `CAST(${id} AS TEXT) AS candidate_id, at, ${position} AS candidate_position`;
|
|
234
298
|
const where = ["project IN (SELECT value FROM json_each(?))"]; bind.push(JSON.stringify(window.projects ?? [window.project]));
|
|
235
299
|
if (window.since) { where.push("julianday(at) >= julianday(?)"); bind.push(window.since); }
|
|
236
300
|
if (window.id !== undefined) { where.push(`${id} = ?`); bind.push(window.id); }
|
|
237
301
|
if (window.prefix !== undefined) { where.push(`substr(${id}, 1, length(?)) = ?`); bind.push(window.prefix, window.prefix); }
|
|
238
|
-
if (window.after) {
|
|
302
|
+
if (window.after && window.order === "id") { where.push(`${id} > ?`); bind.push(window.after.id); }
|
|
303
|
+
else if (window.after) {
|
|
239
304
|
where.push("(candidate_position > ? OR (candidate_position = ? AND candidate_id > ?))");
|
|
240
305
|
bind.push(window.after.position, window.after.position, window.after.id);
|
|
241
306
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** One fixed score recipe for numeric library ranking and native screened SQL. */
|
|
2
|
+
export interface ScoreFactors {
|
|
3
|
+
rawBm25Rank: number | null;
|
|
4
|
+
tierFactor: number | null;
|
|
5
|
+
recencyFactor: number | null;
|
|
6
|
+
finalScore: number | null;
|
|
7
|
+
matchMode: "fts5" | "substring";
|
|
8
|
+
}
|
|
9
|
+
export interface SearchScoreDetails extends ScoreFactors { source: "curated" | "capture" | "repo" }
|
|
10
|
+
|
|
11
|
+
type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: number }
|
|
12
|
+
| { op: "negative" | "reciprocal"; value: ScoreExpr }
|
|
13
|
+
| { op: "add" | "multiply" | "max"; left: ScoreExpr; right: ScoreExpr };
|
|
14
|
+
const MONTH_DAYS = 30;
|
|
15
|
+
const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
|
|
16
|
+
const DEFAULT_TIER_WEIGHT = 0.9;
|
|
17
|
+
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
18
|
+
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
|
19
|
+
const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
|
|
20
|
+
const SCORE: ScoreExpr = { op: "multiply", left: { op: "multiply", left: { op: "negative", value: { op: "rank" } }, right: TIER_FACTOR }, right: RECENCY_FACTOR };
|
|
21
|
+
// ponytail: this represents only the fixed rank/tier/age recipe, not arbitrary expressions;
|
|
22
|
+
// extend its operations with numeric/SQL parity tests when the ranking contract changes.
|
|
23
|
+
function numeric(expr: ScoreExpr, rank: number, tier: string, at: string, now: number): number {
|
|
24
|
+
switch (expr.op) {
|
|
25
|
+
case "rank": return rank;
|
|
26
|
+
case "tier": return Object.hasOwn(TIER_WEIGHTS, tier) ? TIER_WEIGHTS[tier]! : DEFAULT_TIER_WEIGHT;
|
|
27
|
+
case "age": return (now - Date.parse(at)) / (MONTH_DAYS * 24 * 3600_000);
|
|
28
|
+
case "constant": return expr.value;
|
|
29
|
+
case "negative": return -numeric(expr.value, rank, tier, at, now);
|
|
30
|
+
case "reciprocal": return 1 / numeric(expr.value, rank, tier, at, now);
|
|
31
|
+
case "add": return numeric(expr.left, rank, tier, at, now) + numeric(expr.right, rank, tier, at, now);
|
|
32
|
+
case "multiply": return numeric(expr.left, rank, tier, at, now) * numeric(expr.right, rank, tier, at, now);
|
|
33
|
+
case "max": return Math.max(numeric(expr.left, rank, tier, at, now), numeric(expr.right, rank, tier, at, now));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const real = (value: number): string => Number.isInteger(value) ? `${value}.0` : String(value);
|
|
37
|
+
function sql(expr: ScoreExpr, rank: string): string {
|
|
38
|
+
const get = (part: ScoreExpr): string => sql(part, rank);
|
|
39
|
+
switch (expr.op) {
|
|
40
|
+
case "rank": return rank;
|
|
41
|
+
case "tier": return `CASE tier ${Object.entries(TIER_WEIGHTS).map(([name, value]) => `WHEN '${name}' THEN ${real(value)}`).join(" ")} ELSE ${real(DEFAULT_TIER_WEIGHT)} END`;
|
|
42
|
+
case "age": return `((julianday(?) - julianday(at)) / ${real(MONTH_DAYS)})`;
|
|
43
|
+
case "constant": return real(expr.value);
|
|
44
|
+
case "negative": return `(-(${get(expr.value)}))`;
|
|
45
|
+
case "reciprocal": return `(1.0 / ${get(expr.value)})`;
|
|
46
|
+
case "add": return `(${get(expr.left)} + ${get(expr.right)})`;
|
|
47
|
+
// Preserve the existing SQL quotient's floating-point order; numeric ranking
|
|
48
|
+
// retains its original reciprocal product. Both interpret the same recipe.
|
|
49
|
+
case "multiply": return expr.right.op === "reciprocal" ? `(${get(expr.left)} / ${get(expr.right.value)})` : `(${get(expr.left)} * ${get(expr.right)})`;
|
|
50
|
+
case "max": return `max(${get(expr.left)}, ${get(expr.right)})`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function rankedScore(rank: number, tier: string, at: string, now: number): number {
|
|
54
|
+
return numeric(SCORE, rank, tier, at, now);
|
|
55
|
+
}
|
|
56
|
+
/** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
|
|
57
|
+
export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
|
|
58
|
+
export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
|
|
59
|
+
return { rawBm25Rank: useFts ? rank : "NULL", tierFactor: sql(TIER_FACTOR, rank), recencyFactor: sql(RECENCY_FACTOR, rank) };
|
|
60
|
+
}
|
|
61
|
+
export function rankedScoreDetails(source: SearchScoreDetails["source"], rank: number, tier: string, at: string, now: number, finalScore: number): SearchScoreDetails {
|
|
62
|
+
return { source, rawBm25Rank: rank, tierFactor: numeric(TIER_FACTOR, rank, tier, at, now), recencyFactor: numeric(RECENCY_FACTOR, rank, tier, at, now), finalScore, matchMode: "fts5" };
|
|
63
|
+
}
|
|
64
|
+
export function unrankedScoreDetails(source: SearchScoreDetails["source"], finalScore: number | null): SearchScoreDetails {
|
|
65
|
+
return { source, rawBm25Rank: null, tierFactor: null, recencyFactor: null, finalScore, matchMode: "substring" };
|
|
66
|
+
}
|
package/src/memory/store.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { memoryHome } from "./home.ts";
|
|
|
10
10
|
import { machineId, type Machine } from "./machine.ts";
|
|
11
11
|
import { assertProjectActive } from "./lifecycle.ts";
|
|
12
12
|
export { machineId, type Machine } from "./machine.ts";
|
|
13
|
+
import { rankedScore, rankedScoreDetails, unrankedScoreDetails, type SearchScoreDetails } from "./search-score.ts";
|
|
14
|
+
export type { SearchScoreDetails } from "./search-score.ts";
|
|
13
15
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
14
16
|
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
15
17
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
@@ -168,10 +170,12 @@ export interface Filter { project?: string; tier?: Tier; kind?: Kind; peer?: str
|
|
|
168
170
|
/** #13 search: `Filter` plus the capture-tier switch (on by default; capture is recall, never curated evidence). */
|
|
169
171
|
export interface SearchFilter extends Filter {
|
|
170
172
|
includeCapture?: boolean;
|
|
173
|
+
/** Attach score factors only when explicitly requested. */
|
|
174
|
+
explain?: boolean;
|
|
171
175
|
/** A host that writes capture with a `retentionDays` override passes the same value here; files decide otherwise. */
|
|
172
176
|
captureRetentionDays?: number;
|
|
173
177
|
}
|
|
174
|
-
export interface SearchHit { source: "curated" | "capture"; score: number; view?: View; capture?: CaptureRow }
|
|
178
|
+
export interface SearchHit { source: "curated" | "capture"; score: number; scoreDetails?: SearchScoreDetails; view?: View; capture?: CaptureRow }
|
|
175
179
|
export interface SearchResult { engine: "fts5" | "substring"; hits: SearchHit[] }
|
|
176
180
|
export interface Graph { nodes: { id: Node; type: string; label: string; kind?: string; tier?: Tier }[]; edges: { from: Node; to: Node; rel: string }[] }
|
|
177
181
|
|
|
@@ -490,18 +494,30 @@ export class MemoryStore {
|
|
|
490
494
|
// `ownClock`: a note asked for short-term on purpose expires on its own clock, open task or not, and is never promoted.
|
|
491
495
|
note(n: NoteInput, ownClock = false): string { return this.append(n.tier === "short" && !n.expires && (ownClock || !n.task || this.settled(n.task)) ? { ...n, expires: this.until() } : n, n.tier === "short"); }
|
|
492
496
|
|
|
493
|
-
/**
|
|
494
|
-
|
|
495
|
-
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
496
|
-
*/
|
|
497
|
-
importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
|
|
497
|
+
/** The already-opened owner of an imported row, shared by preflight and append. */
|
|
498
|
+
private importOwner(n: NoteInput): MemoryStore {
|
|
498
499
|
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
499
|
-
if (this.userOverlay && n.scope === "shared") return this.userOverlay.
|
|
500
|
+
if (this.userOverlay && n.scope === "shared") return this.userOverlay.importOwner(n);
|
|
500
501
|
if (this.profile.sharded && !this.profile.shardUser && n.scope === "project" && n.project !== this.profile.shardKey) {
|
|
501
502
|
const owner = this.aliasStores.find(store => store.profile.shardKey === n.project);
|
|
502
|
-
if (owner) return owner.
|
|
503
|
+
if (owner) return owner.importOwner(n);
|
|
503
504
|
}
|
|
504
505
|
if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
|
|
506
|
+
return this;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Check every import route before a multi-row caller appends; never creates a missing alias shard. */
|
|
510
|
+
assertImportable(notes: Iterable<NoteInput>): void {
|
|
511
|
+
for (const note of notes) this.importOwner(note);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
|
|
516
|
+
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
517
|
+
*/
|
|
518
|
+
importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
|
|
519
|
+
const owner = this.importOwner(n);
|
|
520
|
+
if (owner !== this) return owner.importNote(n, at, identity);
|
|
505
521
|
assertSharedV1Op(n.op);
|
|
506
522
|
// A fixed machine: the same observation imported on two machines is one entry once they sync. A project import preserves the exported identity instead.
|
|
507
523
|
const body = { schema: MEMORY_SCHEMA, at, machine: identity.machine ?? "claude-mem", ...n, text: stripPrivateSpans(n.text).slice(0, MAX_TEXT), ...(n.title ? { title: stripPrivateSpans(n.title).slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
@@ -1057,12 +1073,15 @@ export class MemoryStore {
|
|
|
1057
1073
|
*/
|
|
1058
1074
|
search(q: string, f: SearchFilter = {}): SearchResult {
|
|
1059
1075
|
const limit = f.limit ?? 200;
|
|
1060
|
-
if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map(
|
|
1076
|
+
if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map(view => {
|
|
1077
|
+
const hit: SearchHit = { source: "curated", score: 0, view };
|
|
1078
|
+
if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
|
|
1079
|
+
return hit;
|
|
1080
|
+
}) };
|
|
1061
1081
|
const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
|
|
1062
1082
|
&& (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
|
|
1063
1083
|
for (const store of this.readStores()) store.syncSearchIndex();
|
|
1064
1084
|
const now = this.now();
|
|
1065
|
-
const recency = (at: string): number => 1 / (1 + Math.max(0, now - Date.parse(at)) / (30 * 24 * 3600_000));
|
|
1066
1085
|
const hits: SearchHit[] = [];
|
|
1067
1086
|
// The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
|
|
1068
1087
|
// starve this project's LIMIT.
|
|
@@ -1082,7 +1101,9 @@ export class MemoryStore {
|
|
|
1082
1101
|
if (!view || seen.has(hit.ref)) continue;
|
|
1083
1102
|
seen.add(hit.ref);
|
|
1084
1103
|
found++;
|
|
1085
|
-
|
|
1104
|
+
const selected: SearchHit = { source: "curated", score: rankedScore(hit.rank, view.tier, hit.at, now), view };
|
|
1105
|
+
if (f.explain) selected.scoreDetails = rankedScoreDetails("curated", hit.rank, view.tier, hit.at, now, selected.score);
|
|
1106
|
+
hits.push(selected);
|
|
1086
1107
|
}
|
|
1087
1108
|
if (ranked.length < page) break;
|
|
1088
1109
|
}
|
|
@@ -1097,12 +1118,18 @@ export class MemoryStore {
|
|
|
1097
1118
|
const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
|
|
1098
1119
|
const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
|
|
1099
1120
|
for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
|
|
1100
|
-
|
|
1121
|
+
const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
|
|
1122
|
+
if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
|
|
1123
|
+
hits.push(selected);
|
|
1101
1124
|
}
|
|
1102
1125
|
}
|
|
1103
1126
|
// Where FTS misses entirely (a stopword-only query, a tokenization edge), the substring filter is the safety net.
|
|
1104
1127
|
if (!hits.length && q.trim()) {
|
|
1105
|
-
for (const view of this.list({ ...f, q }))
|
|
1128
|
+
for (const view of this.list({ ...f, q })) {
|
|
1129
|
+
const hit: SearchHit = { source: "curated", score: 0, view };
|
|
1130
|
+
if (f.explain) hit.scoreDetails = unrankedScoreDetails("curated", hit.score);
|
|
1131
|
+
hits.push(hit);
|
|
1132
|
+
}
|
|
1106
1133
|
}
|
|
1107
1134
|
const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
|
|
1108
1135
|
hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
|
|
@@ -50,7 +50,11 @@ if (import.meta.main) {
|
|
|
50
50
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
|
|
51
51
|
const expectedScope = hookCaptureScope(options.cwd, options.base!);
|
|
52
52
|
let project: string;
|
|
53
|
-
|
|
53
|
+
// Overlap the shared resolver's existing origin/metadata reads on a cold cache;
|
|
54
|
+
// serial native launches can exhaust every hook before any binding or replay is published.
|
|
55
|
+
// ponytail: cold identity must still fit one hook deadline. Revalidated durable
|
|
56
|
+
// identity progress is the upgrade if slower native reads keep exhausting that slice.
|
|
57
|
+
try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart", prefetch: true }, nativeUsage)); }
|
|
54
58
|
catch {
|
|
55
59
|
if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
|
|
56
60
|
project = stop();
|
package/src/setup-agy.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
|
+
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
|
|
4
|
+
// ponytail: Antigravity setup supports strict JSON only. JSONC needs a comment-aware
|
|
5
|
+
// lexical view and trailing-comma ownership before byte-preserving edits can support it.
|
|
6
|
+
const codec = createJsonMcpCodec({ canonicalKey: "mcpServers", label: "Antigravity strict JSON" });
|
|
7
|
+
export function readAgyMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
8
|
+
export function putAgyMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
9
|
+
export function restoreAgyMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
|
+
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
|
|
4
|
+
const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
|
|
5
|
+
export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
6
|
+
export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
7
|
+
export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
3
|
+
import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
|
|
4
|
+
|
|
5
|
+
interface Member { key: string; keyStart: number; keyEnd: number; valueStart: number; valueEnd: number; comma?: number }
|
|
6
|
+
interface ObjectSpan { close: number; members: Member[] }
|
|
7
|
+
interface Document { text: string; root: ObjectSpan; container?: Member; servers?: ObjectSpan; owned?: Member }
|
|
8
|
+
interface Edit { start: number; end: number; text: string }
|
|
9
|
+
const decode = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
10
|
+
const same = (a: ConfigImage, b: ConfigImage) => a.bytes === b.bytes && a.mode === b.mode;
|
|
11
|
+
export interface JsonMcpCodecOptions { canonicalKey: string; aliases?: readonly string[]; label: string }
|
|
12
|
+
|
|
13
|
+
/** Strict JSON only. Shared lexical edits never re-render unrelated configuration data. */
|
|
14
|
+
export function createJsonMcpCodec(options: JsonMcpCodecOptions) {
|
|
15
|
+
const { canonicalKey, label } = options, keys = new Set([canonicalKey, ...(options.aliases ?? [])]);
|
|
16
|
+
const fail = (): never => { throw Error(`setup ${label} MCP configuration unavailable or ambiguous; no configuration changed`); };
|
|
17
|
+
const whitespace = (text: string, start: number) => { let at = start; while (at < text.length && /[ \t\r\n]/.test(text[at]!)) at++; return at; };
|
|
18
|
+
function stringEnd(text: string, start: number): number {
|
|
19
|
+
let at = start + 1;
|
|
20
|
+
while (at < text.length) { if (text[at] === "\\") at += 2; else if (text[at++] === '"') return at; }
|
|
21
|
+
return fail();
|
|
22
|
+
}
|
|
23
|
+
/** JSON.parse validates syntax first; this scanner retains only lexical edit boundaries. */
|
|
24
|
+
function valueEnd(text: string, start: number): number {
|
|
25
|
+
if (text[start] === '"') return stringEnd(text, start);
|
|
26
|
+
if (text[start] === "{" || text[start] === "[") {
|
|
27
|
+
let depth = 0, at = start;
|
|
28
|
+
while (at < text.length) {
|
|
29
|
+
const char = text[at]!;
|
|
30
|
+
if (char === '"') { at = stringEnd(text, at); continue; }
|
|
31
|
+
if (char === "{" || char === "[") depth++;
|
|
32
|
+
else if ((char === "}" || char === "]") && --depth === 0) return at + 1;
|
|
33
|
+
at++;
|
|
34
|
+
}
|
|
35
|
+
return fail();
|
|
36
|
+
}
|
|
37
|
+
let at = start; while (at < text.length && !/[ \t\r\n,}\]]/.test(text[at]!)) at++; return at;
|
|
38
|
+
}
|
|
39
|
+
function objectSpan(text: string, start: number): ObjectSpan {
|
|
40
|
+
if (text[start] !== "{") return fail();
|
|
41
|
+
const members: Member[] = []; let at = whitespace(text, start + 1);
|
|
42
|
+
while (text[at] !== "}") {
|
|
43
|
+
const keyStart = at, keyEnd = stringEnd(text, at), key: unknown = JSON.parse(text.slice(keyStart, keyEnd));
|
|
44
|
+
if (typeof key !== "string") return fail();
|
|
45
|
+
const valueStart = whitespace(text, whitespace(text, keyEnd) + 1), end = valueEnd(text, valueStart), next = whitespace(text, end);
|
|
46
|
+
const comma = text[next] === "," ? next : undefined;
|
|
47
|
+
members.push({ key, keyStart, keyEnd, valueStart, valueEnd: end, ...(comma === undefined ? {} : { comma }) });
|
|
48
|
+
at = comma === undefined ? next : whitespace(text, comma + 1);
|
|
49
|
+
}
|
|
50
|
+
return { close: at, members };
|
|
51
|
+
}
|
|
52
|
+
/** Only the owned value requires recursive key uniqueness; unrelated JSON stays opaque. */
|
|
53
|
+
function uniqueOwnedKeys(text: string, member: Member): void {
|
|
54
|
+
const objects: (Set<string> | null)[] = [];
|
|
55
|
+
let at = member.valueStart;
|
|
56
|
+
while (at < member.valueEnd) {
|
|
57
|
+
const char = text[at]!;
|
|
58
|
+
if (char === '"') {
|
|
59
|
+
const end = stringEnd(text, at), current = objects.at(-1);
|
|
60
|
+
if (text[whitespace(text, end)] === ":" && current) {
|
|
61
|
+
const key = JSON.parse(text.slice(at, end)) as string;
|
|
62
|
+
if (current.has(key)) return fail();
|
|
63
|
+
current.add(key);
|
|
64
|
+
}
|
|
65
|
+
at = end;
|
|
66
|
+
} else {
|
|
67
|
+
if (char === "{") objects.push(new Set());
|
|
68
|
+
else if (char === "[") objects.push(null);
|
|
69
|
+
else if (char === "}" || char === "]") objects.pop();
|
|
70
|
+
at++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function document(image: ConfigImage): Document {
|
|
75
|
+
let text = "{}";
|
|
76
|
+
if (image.bytes !== null) {
|
|
77
|
+
if (image.bytes.length > Math.ceil(SETUP_CONFIG_BYTES / 3) * 4) return fail();
|
|
78
|
+
const bytes = Buffer.from(image.bytes, "base64");
|
|
79
|
+
if (bytes.toString("base64") !== image.bytes || bytes.length > SETUP_CONFIG_BYTES) return fail();
|
|
80
|
+
try { text = decode.decode(bytes); } catch { return fail(); }
|
|
81
|
+
}
|
|
82
|
+
let value: unknown; try { value = JSON.parse(text); } catch { return fail(); }
|
|
83
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
|
|
84
|
+
const root = objectSpan(text, whitespace(text, 0));
|
|
85
|
+
const containers = root.members.filter(member => keys.has(member.key));
|
|
86
|
+
if (containers.length > 1) return fail();
|
|
87
|
+
const container = containers[0]; if (!container) return { text, root };
|
|
88
|
+
const servers = objectSpan(text, container.valueStart), owned = servers.members.filter(member => member.key === "amem");
|
|
89
|
+
if (owned.length > 1) return fail();
|
|
90
|
+
if (owned[0]) uniqueOwnedKeys(text, owned[0]);
|
|
91
|
+
return { text, root, container, servers, ...(owned[0] ? { owned: owned[0] } : {}) };
|
|
92
|
+
}
|
|
93
|
+
const entry = (doc: Document): unknown => doc.owned ? JSON.parse(doc.text.slice(doc.owned.valueStart, doc.owned.valueEnd)) : undefined;
|
|
94
|
+
function readMcp(image: ConfigImage): unknown { return entry(document(image)); }
|
|
95
|
+
|
|
96
|
+
function insert(object: ObjectSpan, text: string): Edit {
|
|
97
|
+
const last = object.members.at(-1);
|
|
98
|
+
const at = last?.valueEnd ?? object.close;
|
|
99
|
+
return { start: at, end: at, text: `${last ? "," : ""}${text}` };
|
|
100
|
+
}
|
|
101
|
+
function remove(object: ObjectSpan, member: Member): Edit {
|
|
102
|
+
if (member.comma !== undefined) return { start: member.keyStart, end: member.comma + 1, text: "" };
|
|
103
|
+
const previous = object.members[object.members.indexOf(member) - 1];
|
|
104
|
+
return { start: previous?.comma ?? member.keyStart, end: member.valueEnd, text: "" };
|
|
105
|
+
}
|
|
106
|
+
function publish(image: ConfigImage, doc: Document, edits: Edit[]): ConfigImage {
|
|
107
|
+
let text = doc.text;
|
|
108
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
109
|
+
if (text === doc.text) return image;
|
|
110
|
+
if (Buffer.byteLength(text) > SETUP_CONFIG_BYTES) return fail();
|
|
111
|
+
const result = { bytes: Buffer.from(text).toString("base64"), mode: image.mode };
|
|
112
|
+
document(result); return result;
|
|
113
|
+
}
|
|
114
|
+
function put(image: ConfigImage, value: unknown, options: { key?: string; keepEmpty?: boolean; rawValue?: string; emptyContainer?: string } = {}): ConfigImage {
|
|
115
|
+
const doc = document(image), edits: Edit[] = [], key = options.key ?? canonicalKey;
|
|
116
|
+
if (!doc.container) {
|
|
117
|
+
if (value === undefined && !options.keepEmpty) return image;
|
|
118
|
+
const body = value === undefined ? options.emptyContainer ?? "{}" : `{"amem":${options.rawValue ?? JSON.stringify(value)}}`;
|
|
119
|
+
edits.push(insert(doc.root, `${JSON.stringify(key)}:${body}`));
|
|
120
|
+
} else if (value === undefined && !options.keepEmpty && doc.servers!.members.every(member => member.key === "amem")) {
|
|
121
|
+
edits.push(remove(doc.root, doc.container));
|
|
122
|
+
} else {
|
|
123
|
+
if (doc.container.key !== key) edits.push({ start: doc.container.keyStart, end: doc.container.keyEnd, text: JSON.stringify(key) });
|
|
124
|
+
if (value === undefined && doc.owned) edits.push(remove(doc.servers!, doc.owned));
|
|
125
|
+
else if (value !== undefined && !isDeepStrictEqual(entry(doc), value)) {
|
|
126
|
+
const rendered = options.rawValue ?? JSON.stringify(value); if (rendered === undefined) return fail();
|
|
127
|
+
edits.push(doc.owned ? { start: doc.owned.valueStart, end: doc.owned.valueEnd, text: rendered } : insert(doc.servers!, `"amem":${rendered}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return publish(image, doc, edits);
|
|
131
|
+
}
|
|
132
|
+
/** Canonicalize only the owned container key; all unrelated bytes remain untouched. */
|
|
133
|
+
function putMcp(image: ConfigImage, value: unknown): ConfigImage { return put(image, value); }
|
|
134
|
+
|
|
135
|
+
/** Restore the owned entry (and our alias rename), preserving unrelated lexical edits. */
|
|
136
|
+
function restoreMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
137
|
+
const original = document(before), installed = document(after), live = document(current);
|
|
138
|
+
if (same(current, after)) return before;
|
|
139
|
+
if (same(current, before)) return current;
|
|
140
|
+
const previous = entry(original), expected = entry(installed), present = entry(live);
|
|
141
|
+
const previousRestored = isDeepStrictEqual(present, previous);
|
|
142
|
+
const pendingLayout = !!original.container && !!installed.container && original.container.key !== installed.container.key && live.container?.key === installed.container.key;
|
|
143
|
+
if (previousRestored && !pendingLayout) return current;
|
|
144
|
+
if (!previousRestored && !isDeepStrictEqual(present, expected)) throw Error(`setup-owned ${label} MCP entry changed; refusing to overwrite it`);
|
|
145
|
+
const restored = put(current, previous, { key: original.container?.key ?? canonicalKey, keepEmpty: !!original.container && !original.servers!.members.length,
|
|
146
|
+
...(original.owned ? { rawValue: original.text.slice(original.owned.valueStart, original.owned.valueEnd) } : {}),
|
|
147
|
+
...(original.container && !original.servers!.members.length ? { emptyContainer: original.text.slice(original.container.valueStart, original.container.valueEnd) } : {}) });
|
|
148
|
+
return before.bytes === null && !document(restored).root.members.length ? { bytes: null, mode: current.mode } : restored;
|
|
149
|
+
}
|
|
150
|
+
return { read: readMcp, put: putMcp, restore: restoreMcp };
|
|
151
|
+
}
|