pi-chalin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/memory.ts ADDED
@@ -0,0 +1,945 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import initSqlJs from "sql.js-fts5/dist/sql-asm.js";
4
+ import { resolveChalinPaths, type ChalinPathsOptions } from "./paths.ts";
5
+ import type { AgentConcern, MemoryAuditEvent, MemoryAuditEventType, MemoryCandidate, MemoryRecord } from "./schemas.ts";
6
+
7
+ export interface MemorySearchResult {
8
+ record: MemoryRecord;
9
+ score: number;
10
+ highlights: string[];
11
+ }
12
+
13
+ export interface MemoryContextRequest {
14
+ query: string;
15
+ sourceAgent?: string;
16
+ agentConcern?: AgentConcern;
17
+ limit?: number;
18
+ tokenBudget?: number;
19
+ includeEvidence?: boolean;
20
+ }
21
+
22
+ export interface MemoryContextBundle {
23
+ text: string;
24
+ results: MemorySearchResult[];
25
+ tokenBudget: number;
26
+ estimatedTokens: number;
27
+ omitted: number;
28
+ }
29
+
30
+ export interface MemoryRevisionInput {
31
+ category?: string;
32
+ content: string;
33
+ sourceAgent: string;
34
+ confidence?: number;
35
+ evidence?: string;
36
+ scope?: "project" | "user";
37
+ topicKey?: string;
38
+ reason?: string;
39
+ }
40
+
41
+ type SqlJsStatic = any;
42
+ type SqlJsDatabase = any;
43
+
44
+ let sqlModulePromise: Promise<SqlJsStatic> | undefined;
45
+
46
+ export class MemoryStore {
47
+ private readonly dbPath: string;
48
+
49
+ constructor(options: ChalinPathsOptions) {
50
+ this.dbPath = path.join(resolveChalinPaths(options).projectRoot, ".pi-chalin", "memory.sqlite");
51
+ }
52
+
53
+ async submitCandidates(candidates: MemoryCandidate[]): Promise<MemoryRecord[]> {
54
+ const now = new Date().toISOString();
55
+ const records = dedupeCandidates(candidates).map((candidate) => buildMemoryRecord(candidate, now));
56
+
57
+ await this.withDb(true, (db) => {
58
+ const existingRecords = selectRows(db, "SELECT * FROM memory_records ORDER BY createdAt DESC")
59
+ .map(rowToRecord)
60
+ .map(applyCurrentPolicy)
61
+ .filter((record): record is MemoryRecord => Boolean(record));
62
+ const accepted = [...existingRecords];
63
+ try {
64
+ for (const record of records) {
65
+ if (record.status === "rejected") {
66
+ appendMemoryEvent(db, {
67
+ recordId: record.id,
68
+ type: "reject",
69
+ actor: record.sourceAgent,
70
+ at: now,
71
+ summary: `Rejected memory candidate during WriteGuard: ${record.trigger}`,
72
+ nextContent: record.content,
73
+ });
74
+ continue;
75
+ }
76
+ const target = findMemoryUpdateTarget(record, accepted);
77
+ const next = target ? mergeMemoryRecord(target, record, now) : record;
78
+ upsertMemoryRecord(db, next);
79
+ appendMemoryEvent(db, {
80
+ recordId: next.id,
81
+ type: target ? (normalizeForDedupe(target.content) === normalizeForDedupe(record.content) ? "duplicate" : "revise") : "create",
82
+ actor: record.sourceAgent,
83
+ at: now,
84
+ summary: target ? "Memory candidate merged into an existing record." : "Memory candidate accepted by WriteGuard.",
85
+ previousContent: target?.content,
86
+ nextContent: next.content,
87
+ metadata: { category: next.category, status: next.status, topicKey: next.topicKey },
88
+ });
89
+ const index = accepted.findIndex((item) => item.id === next.id);
90
+ if (index >= 0) accepted[index] = next;
91
+ else accepted.push(next);
92
+ }
93
+ } finally {
94
+ // Prepared statements live inside upsertMemoryRecord to keep mutation paths simple and atomic per record.
95
+ }
96
+ });
97
+
98
+ return records;
99
+ }
100
+
101
+ async list(status?: MemoryRecord["status"]): Promise<MemoryRecord[]> {
102
+ return this.withDb(false, (db) => {
103
+ const rows = selectRows(
104
+ db,
105
+ status ? "SELECT * FROM memory_records WHERE status = ? ORDER BY createdAt DESC" : "SELECT * FROM memory_records ORDER BY createdAt DESC",
106
+ status ? [status] : [],
107
+ );
108
+ return sortMemoryRecords(dedupeRecords(rows.map(rowToRecord).map(applyCurrentPolicy).filter((record): record is MemoryRecord => Boolean(record))));
109
+ });
110
+ }
111
+
112
+ async pendingCount(): Promise<number> {
113
+ return (await this.list("pending")).length;
114
+ }
115
+
116
+ async approve(id: string): Promise<MemoryRecord | undefined> {
117
+ const record = await this.updateStatus(id, "active");
118
+ if (record) {
119
+ await this.withDb(true, (db) => {
120
+ db.run("DELETE FROM memory_fts WHERE id = ?", [record.id]);
121
+ db.run("INSERT INTO memory_fts (id, category, content, evidence, sourceAgent) VALUES (?, ?, ?, ?, ?)", [record.id, record.category, record.content, record.evidence ?? "", record.sourceAgent]);
122
+ appendMemoryEvent(db, {
123
+ recordId: record.id,
124
+ type: "approve",
125
+ actor: "human-review",
126
+ at: record.reviewedAt ?? new Date().toISOString(),
127
+ summary: "Memory approved for retrieval.",
128
+ nextContent: record.content,
129
+ });
130
+ });
131
+ }
132
+ return record;
133
+ }
134
+
135
+ async reject(id: string): Promise<MemoryRecord | undefined> {
136
+ const record = await this.updateStatus(id, "rejected");
137
+ if (record) await this.withDb(true, (db) => {
138
+ db.run("DELETE FROM memory_fts WHERE id = ?", [id]);
139
+ appendMemoryEvent(db, {
140
+ recordId: record.id,
141
+ type: "reject",
142
+ actor: "human-review",
143
+ at: record.reviewedAt ?? new Date().toISOString(),
144
+ summary: "Memory rejected and removed from retrieval.",
145
+ previousContent: record.content,
146
+ });
147
+ });
148
+ return record;
149
+ }
150
+
151
+ async delete(id: string): Promise<boolean> {
152
+ const before = await this.rawCount();
153
+ await this.withDb(true, (db) => {
154
+ const record = selectRows(db, "SELECT * FROM memory_records WHERE id = ?", [id]).map(rowToRecord)[0];
155
+ db.run("DELETE FROM memory_fts WHERE id = ?", [id]);
156
+ db.run("DELETE FROM memory_records WHERE id = ?", [id]);
157
+ appendMemoryEvent(db, {
158
+ recordId: id,
159
+ type: "delete",
160
+ actor: "human-review",
161
+ at: new Date().toISOString(),
162
+ summary: "Memory deleted from the primary store.",
163
+ previousContent: record?.content,
164
+ });
165
+ });
166
+ const after = await this.rawCount();
167
+ return after < before;
168
+ }
169
+
170
+ async search(query: string, limit = 10): Promise<MemorySearchResult[]> {
171
+ const ftsQuery = buildFtsQuery(query);
172
+ if (!ftsQuery) return [];
173
+
174
+ return this.withDb(false, (db) => {
175
+ const rows = selectRows(
176
+ db,
177
+ `SELECT r.*, bm25(memory_fts) AS score
178
+ FROM memory_fts
179
+ JOIN memory_records r ON r.id = memory_fts.id
180
+ WHERE memory_fts MATCH ?
181
+ ORDER BY score ASC, r.createdAt DESC
182
+ LIMIT ?`,
183
+ [ftsQuery, Math.max(limit * 3, limit)],
184
+ );
185
+ return rows
186
+ .map((row) => {
187
+ const record = applyCurrentPolicy(rowToRecord(row));
188
+ return record ? { record, score: Math.abs(Number(row.score ?? 0)), highlights: [String(row.content ?? "").slice(0, 180)] } : undefined;
189
+ })
190
+ .filter((result): result is MemorySearchResult => result !== undefined)
191
+ .filter((result) => result.record.status === "active")
192
+ .slice(0, limit);
193
+ });
194
+ }
195
+
196
+ async retrieve(request: MemoryContextRequest): Promise<MemoryContextBundle> {
197
+ const tokenBudget = memoryTokenBudget(request);
198
+ const results = rerankMemoryResults(await this.search(request.query, Math.max(request.limit ?? 8, 1)), request);
199
+ const selected = selectMemoryResultsWithinBudget(results, tokenBudget, Boolean(request.includeEvidence));
200
+ const now = new Date().toISOString();
201
+ if (selected.results.length > 0) {
202
+ await this.withDb(true, (db) => {
203
+ for (const result of selected.results) {
204
+ db.run(
205
+ "UPDATE memory_records SET lastUsedAt = ?, useCount = useCount + 1, utilityScore = MIN(1, utilityScore + 0.04) WHERE id = ?",
206
+ [now, result.record.id],
207
+ );
208
+ appendMemoryEvent(db, {
209
+ recordId: result.record.id,
210
+ type: "retrieve",
211
+ actor: request.sourceAgent ?? "memory-system",
212
+ at: now,
213
+ summary: `Retrieved for '${truncateText(request.query, 120)}'.`,
214
+ metadata: { score: result.score, tokenBudget },
215
+ });
216
+ }
217
+ });
218
+ }
219
+ return {
220
+ text: formatMemoryContext(selected.results, tokenBudget, Boolean(request.includeEvidence)),
221
+ results: selected.results,
222
+ tokenBudget,
223
+ estimatedTokens: selected.estimatedTokens,
224
+ omitted: Math.max(0, results.length - selected.results.length),
225
+ };
226
+ }
227
+
228
+ async revise(id: string, input: MemoryRevisionInput): Promise<MemoryRecord | undefined> {
229
+ const now = new Date().toISOString();
230
+ let revised: MemoryRecord | undefined;
231
+ await this.withDb(true, (db) => {
232
+ const existing = selectRows(db, "SELECT * FROM memory_records WHERE id = ?", [id]).map(rowToRecord)[0];
233
+ if (!existing || existing.status === "rejected") return;
234
+ const candidate = createMemoryCandidate({
235
+ category: input.category ?? existing.category,
236
+ content: input.content,
237
+ sourceAgent: input.sourceAgent,
238
+ confidence: input.confidence ?? existing.confidence,
239
+ evidence: input.evidence,
240
+ scope: input.scope ?? existing.scope,
241
+ topicKey: input.topicKey ?? existing.topicKey,
242
+ });
243
+ const incoming = buildMemoryRecord(candidate, now);
244
+ revised = {
245
+ ...existing,
246
+ category: incoming.category,
247
+ content: incoming.content,
248
+ sourceAgent: incoming.sourceAgent,
249
+ confidence: Math.max(existing.confidence, incoming.confidence),
250
+ evidence: mergeEvidence(existing.evidence, incoming.evidence),
251
+ status: existing.status === "quarantined" ? "pending" : incoming.status,
252
+ reviewedAt: incoming.status === "pending" ? existing.reviewedAt : now,
253
+ topicKey: incoming.topicKey ?? existing.topicKey,
254
+ importance: Math.max(existing.importance, incoming.importance),
255
+ trigger: incoming.trigger,
256
+ lastSeenAt: now,
257
+ updatedAt: now,
258
+ tokenCostEstimate: estimateTokens(incoming.content),
259
+ revisionCount: existing.revisionCount + 1,
260
+ };
261
+ upsertMemoryRecord(db, revised);
262
+ appendMemoryEvent(db, {
263
+ recordId: existing.id,
264
+ type: "revise",
265
+ actor: input.sourceAgent,
266
+ at: now,
267
+ summary: input.reason ? `Memory revised: ${truncateText(input.reason, 180)}` : "Memory revised by autonomous memory policy.",
268
+ previousContent: existing.content,
269
+ nextContent: revised.content,
270
+ metadata: { category: revised.category, status: revised.status, topicKey: revised.topicKey },
271
+ });
272
+ });
273
+ return revised;
274
+ }
275
+
276
+ async events(recordId?: string): Promise<MemoryAuditEvent[]> {
277
+ return this.withDb(false, (db) => selectRows(
278
+ db,
279
+ recordId ? "SELECT * FROM memory_events WHERE recordId = ? ORDER BY at ASC" : "SELECT * FROM memory_events ORDER BY at ASC",
280
+ recordId ? [recordId] : [],
281
+ ).map(rowToMemoryEvent));
282
+ }
283
+
284
+ private async rawCount(): Promise<number> {
285
+ return this.withDb(false, (db) => Number(selectRows(db, "SELECT COUNT(*) AS count FROM memory_records")[0]?.count ?? 0));
286
+ }
287
+
288
+ private async updateStatus(id: string, status: MemoryRecord["status"]): Promise<MemoryRecord | undefined> {
289
+ const reviewedAt = new Date().toISOString();
290
+ await this.withDb(true, (db) => db.run("UPDATE memory_records SET status = ?, reviewedAt = ? WHERE id = ?", [status, reviewedAt, id]));
291
+ return this.withDb(false, (db) => selectRows(db, "SELECT * FROM memory_records WHERE id = ?", [id]).map(rowToRecord)[0]);
292
+ }
293
+
294
+ private async withDb<T>(write: boolean, fn: (db: SqlJsDatabase) => T): Promise<T> {
295
+ fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
296
+ const SQL = await getSqlModule();
297
+ const db = fs.existsSync(this.dbPath) ? new SQL.Database(fs.readFileSync(this.dbPath)) : new SQL.Database();
298
+ try {
299
+ migrate(db);
300
+ const result = fn(db);
301
+ if (write) fs.writeFileSync(this.dbPath, Buffer.from(db.export()));
302
+ return result;
303
+ } finally {
304
+ db.close();
305
+ }
306
+ }
307
+ }
308
+
309
+ export function createMemoryCandidate(input: Omit<MemoryCandidate, "id" | "createdAt"> & { id?: string; createdAt?: string }): MemoryCandidate {
310
+ const createdAt = input.createdAt ?? new Date().toISOString();
311
+ const content = normalizeContent(input.content);
312
+ return {
313
+ ...input,
314
+ content,
315
+ id: input.id ?? `memory-${stableHash(normalizeForDedupe(content))}`,
316
+ createdAt,
317
+ };
318
+ }
319
+
320
+ async function getSqlModule(): Promise<SqlJsStatic> {
321
+ sqlModulePromise ??= initSqlJs();
322
+ return sqlModulePromise;
323
+ }
324
+
325
+ function migrate(db: SqlJsDatabase): void {
326
+ db.run(`
327
+ CREATE TABLE IF NOT EXISTS memory_records (
328
+ id TEXT PRIMARY KEY,
329
+ category TEXT NOT NULL,
330
+ content TEXT NOT NULL,
331
+ sourceAgent TEXT NOT NULL,
332
+ confidence REAL NOT NULL,
333
+ evidence TEXT,
334
+ scope TEXT NOT NULL,
335
+ createdAt TEXT NOT NULL,
336
+ status TEXT NOT NULL,
337
+ reviewedAt TEXT,
338
+ topicKey TEXT,
339
+ importance REAL NOT NULL DEFAULT 0,
340
+ trigger TEXT NOT NULL DEFAULT 'unknown',
341
+ lastSeenAt TEXT,
342
+ duplicateCount INTEGER NOT NULL DEFAULT 1,
343
+ revisionCount INTEGER NOT NULL DEFAULT 1,
344
+ updatedAt TEXT,
345
+ lastUsedAt TEXT,
346
+ useCount INTEGER NOT NULL DEFAULT 0,
347
+ utilityScore REAL NOT NULL DEFAULT 0,
348
+ tokenCostEstimate INTEGER NOT NULL DEFAULT 0,
349
+ supersedesId TEXT,
350
+ supersededBy TEXT
351
+ );
352
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
353
+ id UNINDEXED,
354
+ category,
355
+ content,
356
+ evidence,
357
+ sourceAgent
358
+ );
359
+ CREATE TABLE IF NOT EXISTS memory_events (
360
+ id TEXT PRIMARY KEY,
361
+ recordId TEXT NOT NULL,
362
+ type TEXT NOT NULL,
363
+ actor TEXT NOT NULL,
364
+ at TEXT NOT NULL,
365
+ summary TEXT NOT NULL,
366
+ previousContent TEXT,
367
+ nextContent TEXT,
368
+ metadata TEXT
369
+ );
370
+ `);
371
+ addColumnIfMissing(db, "memory_records", "topicKey", "TEXT");
372
+ addColumnIfMissing(db, "memory_records", "importance", "REAL NOT NULL DEFAULT 0");
373
+ addColumnIfMissing(db, "memory_records", "trigger", "TEXT NOT NULL DEFAULT 'unknown'");
374
+ addColumnIfMissing(db, "memory_records", "lastSeenAt", "TEXT");
375
+ addColumnIfMissing(db, "memory_records", "duplicateCount", "INTEGER NOT NULL DEFAULT 1");
376
+ addColumnIfMissing(db, "memory_records", "revisionCount", "INTEGER NOT NULL DEFAULT 1");
377
+ addColumnIfMissing(db, "memory_records", "updatedAt", "TEXT");
378
+ addColumnIfMissing(db, "memory_records", "lastUsedAt", "TEXT");
379
+ addColumnIfMissing(db, "memory_records", "useCount", "INTEGER NOT NULL DEFAULT 0");
380
+ addColumnIfMissing(db, "memory_records", "utilityScore", "REAL NOT NULL DEFAULT 0");
381
+ addColumnIfMissing(db, "memory_records", "tokenCostEstimate", "INTEGER NOT NULL DEFAULT 0");
382
+ addColumnIfMissing(db, "memory_records", "supersedesId", "TEXT");
383
+ addColumnIfMissing(db, "memory_records", "supersededBy", "TEXT");
384
+ }
385
+
386
+ function addColumnIfMissing(db: SqlJsDatabase, table: string, column: string, definition: string): void {
387
+ const columns = selectRows(db, `PRAGMA table_info(${table})`).map((row) => String(row.name));
388
+ if (!columns.includes(column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
389
+ }
390
+
391
+ function selectRows(db: SqlJsDatabase, sql: string, params: unknown[] = []): Array<Record<string, unknown>> {
392
+ const stmt = db.prepare(sql);
393
+ const rows: Array<Record<string, unknown>> = [];
394
+ try {
395
+ stmt.bind(params);
396
+ while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
397
+ } finally {
398
+ stmt.free();
399
+ }
400
+ return rows;
401
+ }
402
+
403
+ function dedupeCandidates(candidates: MemoryCandidate[]): MemoryCandidate[] {
404
+ const result: MemoryCandidate[] = [];
405
+ for (const candidate of candidates) {
406
+ const normalized = normalizeForDedupe(candidate.content);
407
+ if (!normalized || result.some((existing) => isDuplicateMemoryContent(existing.content, candidate.content))) continue;
408
+ result.push({ ...candidate, content: normalizeContent(candidate.content) });
409
+ }
410
+ return result;
411
+ }
412
+
413
+
414
+ function dedupeRecords(records: MemoryRecord[]): MemoryRecord[] {
415
+ const result: MemoryRecord[] = [];
416
+ for (const record of records) {
417
+ if (result.some((existing) => isDuplicateMemoryContent(existing.content, record.content))) continue;
418
+ result.push(record);
419
+ }
420
+ return result;
421
+ }
422
+
423
+ function sortMemoryRecords(records: MemoryRecord[]): MemoryRecord[] {
424
+ const rank: Record<MemoryRecord["status"], number> = { pending: 0, quarantined: 1, active: 2, stale: 3, superseded: 4, rejected: 5 };
425
+ return records.sort((a, b) => rank[a.status] - rank[b.status] || b.createdAt.localeCompare(a.createdAt));
426
+ }
427
+
428
+ function applyCurrentPolicy(record: MemoryRecord): MemoryRecord | undefined {
429
+ const assessment = assessMemoryCandidate(record);
430
+ if (assessment.status === "rejected") return undefined;
431
+ if (record.status === "rejected") return undefined;
432
+ if (record.status === "active" && assessment.status === "pending") return { ...record, status: "pending", importance: assessment.importance, trigger: assessment.trigger };
433
+ return { ...record, importance: record.importance || assessment.importance, trigger: record.trigger || assessment.trigger, topicKey: record.topicKey ?? assessment.topicKey };
434
+ }
435
+
436
+ function normalizeContent(content: string): string {
437
+ return content.replace(/\s+/g, " ").trim();
438
+ }
439
+
440
+ function normalizeForDedupe(content: string): string {
441
+ return normalizeContent(content)
442
+ .toLowerCase()
443
+ .normalize("NFKD")
444
+ .replace(/[\u0300-\u036f]/g, "")
445
+ .replace(/[`'".,;:!?()[\]{}]/g, "")
446
+ .replace(/\s+/g, " ")
447
+ .trim();
448
+ }
449
+
450
+ function isDuplicateMemoryContent(a: string, b: string): boolean {
451
+ const normalizedA = normalizeForDedupe(a);
452
+ const normalizedB = normalizeForDedupe(b);
453
+ if (!normalizedA || !normalizedB) return false;
454
+ if (normalizedA === normalizedB) return true;
455
+ const topicA = memoryTopicKey(normalizedA);
456
+ const topicB = memoryTopicKey(normalizedB);
457
+ if (topicA && topicA === topicB) return true;
458
+ const tokensA = memoryTokens(normalizedA);
459
+ const tokensB = memoryTokens(normalizedB);
460
+ if (tokensA.size < 5 || tokensB.size < 5) return false;
461
+ const score = jaccard(tokensA, tokensB);
462
+ if (score >= 0.42) return true;
463
+ const entitiesA = memoryEntities(normalizedA);
464
+ const entitiesB = memoryEntities(normalizedB);
465
+ const sharedEntities = [...entitiesA].filter((entity) => entitiesB.has(entity));
466
+ if (sharedEntities.length >= 2 && score >= 0.25) return true;
467
+ if (sharedEntities.some((entity) => isStrongMemoryEntity(entity)) && score >= 0.2) return true;
468
+ return false;
469
+ }
470
+
471
+ function memoryTopicKey(normalized: string): string | undefined {
472
+ const tokens = memoryTokens(normalized);
473
+ if (tokens.has("bun") && tokens.has("test") && tokens.has("settimeout")) return "testing:bun-no-settimeout";
474
+ if ((tokens.has("buntest") || normalized.includes("bun:test")) && tokens.has("test")) return "testing:bun-test";
475
+ if ((tokens.has("nodetest") || normalized.includes("node:test")) && tokens.has("test")) return "testing:node-test";
476
+ if (tokens.has("checkpoint") && (tokens.has("handoff") || tokens.has("resume"))) return "workflow:handoff-checkpoints";
477
+ if (tokens.has("validation") && tokens.has("contract")) return "workflow:validation-contracts";
478
+ const has = (...items: string[]) => items.every((item) => tokens.has(item) || normalized.includes(item));
479
+ if (has("extension", "routing", "subagent")) return "extension-routing-subagents";
480
+ if (normalized.includes("memory.sqlite") || (has("memory") && (tokens.has("sqlite") || tokens.has("fts5") || normalized.includes("sql.js-fts5")))) return "memory-store";
481
+ if ((normalized.includes("agents/") || normalized.includes("agents*.md") || normalized.includes("agents/*.md")) && has("agent")) return "agent-catalog";
482
+ if (normalized.includes(".pi-chalin/runs") || normalized.includes("runs/<id>.json") || normalized.includes("runs/*.json")) return "run-persistence";
483
+ if (normalized.includes("src/commands.ts") || normalized.includes("/chalin")) return "chalin-commands";
484
+ if (normalized.includes("src/index.ts")) return "runtime-entrypoint";
485
+ if (normalized.includes("src/kernel.ts") || tokens.has("chalinkernel")) return "kernel-routing";
486
+ if (tokens.has("architecture") || tokens.has("monolith") || tokens.has("monolito")) return "architecture";
487
+ if (tokens.has("tui") && (tokens.has("modelos") || tokens.has("models") || tokens.has("rutas") || tokens.has("memory"))) return "tui-surface";
488
+ return undefined;
489
+ }
490
+
491
+ function memoryTokens(normalized: string): Set<string> {
492
+ const stop = new Set([
493
+ "the", "and", "for", "that", "this", "with", "from", "into", "using", "uses", "use", "under", "through", "when", "where", "should",
494
+ "este", "esta", "esto", "para", "que", "con", "por", "desde", "hacia", "como", "usa", "usar", "usando", "debe", "deben", "del", "las", "los", "una", "uno", "mas", "más",
495
+ "project", "proyecto", "pi", "chalin", "pi-chalin", "coding", "agent",
496
+ ]);
497
+ return new Set(normalized.split(/\s+/).map(canonicalMemoryToken).filter((token) => token.length >= 3 && !stop.has(token)));
498
+ }
499
+
500
+ function canonicalMemoryToken(token: string): string {
501
+ const aliases: Record<string, string> = {
502
+ extension: "extension",
503
+ extensionpackage: "extension",
504
+ paqueteextension: "extension",
505
+ extensiones: "extension",
506
+ extensiontypescript: "extension",
507
+ routing: "routing",
508
+ routed: "routing",
509
+ routes: "routing",
510
+ route: "routing",
511
+ enruta: "routing",
512
+ rutea: "routing",
513
+ convierte: "routing",
514
+ prompts: "prompt",
515
+ normales: "normal",
516
+ normal: "normal",
517
+ subagent: "subagent",
518
+ subagents: "subagent",
519
+ subagente: "subagent",
520
+ subagentes: "subagent",
521
+ memoria: "memory",
522
+ memory: "memory",
523
+ memorias: "memory",
524
+ agentes: "agent",
525
+ agents: "agent",
526
+ agente: "agent",
527
+ especializado: "specialized",
528
+ especializados: "specialized",
529
+ specialized: "specialized",
530
+ workflows: "workflow",
531
+ workflow: "workflow",
532
+ automatico: "automatic",
533
+ automatic: "automatic",
534
+ principal: "main",
535
+ main: "main",
536
+ entrypoint: "entrypoint",
537
+ entrada: "entrypoint",
538
+ arquitectura: "architecture",
539
+ architecture: "architecture",
540
+ tests: "test",
541
+ testing: "test",
542
+ test: "test",
543
+ pruebas: "test",
544
+ prueba: "test",
545
+ settimeout: "settimeout",
546
+ timers: "timer",
547
+ bun: "bun",
548
+ node: "node",
549
+ checkpoints: "checkpoint",
550
+ checkpoint: "checkpoint",
551
+ handoffs: "handoff",
552
+ handoff: "handoff",
553
+ resume: "resume",
554
+ resumable: "resume",
555
+ continuation: "resume",
556
+ validation: "validation",
557
+ validations: "validation",
558
+ contract: "contract",
559
+ contracts: "contract",
560
+ };
561
+ return aliases[token] ?? token;
562
+ }
563
+
564
+ function memoryEntities(normalized: string): Set<string> {
565
+ const entities = new Set<string>();
566
+ for (const match of normalized.matchAll(/[\w.-]+\/[\w./-]+|[\w.-]+\.(?:ts|tsx|js|jsx|json|md|sqlite)/g)) entities.add(match[0]);
567
+ for (const token of ["chalinkernel", "agentcatalog", "memorystore", "typescript", "sqlite", "fts5", "tui", "sdk", "bun", "bun:test", "node:test"]) {
568
+ if (normalized.includes(token)) entities.add(token);
569
+ }
570
+ return entities;
571
+ }
572
+
573
+ function isStrongMemoryEntity(entity: string): boolean {
574
+ return entity.includes("/") || /\.(?:ts|tsx|js|jsx|json|md|sqlite)$/.test(entity) || ["chalinkernel", "agentcatalog", "memorystore", "sqlite", "fts5", "bun", "bun:test", "node:test"].includes(entity);
575
+ }
576
+
577
+ function jaccard(a: Set<string>, b: Set<string>): number {
578
+ let intersection = 0;
579
+ for (const token of a) if (b.has(token)) intersection++;
580
+ return intersection / (a.size + b.size - intersection);
581
+ }
582
+
583
+ function isUsefulMemoryContent(content: string): boolean {
584
+ const normalized = normalizeContent(content);
585
+ if (normalized.length < 48 || normalized.length > 600) return false;
586
+ if ((normalized.match(/[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{3,}/g) ?? []).length < 6) return false;
587
+ if (/^(cmd|env|try|except|print|return|const|let|var|import|export|function|class|if|else|for|while|sys\.|p\s*=|#)/i.test(normalized)) return false;
588
+ if (/\b(subprocess|os\.environ|PI_OFFLINE|stdout|stderr|returncode|TimeoutExpired|sys\.exit|traceback|stack trace)\b/i.test(normalized)) return false;
589
+ if (/\b(completed a .* step for|mock handoff|previous handoff|task:)\b/i.test(normalized)) return false;
590
+ const codePunctuation = (normalized.match(/[=;{}()[\]]/g) ?? []).length;
591
+ if (codePunctuation >= 4) return false;
592
+ return true;
593
+ }
594
+
595
+
596
+ function assessMemoryCandidate(candidate: MemoryCandidate): { status: MemoryRecord["status"]; importance: number; trigger: string; topicKey?: string } {
597
+ const category = candidate.category.toLowerCase();
598
+ const topicKey = candidate.topicKey ?? memoryTopicKey(normalizeForDedupe(candidate.content)) ?? genericTopicKey(category, candidate.content);
599
+ if (!isUsefulMemoryContent(candidate.content)) return { status: "rejected", importance: 0, trigger: "noise-rejected", topicKey };
600
+ if (candidate.confidence < 0.35) return { status: "rejected", importance: 0.1, trigger: "low-confidence", topicKey };
601
+ if (["decision", "preference", "security", "safety", "architecture", "agent-note"].includes(category)) {
602
+ return { status: "pending", importance: importanceForCategory(category), trigger: triggerForCategory(category), topicKey };
603
+ }
604
+ if (candidate.confidence < 0.85) return { status: "pending", importance: importanceForCategory(category), trigger: "needs-confidence-review", topicKey };
605
+ return { status: "active", importance: importanceForCategory(category), trigger: triggerForCategory(category), topicKey };
606
+ }
607
+
608
+ function importanceForCategory(category: string): number {
609
+ if (["security", "safety", "decision", "architecture"].includes(category)) return 0.95;
610
+ if (["workflow", "testing", "tooling", "pattern", "failure", "bugfix"].includes(category)) return 0.8;
611
+ if (category === "project-fact") return 0.7;
612
+ return 0.55;
613
+ }
614
+
615
+ function triggerForCategory(category: string): string {
616
+ const triggers: Record<string, string> = {
617
+ "project-fact": "project-fact",
618
+ pattern: "pattern-learning",
619
+ tooling: "tooling-learning",
620
+ testing: "testing-learning",
621
+ workflow: "workflow-learning",
622
+ bugfix: "bugfix-learning",
623
+ failure: "failure-learning",
624
+ decision: "decision-review",
625
+ preference: "preference-review",
626
+ architecture: "architecture-review",
627
+ safety: "safety-review",
628
+ security: "security-review",
629
+ "agent-note": "manual-review",
630
+ };
631
+ return triggers[category] ?? "project-learning";
632
+ }
633
+
634
+ function genericTopicKey(category: string, content: string): string | undefined {
635
+ const tokens = [...memoryTokens(normalizeForDedupe(content))].slice(0, 5);
636
+ if (tokens.length < 3) return undefined;
637
+ return `${category}:${tokens.join("-")}`;
638
+ }
639
+
640
+ function buildFtsQuery(query: string): string {
641
+ const terms = query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? [];
642
+ return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
643
+ }
644
+
645
+ function stableHash(input: string): string {
646
+ let hash = 5381;
647
+ for (let index = 0; index < input.length; index++) hash = (hash * 33) ^ input.charCodeAt(index);
648
+ return (hash >>> 0).toString(16);
649
+ }
650
+
651
+ function rowToRecord(row: Record<string, unknown>): MemoryRecord {
652
+ const createdAt = String(row.createdAt);
653
+ return {
654
+ id: String(row.id),
655
+ category: String(row.category),
656
+ content: String(row.content),
657
+ sourceAgent: String(row.sourceAgent),
658
+ confidence: Number(row.confidence),
659
+ ...(row.evidence ? { evidence: String(row.evidence) } : {}),
660
+ scope: row.scope === "user" ? "user" : "project",
661
+ createdAt,
662
+ status: memoryStatusFromRow(row.status),
663
+ ...(row.reviewedAt ? { reviewedAt: String(row.reviewedAt) } : {}),
664
+ ...(row.topicKey ? { topicKey: String(row.topicKey) } : {}),
665
+ importance: Number(row.importance ?? 0),
666
+ trigger: String(row.trigger ?? "unknown"),
667
+ lastSeenAt: row.lastSeenAt ? String(row.lastSeenAt) : createdAt,
668
+ duplicateCount: Number(row.duplicateCount ?? 1),
669
+ revisionCount: Number(row.revisionCount ?? 1),
670
+ ...(row.updatedAt ? { updatedAt: String(row.updatedAt) } : {}),
671
+ ...(row.lastUsedAt ? { lastUsedAt: String(row.lastUsedAt) } : {}),
672
+ useCount: Number(row.useCount ?? 0),
673
+ utilityScore: Number(row.utilityScore ?? 0),
674
+ tokenCostEstimate: Number(row.tokenCostEstimate ?? estimateTokens(String(row.content ?? ""))),
675
+ ...(row.supersedesId ? { supersedesId: String(row.supersedesId) } : {}),
676
+ ...(row.supersededBy ? { supersededBy: String(row.supersededBy) } : {}),
677
+ };
678
+ }
679
+
680
+ function buildMemoryRecord(candidate: MemoryCandidate, now: string): MemoryRecord {
681
+ const content = normalizeContent(candidate.content);
682
+ const assessment = assessMemoryCandidate({ ...candidate, content });
683
+ return {
684
+ ...candidate,
685
+ content,
686
+ category: candidate.category.toLowerCase(),
687
+ status: assessment.status,
688
+ ...(assessment.status !== "pending" ? { reviewedAt: now } : {}),
689
+ ...(assessment.topicKey ? { topicKey: assessment.topicKey } : {}),
690
+ importance: assessment.importance,
691
+ trigger: assessment.trigger,
692
+ lastSeenAt: now,
693
+ duplicateCount: 1,
694
+ revisionCount: 1,
695
+ updatedAt: now,
696
+ useCount: 0,
697
+ utilityScore: assessment.importance * 0.5,
698
+ tokenCostEstimate: estimateTokens(content),
699
+ };
700
+ }
701
+
702
+ function findMemoryUpdateTarget(record: MemoryRecord, records: MemoryRecord[]): MemoryRecord | undefined {
703
+ const exact = records.find((existing) => normalizeForDedupe(existing.content) === normalizeForDedupe(record.content));
704
+ if (exact) return exact;
705
+ if (record.topicKey) {
706
+ const sameTopic = records.find((existing) => existing.topicKey === record.topicKey);
707
+ if (sameTopic) return sameTopic;
708
+ }
709
+ return records.find((existing) => isDuplicateMemoryContent(existing.content, record.content));
710
+ }
711
+
712
+ function mergeMemoryRecord(existing: MemoryRecord, incoming: MemoryRecord, now: string): MemoryRecord {
713
+ const exact = normalizeForDedupe(existing.content) === normalizeForDedupe(incoming.content);
714
+ if (exact) {
715
+ return {
716
+ ...existing,
717
+ confidence: Math.max(existing.confidence, incoming.confidence),
718
+ evidence: mergeEvidence(existing.evidence, incoming.evidence),
719
+ lastSeenAt: now,
720
+ duplicateCount: existing.duplicateCount + 1,
721
+ updatedAt: now,
722
+ utilityScore: Math.min(1, Math.max(existing.utilityScore ?? 0, incoming.utilityScore ?? 0) + 0.02),
723
+ };
724
+ }
725
+ const status = existing.status === "pending" || incoming.status === "pending" ? "pending" : incoming.status;
726
+ return {
727
+ ...existing,
728
+ category: incoming.category,
729
+ content: incoming.content,
730
+ sourceAgent: incoming.sourceAgent,
731
+ confidence: Math.max(existing.confidence, incoming.confidence),
732
+ evidence: mergeEvidence(existing.evidence, incoming.evidence),
733
+ status,
734
+ reviewedAt: status === "pending" ? existing.reviewedAt : now,
735
+ topicKey: incoming.topicKey ?? existing.topicKey,
736
+ importance: Math.max(existing.importance, incoming.importance),
737
+ trigger: incoming.trigger,
738
+ lastSeenAt: now,
739
+ updatedAt: now,
740
+ tokenCostEstimate: estimateTokens(incoming.content),
741
+ utilityScore: Math.min(1, Math.max(existing.utilityScore ?? 0, incoming.utilityScore ?? 0) + 0.04),
742
+ duplicateCount: existing.duplicateCount,
743
+ revisionCount: existing.revisionCount + 1,
744
+ };
745
+ }
746
+
747
+ function mergeEvidence(a: string | undefined, b: string | undefined): string | undefined {
748
+ if (!a) return b;
749
+ if (!b || a.includes(b)) return a;
750
+ return `${a}; ${b}`.slice(0, 400);
751
+ }
752
+
753
+ function upsertMemoryRecord(db: SqlJsDatabase, record: MemoryRecord): void {
754
+ const upsert = db.prepare(`
755
+ INSERT INTO memory_records (id, category, content, sourceAgent, confidence, evidence, scope, createdAt, status, reviewedAt, topicKey, importance, trigger, lastSeenAt, duplicateCount, revisionCount, updatedAt, lastUsedAt, useCount, utilityScore, tokenCostEstimate, supersedesId, supersededBy)
756
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
757
+ ON CONFLICT(id) DO UPDATE SET
758
+ category=excluded.category,
759
+ content=excluded.content,
760
+ sourceAgent=excluded.sourceAgent,
761
+ confidence=excluded.confidence,
762
+ evidence=excluded.evidence,
763
+ scope=excluded.scope,
764
+ status=excluded.status,
765
+ reviewedAt=excluded.reviewedAt,
766
+ topicKey=excluded.topicKey,
767
+ importance=excluded.importance,
768
+ trigger=excluded.trigger,
769
+ lastSeenAt=excluded.lastSeenAt,
770
+ duplicateCount=excluded.duplicateCount,
771
+ revisionCount=excluded.revisionCount,
772
+ updatedAt=excluded.updatedAt,
773
+ lastUsedAt=excluded.lastUsedAt,
774
+ useCount=excluded.useCount,
775
+ utilityScore=excluded.utilityScore,
776
+ tokenCostEstimate=excluded.tokenCostEstimate,
777
+ supersedesId=excluded.supersedesId,
778
+ supersededBy=excluded.supersededBy
779
+ `);
780
+ const deleteFts = db.prepare("DELETE FROM memory_fts WHERE id = ?");
781
+ const insertFts = db.prepare("INSERT INTO memory_fts (id, category, content, evidence, sourceAgent) VALUES (?, ?, ?, ?, ?)");
782
+ try {
783
+ upsert.run([
784
+ record.id,
785
+ record.category,
786
+ record.content,
787
+ record.sourceAgent,
788
+ record.confidence,
789
+ record.evidence ?? null,
790
+ record.scope,
791
+ record.createdAt,
792
+ record.status,
793
+ record.reviewedAt ?? null,
794
+ record.topicKey ?? null,
795
+ record.importance,
796
+ record.trigger,
797
+ record.lastSeenAt,
798
+ record.duplicateCount,
799
+ record.revisionCount,
800
+ record.updatedAt ?? record.lastSeenAt,
801
+ record.lastUsedAt ?? null,
802
+ record.useCount ?? 0,
803
+ record.utilityScore ?? 0,
804
+ record.tokenCostEstimate ?? estimateTokens(record.content),
805
+ record.supersedesId ?? null,
806
+ record.supersededBy ?? null,
807
+ ]);
808
+ deleteFts.run([record.id]);
809
+ if (record.status === "active") insertFts.run([record.id, record.category, record.content, record.evidence ?? "", record.sourceAgent]);
810
+ } finally {
811
+ upsert.free();
812
+ deleteFts.free();
813
+ insertFts.free();
814
+ }
815
+ }
816
+
817
+ function appendMemoryEvent(
818
+ db: SqlJsDatabase,
819
+ event: Omit<MemoryAuditEvent, "id"> & { id?: string },
820
+ ): void {
821
+ const id = event.id ?? `memory-event-${stableHash(`${event.recordId}:${event.type}:${event.at}:${event.summary}:${event.nextContent ?? ""}`)}`;
822
+ db.run(
823
+ "INSERT OR IGNORE INTO memory_events (id, recordId, type, actor, at, summary, previousContent, nextContent, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
824
+ [
825
+ id,
826
+ event.recordId,
827
+ event.type,
828
+ event.actor,
829
+ event.at,
830
+ event.summary,
831
+ event.previousContent ?? null,
832
+ event.nextContent ?? null,
833
+ event.metadata ? JSON.stringify(event.metadata) : null,
834
+ ],
835
+ );
836
+ }
837
+
838
+ function rowToMemoryEvent(row: Record<string, unknown>): MemoryAuditEvent {
839
+ return {
840
+ id: String(row.id),
841
+ recordId: String(row.recordId),
842
+ type: memoryEventTypeFromRow(row.type),
843
+ actor: String(row.actor),
844
+ at: String(row.at),
845
+ summary: String(row.summary),
846
+ ...(row.previousContent ? { previousContent: String(row.previousContent) } : {}),
847
+ ...(row.nextContent ? { nextContent: String(row.nextContent) } : {}),
848
+ ...(row.metadata ? { metadata: parseMetadata(row.metadata) } : {}),
849
+ };
850
+ }
851
+
852
+ function memoryEventTypeFromRow(value: unknown): MemoryAuditEventType {
853
+ const text = String(value);
854
+ if (["create", "duplicate", "revise", "approve", "reject", "delete", "retrieve", "quarantine", "stale"].includes(text)) return text as MemoryAuditEventType;
855
+ return "revise";
856
+ }
857
+
858
+ function parseMetadata(value: unknown): Record<string, unknown> {
859
+ if (typeof value !== "string") return {};
860
+ try {
861
+ const parsed = JSON.parse(value) as unknown;
862
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
863
+ } catch {
864
+ return {};
865
+ }
866
+ }
867
+
868
+ function memoryStatusFromRow(value: unknown): MemoryRecord["status"] {
869
+ const text = String(value);
870
+ if (text === "pending" || text === "rejected" || text === "superseded" || text === "stale" || text === "quarantined") return text;
871
+ return "active";
872
+ }
873
+
874
+ function memoryTokenBudget(request: MemoryContextRequest): number {
875
+ if (Number.isFinite(request.tokenBudget) && (request.tokenBudget ?? 0) > 0) return Math.max(80, Math.min(1800, Math.floor(request.tokenBudget!)));
876
+ if (request.agentConcern === "review" || request.agentConcern === "decision-consistency") return 900;
877
+ if (request.agentConcern === "planning" || request.agentConcern === "context-building") return 700;
878
+ if (request.agentConcern === "implementation" || request.agentConcern === "conflict-resolution") return 520;
879
+ return 420;
880
+ }
881
+
882
+ function rerankMemoryResults(results: MemorySearchResult[], request: MemoryContextRequest): MemorySearchResult[] {
883
+ const now = Date.now();
884
+ return [...results].sort((a, b) => memoryResultRank(b, now, request) - memoryResultRank(a, now, request));
885
+ }
886
+
887
+ function memoryResultRank(result: MemorySearchResult, now: number, request: MemoryContextRequest): number {
888
+ const record = result.record;
889
+ const lastSeen = Date.parse(record.lastSeenAt || record.createdAt);
890
+ const ageDays = Number.isFinite(lastSeen) ? Math.max(0, (now - lastSeen) / 86_400_000) : 30;
891
+ const recency = 1 / (1 + ageDays / 30);
892
+ const utility = record.utilityScore ?? 0;
893
+ const useSignal = Math.min(0.2, (record.useCount ?? 0) * 0.02);
894
+ const costPenalty = Math.min(0.25, (record.tokenCostEstimate ?? estimateTokens(record.content)) / Math.max(memoryTokenBudget(request), 1));
895
+ return record.importance * 0.35 + record.confidence * 0.2 + recency * 0.15 + utility * 0.2 + useSignal - result.score * 0.02 - costPenalty;
896
+ }
897
+
898
+ function selectMemoryResultsWithinBudget(
899
+ results: MemorySearchResult[],
900
+ tokenBudget: number,
901
+ includeEvidence: boolean,
902
+ ): { results: MemorySearchResult[]; estimatedTokens: number } {
903
+ const selected: MemorySearchResult[] = [];
904
+ let used = 0;
905
+ for (const result of results) {
906
+ const tokens = estimateTokens(formatMemoryLine(result.record, includeEvidence));
907
+ if (selected.length > 0 && used + tokens > tokenBudget) continue;
908
+ selected.push(result);
909
+ used += tokens;
910
+ if (used >= tokenBudget) break;
911
+ }
912
+ return { results: selected, estimatedTokens: used };
913
+ }
914
+
915
+ function formatMemoryContext(results: MemorySearchResult[], tokenBudget: number, includeEvidence: boolean): string {
916
+ if (results.length === 0) return "";
917
+ const lines = [
918
+ `Memory context (${results.length} records, <=${tokenBudget} token budget). Treat as guidance; current repo evidence wins.`,
919
+ ...results.map((result) => `- ${formatMemoryLine(result.record, includeEvidence)}`),
920
+ ];
921
+ return lines.join("\n");
922
+ }
923
+
924
+ function formatMemoryLine(record: MemoryRecord, includeEvidence: boolean): string {
925
+ const meta = [
926
+ record.id,
927
+ record.category,
928
+ `${Math.round(record.confidence * 100)}%`,
929
+ record.topicKey ? `topic=${record.topicKey}` : undefined,
930
+ record.revisionCount > 1 ? `rev=${record.revisionCount}` : undefined,
931
+ ].filter(Boolean).join(" · ");
932
+ const content = truncateText(record.content, 260);
933
+ const evidence = includeEvidence && record.evidence ? ` evidence=${truncateText(record.evidence, 120)}` : "";
934
+ return `[${meta}] ${content}${evidence}`;
935
+ }
936
+
937
+ function estimateTokens(text: string): number {
938
+ return Math.max(1, Math.ceil(text.length / 4));
939
+ }
940
+
941
+ function truncateText(text: string, maxChars: number): string {
942
+ const normalized = normalizeContent(text);
943
+ if (normalized.length <= maxChars) return normalized;
944
+ return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
945
+ }