@rohirik/openltm-core 2.8.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.
Files changed (97) hide show
  1. package/README.md +67 -0
  2. package/assets/opencode/agents/aegis.md +211 -0
  3. package/assets/opencode/plugins/aegis.ts +3 -0
  4. package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
  5. package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
  6. package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
  7. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
  8. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
  9. package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
  10. package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
  11. package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
  12. package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
  13. package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
  14. package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
  15. package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
  16. package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
  17. package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
  18. package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
  19. package/package.json +41 -0
  20. package/src/__tests__/cli/claude.test.ts +122 -0
  21. package/src/__tests__/cli/detect.test.ts +91 -0
  22. package/src/__tests__/cli/install.test.ts +161 -0
  23. package/src/__tests__/cli/opencode.test.ts +169 -0
  24. package/src/__tests__/cli/pi.test.ts +113 -0
  25. package/src/__tests__/cli.test.ts +70 -0
  26. package/src/__tests__/events/crossProcess.test.ts +82 -0
  27. package/src/__tests__/events/index.test.ts +32 -0
  28. package/src/__tests__/extensions.test.ts +81 -0
  29. package/src/__tests__/migrations/retention.test.ts +118 -0
  30. package/src/__tests__/queue/index.test.ts +61 -0
  31. package/src/__tests__/scheduler/index.test.ts +39 -0
  32. package/src/__tests__/vec/index.test.ts +130 -0
  33. package/src/__tests__/vec/parity.test.ts +70 -0
  34. package/src/adapterTypes.ts +23 -0
  35. package/src/cli/_shared.ts +120 -0
  36. package/src/cli/bin.ts +97 -0
  37. package/src/cli/claude.ts +124 -0
  38. package/src/cli/detect.ts +55 -0
  39. package/src/cli/hook.ts +25 -0
  40. package/src/cli/index.ts +22 -0
  41. package/src/cli/install.ts +185 -0
  42. package/src/cli/opencode.ts +193 -0
  43. package/src/cli/pi.ts +74 -0
  44. package/src/cli/types.ts +78 -0
  45. package/src/config.ts +163 -0
  46. package/src/context.ts +172 -0
  47. package/src/dao/conflicts.ts +26 -0
  48. package/src/dao/contextItems.ts +70 -0
  49. package/src/dao/embeddings.ts +78 -0
  50. package/src/dao/index.ts +9 -0
  51. package/src/dao/provenanceAudit.ts +108 -0
  52. package/src/dao/types.ts +142 -0
  53. package/src/db.ts +780 -0
  54. package/src/dedup.ts +12 -0
  55. package/src/embeddings.ts +386 -0
  56. package/src/events/index.ts +130 -0
  57. package/src/extensions.ts +140 -0
  58. package/src/graph.ts +268 -0
  59. package/src/index.ts +95 -0
  60. package/src/janitor/archive.ts +66 -0
  61. package/src/janitor/decay.ts +60 -0
  62. package/src/janitor/dedup.ts +333 -0
  63. package/src/janitor/embeddings.ts +209 -0
  64. package/src/janitor/index.ts +215 -0
  65. package/src/janitor/promote.ts +188 -0
  66. package/src/janitor/providers/anthropic.ts +91 -0
  67. package/src/janitor/providers/cohere.ts +135 -0
  68. package/src/janitor/providers/gemini.ts +156 -0
  69. package/src/janitor/providers/ollama.ts +177 -0
  70. package/src/janitor/providers/openai.ts +121 -0
  71. package/src/janitor/providers/openrouter.ts +182 -0
  72. package/src/janitor/providers/types.ts +154 -0
  73. package/src/janitor/providers/utils.ts +35 -0
  74. package/src/janitor/supersedes.ts +199 -0
  75. package/src/lib/honker.ts +54 -0
  76. package/src/lib/honkerTypes.ts +109 -0
  77. package/src/lib/jsonlLogger.ts +92 -0
  78. package/src/lib/writeQueue.ts +28 -0
  79. package/src/migrations.ts +415 -0
  80. package/src/paths.ts +22 -0
  81. package/src/proposals.ts +120 -0
  82. package/src/providers/disabled.ts +19 -0
  83. package/src/providers/embeddingProvider.ts +49 -0
  84. package/src/providers/gemini.ts +37 -0
  85. package/src/providers/index.ts +2 -0
  86. package/src/providers/ollama.ts +43 -0
  87. package/src/providers/openai.ts +35 -0
  88. package/src/queue/index.ts +53 -0
  89. package/src/queue/worker.ts +77 -0
  90. package/src/recall/categorise.ts +139 -0
  91. package/src/recall/explainer.ts +76 -0
  92. package/src/scheduler/index.ts +97 -0
  93. package/src/schema.sql +191 -0
  94. package/src/secretsScrubber.ts +105 -0
  95. package/src/shared-db.ts +158 -0
  96. package/src/vec/index.ts +161 -0
  97. package/tsconfig.json +9 -0
package/src/graph.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * graph.ts — Graph traversal engine for LTM reasoning.
3
+ * BFS from a seed memory, up to configurable depth.
4
+ * Detects conflicts, reinforcements, and infers implicit edges via LLM.
5
+ */
6
+ import type { Database } from "bun:sqlite";
7
+ import { getDb } from "./shared-db.js";
8
+ import type { Memory, RelationshipType } from "./db.js";
9
+
10
+ export interface MemoryNode {
11
+ id: number;
12
+ content: string;
13
+ category: string;
14
+ importance: number;
15
+ project_scope: string | null;
16
+ }
17
+
18
+ export interface MemoryPair {
19
+ a: MemoryNode;
20
+ b: MemoryNode;
21
+ type: RelationshipType;
22
+ }
23
+
24
+ export interface InferredRelation {
25
+ a: MemoryNode;
26
+ b: MemoryNode;
27
+ type: RelationshipType;
28
+ persisted: boolean;
29
+ }
30
+
31
+ export interface TraversalResult {
32
+ chain: MemoryNode[];
33
+ conflicts: MemoryPair[];
34
+ reinforcements: MemoryPair[];
35
+ clusters: MemoryNode[][];
36
+ inferred: InferredRelation[];
37
+ }
38
+
39
+ const CONFLICT_TYPES = new Set<RelationshipType>(["contradicts", "supersedes"]);
40
+ const REINFORCE_TYPES = new Set<RelationshipType>(["supports", "refines"]);
41
+
42
+ function edgeKey(a: number, b: number): string {
43
+ return `${Math.min(a, b)}:${Math.max(a, b)}`;
44
+ }
45
+
46
+ interface EdgeRow {
47
+ neighbor_id: number;
48
+ relationship_type: RelationshipType;
49
+ }
50
+
51
+ function getNeighbors(db: Database, id: number): EdgeRow[] {
52
+ return db.query<EdgeRow, [number, number]>(
53
+ `SELECT target_memory_id as neighbor_id, relationship_type FROM memory_relations WHERE source_memory_id=?
54
+ UNION ALL
55
+ SELECT source_memory_id as neighbor_id, relationship_type FROM memory_relations WHERE target_memory_id=?`
56
+ ).all(id, id);
57
+ }
58
+
59
+ function getMemoryNode(db: Database, id: number): MemoryNode | null {
60
+ return db.query<MemoryNode, [number]>(
61
+ `SELECT id, content, category, importance, project_scope FROM memories WHERE id=? AND status='active'`
62
+ ).get(id) ?? null;
63
+ }
64
+
65
+ /**
66
+ * BFS traversal from startId up to depth hops.
67
+ * Returns ordered chain (BFS order), plus pairs classified by edge type.
68
+ */
69
+ export async function traverseGraph(
70
+ startId: number,
71
+ depth = 2,
72
+ inferImplicit = false,
73
+ ): Promise<TraversalResult> {
74
+ const db = getDb();
75
+
76
+ const seedNode = getMemoryNode(db, startId);
77
+ if (!seedNode) {
78
+ return { chain: [], conflicts: [], reinforcements: [], clusters: [], inferred: [] };
79
+ }
80
+
81
+ const visited = new Set<number>([startId]);
82
+ const chain: MemoryNode[] = [seedNode];
83
+ const conflicts: MemoryPair[] = [];
84
+ const reinforcements: MemoryPair[] = [];
85
+
86
+ // BFS queue: [nodeId, depth]
87
+ const queue: Array<[number, number]> = [[startId, 0]];
88
+
89
+ const edgeMap = new Map<string, RelationshipType>(); // "a:b" -> type
90
+
91
+ while (queue.length > 0) {
92
+ const [curId, curDepth] = queue.shift()!;
93
+ if (curDepth >= depth) continue;
94
+
95
+ const curNode = getMemoryNode(db, curId);
96
+ if (!curNode) continue;
97
+
98
+ const neighbors = getNeighbors(db, curId);
99
+ for (const edge of neighbors) {
100
+ const neighborNode = getMemoryNode(db, edge.neighbor_id);
101
+ if (!neighborNode) continue;
102
+
103
+ edgeMap.set(edgeKey(curId, edge.neighbor_id), edge.relationship_type);
104
+
105
+ if (CONFLICT_TYPES.has(edge.relationship_type)) {
106
+ conflicts.push({ a: curNode, b: neighborNode, type: edge.relationship_type });
107
+ } else if (REINFORCE_TYPES.has(edge.relationship_type)) {
108
+ reinforcements.push({ a: curNode, b: neighborNode, type: edge.relationship_type });
109
+ }
110
+
111
+ if (!visited.has(edge.neighbor_id)) {
112
+ visited.add(edge.neighbor_id);
113
+ chain.push(neighborNode);
114
+ queue.push([edge.neighbor_id, curDepth + 1]);
115
+ }
116
+ }
117
+ }
118
+
119
+ // Build connected components (clusters) from visited nodes
120
+ const clusters = buildClusters(db, [...visited]);
121
+
122
+ // Optionally infer implicit edges between visited nodes that lack a direct edge
123
+ const inferred: InferredRelation[] = [];
124
+ if (inferImplicit && chain.length >= 2) {
125
+ const { classifyRelation } = await import("./embeddings.js");
126
+ const { relate } = await import("./db.js");
127
+
128
+ const pairs = getPairsWithoutEdge(chain, edgeMap);
129
+ const results = await Promise.allSettled(
130
+ pairs.map(async ([a, b]) => {
131
+ const relType = await classifyRelation(a.content, b.content);
132
+ if (!relType) return null;
133
+
134
+ let persisted = false;
135
+ try {
136
+ relate({ source_id: a.id, target_id: b.id, relationship_type: relType });
137
+ persisted = true;
138
+ } catch {
139
+ // Memory deleted between traversal and relate — ignore
140
+ }
141
+
142
+ return { a, b, type: relType as RelationshipType, persisted };
143
+ })
144
+ );
145
+
146
+ for (const r of results) {
147
+ if (r.status === "fulfilled" && r.value) {
148
+ inferred.push(r.value);
149
+ if (CONFLICT_TYPES.has(r.value.type)) {
150
+ conflicts.push({ a: r.value.a, b: r.value.b, type: r.value.type });
151
+ } else if (REINFORCE_TYPES.has(r.value.type)) {
152
+ reinforcements.push({ a: r.value.a, b: r.value.b, type: r.value.type });
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ return { chain, conflicts, reinforcements, clusters, inferred };
159
+ }
160
+
161
+ function getPairsWithoutEdge(nodes: MemoryNode[], edgeMap: Map<string, RelationshipType>): Array<[MemoryNode, MemoryNode]> {
162
+ const pairs: Array<[MemoryNode, MemoryNode]> = [];
163
+ // Limit to avoid O(n^2) explosion — cap at first 6 nodes (15 pairs max)
164
+ const subset = nodes.slice(0, 6);
165
+ for (let i = 0; i < subset.length; i++) {
166
+ for (let j = i + 1; j < subset.length; j++) {
167
+ const a = subset[i]!;
168
+ const b = subset[j]!;
169
+ if (!edgeMap.has(edgeKey(a.id, b.id))) {
170
+ pairs.push([a, b]);
171
+ }
172
+ }
173
+ }
174
+ return pairs;
175
+ }
176
+
177
+ function buildClusters(db: Database, ids: number[]): MemoryNode[][] {
178
+ if (ids.length === 0) return [];
179
+ const idSet = new Set(ids);
180
+ const adj = new Map<number, Set<number>>();
181
+ for (const id of ids) adj.set(id, new Set());
182
+
183
+ // Single batch query instead of N×getNeighbors calls
184
+ const placeholders = ids.map(() => "?").join(",");
185
+ const relations = db.query<{ source: number; target: number }, number[]>(
186
+ `SELECT source_memory_id as source, target_memory_id as target FROM memory_relations
187
+ WHERE source_memory_id IN (${placeholders}) OR target_memory_id IN (${placeholders})`
188
+ ).all(...ids, ...ids);
189
+
190
+ for (const r of relations) {
191
+ if (idSet.has(r.source) && idSet.has(r.target)) {
192
+ adj.get(r.source)!.add(r.target);
193
+ adj.get(r.target)!.add(r.source);
194
+ }
195
+ }
196
+
197
+ const visited = new Set<number>();
198
+ const clusters: MemoryNode[][] = [];
199
+
200
+ for (const id of ids) {
201
+ if (visited.has(id)) continue;
202
+ const cluster: MemoryNode[] = [];
203
+ const q = [id];
204
+ while (q.length > 0) {
205
+ const cur = q.pop()!;
206
+ if (visited.has(cur)) continue;
207
+ visited.add(cur);
208
+ const node = getMemoryNode(db, cur);
209
+ if (node) cluster.push(node);
210
+ for (const neighbor of adj.get(cur) ?? []) {
211
+ if (!visited.has(neighbor)) q.push(neighbor);
212
+ }
213
+ }
214
+ if (cluster.length > 0) clusters.push(cluster);
215
+ }
216
+
217
+ return clusters;
218
+ }
219
+
220
+ /**
221
+ * Build a human-readable reasoning context block (max 5 bullets).
222
+ * Suitable for injecting into session context.
223
+ */
224
+ export function buildReasoningContext(result: TraversalResult): string {
225
+ const lines: string[] = [];
226
+
227
+ if (result.chain.length > 1) {
228
+ const chainSnippet = result.chain
229
+ .slice(0, 4)
230
+ .map(n => n.content.substring(0, 50).replace(/\n/g, " "))
231
+ .join(" → ");
232
+ lines.push(`[Chain] ${chainSnippet}`);
233
+ }
234
+
235
+ for (const c of result.conflicts.slice(0, 2)) {
236
+ const a = c.a.content.substring(0, 40).replace(/\n/g, " ");
237
+ const b = c.b.content.substring(0, 40).replace(/\n/g, " ");
238
+ lines.push(`[Conflict] "${a}" ↔ "${b}" — verify before applying`);
239
+ }
240
+
241
+ if (result.reinforcements.length > 0) {
242
+ const count = result.reinforcements.length;
243
+ const sample = result.reinforcements[0]!;
244
+ const topic = sample.a.content.substring(0, 50).replace(/\n/g, " ");
245
+ lines.push(`[Reinforcement] ${count} memor${count === 1 ? "y" : "ies"} agree: "${topic}"`);
246
+ }
247
+
248
+ return lines.slice(0, 5).join("\n");
249
+ }
250
+
251
+ // CLI entry: bun graph.ts <memoryId> [depth]
252
+ if (import.meta.main) {
253
+ const id = parseInt(process.argv[2] ?? "0", 10);
254
+ const depth = parseInt(process.argv[3] ?? "2", 10);
255
+ if (!id) {
256
+ console.error("Usage: bun graph.ts <memoryId> [depth=2]");
257
+ process.exit(1);
258
+ }
259
+ const result = await traverseGraph(id, depth, true);
260
+ console.log(JSON.stringify({
261
+ chainLength: result.chain.length,
262
+ conflicts: result.conflicts.length,
263
+ reinforcements: result.reinforcements.length,
264
+ clusters: result.clusters.length,
265
+ inferred: result.inferred.length,
266
+ context: buildReasoningContext(result),
267
+ }, null, 2));
268
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ // @rohirik/openltm-core — public API
2
+ export type { LtmCoreConfig, LtmAdapterContext, LtmAdapterOptions } from "./adapterTypes.js";
3
+
4
+ // DB singleton + configuration
5
+ export { getDb, initDb, configure, waitForInit, _setDbForTesting, withRetry, DB_PATH,
6
+ getSetting, setSetting, getAllSettings } from "./shared-db.js";
7
+ export { configureCore, configureDocs } from "./db.js";
8
+
9
+ // Core memory operations
10
+ export {
11
+ learn, recall, forget, relate, getSimilarMemories,
12
+ getContextMerge, getContextMergeWithGraph, computeDecayScore,
13
+ exportMarkdown, exportGraphJson,
14
+ } from "./db.js";
15
+ export type {
16
+ Memory, MemoryWithRelations, MemoryCategory, RelationshipType, MemoryRelation,
17
+ LearnInput, LearnResult, RecallInput, DecayResult,
18
+ } from "./db.js";
19
+
20
+ // Context items
21
+ export { addItem, getItems, exportContextMarkdown, trimProgress, promote } from "./context.js";
22
+
23
+ // Migrations
24
+ export { runPendingMigrations, getMigrationStatus, getMigrationFiles } from "./migrations.js";
25
+
26
+ // DAO types
27
+ export type {
28
+ ContextItemRow, ContextItemType, MemorySlim, AuditOp, ProvenanceRow, ConflictRow,
29
+ } from "./dao/types.js";
30
+ export { getRecentConflicts } from "./dao/conflicts.js";
31
+ export { insertProvenance, insertAudit, queryAudit, listProvenance, snapshotMemory, listProvenanceBatch } from "./dao/provenanceAudit.js";
32
+ export { setEmbedding, getEmbedding, deleteEmbedding, listMemoryIdsMissingEmbedding } from "./dao/embeddings.js";
33
+ export { listByProject, upsertGoal, appendProgress, addDecision, addGotcha } from "./dao/contextItems.js";
34
+
35
+ // Utilities
36
+ export { scrubSecrets } from "./secretsScrubber.js";
37
+ export { normalizeKey } from "./dedup.js";
38
+ export { embedText, getLlmConfig, callLlm } from "./embeddings.js";
39
+
40
+ // Recall utilities
41
+ export { categorise } from "./recall/categorise.js";
42
+ export { buildExplainer, computeTemperature } from "./recall/explainer.js";
43
+ export type { MemoryTemperature, RecallExplainer, ExplainerInput } from "./recall/explainer.js";
44
+
45
+ // Embedding providers
46
+ export * from "./providers/index.js";
47
+ export { WriteQueue, writeQueue } from "./lib/writeQueue.js";
48
+ export { emitEvent, readRecentEvents, getLogPath, _resetForTesting } from "./lib/jsonlLogger.js";
49
+ export type { LtmEvent } from "./lib/jsonlLogger.js";
50
+
51
+ // Proposals
52
+ export { listPendingProposals, acceptProposal, rejectProposal } from "./proposals.js";
53
+ export type { PendingProposal } from "./proposals.js";
54
+
55
+ // Janitor
56
+ export {
57
+ runJanitor, getJanitorStatus, startAutoRun, stopAutoRun,
58
+ runArchive, touchMemory,
59
+ approveMemory, getPendingMemories, rejectMemory,
60
+ mergeMemories, parseDedupSource,
61
+ supersede,
62
+ getEmbeddingProvider, semanticSearch, findSimilarMemories,
63
+ } from "./janitor/index.js";
64
+ export { runDecay } from "./janitor/decay.js";
65
+ export { SETTING_KEYS, SETTING_DEFAULTS } from "./janitor/providers/types.js";
66
+ export { anthropicLLM } from "./janitor/providers/anthropic.js";
67
+ export { cohereEmbedding } from "./janitor/providers/cohere.js";
68
+ export { geminiEmbedding, geminiLLM } from "./janitor/providers/gemini.js";
69
+ export { ollamaEmbedding } from "./janitor/providers/ollama.js";
70
+ export { openaiEmbedding } from "./janitor/providers/openai.js";
71
+ export { openrouterEmbedding } from "./janitor/providers/openrouter.js";
72
+
73
+ // Graph
74
+ export { traverseGraph, buildReasoningContext } from "./graph.js";
75
+
76
+ // Extension capability probe (custom SQLite / sqlite-vec / Honker)
77
+ export { getCapabilities, loadExtensions } from "./extensions.js";
78
+ export type { Capabilities } from "./extensions.js";
79
+
80
+ // Honker durable queue (embedding generation) — dormant without LTM_HONKER_EXT
81
+ export { getHonker, isHonkerAvailable } from "./lib/honker.js";
82
+ export { EMBED_QUEUE, getEmbeddingQueue, enqueueEmbedding, parseEmbeddingJob } from "./queue/index.js";
83
+ export type { EmbeddingJob } from "./queue/index.js";
84
+ export { startEmbeddingWorker } from "./queue/worker.js";
85
+ export type { EmbeddingWorkerHandle } from "./queue/worker.js";
86
+ export {
87
+ registerJanitorSchedule, startJanitorScheduler,
88
+ JANITOR_SCHEDULE_NAME, JANITOR_QUEUE, JANITOR_CHANNEL, DEFAULT_JANITOR_CRON,
89
+ } from "./scheduler/index.js";
90
+ export type { JanitorSchedulerHandle } from "./scheduler/index.js";
91
+ export {
92
+ notifyLtm, startLtmListener, LTM_CHANNEL, MEMORY_ADDED,
93
+ isCrossProcessSyncEnabled, notifyMemoryAdded, startCrossProcessSync,
94
+ } from "./events/index.js";
95
+ export type { LtmLiveEvent, LtmListenerHandle } from "./events/index.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * archive.ts — Evict fully-decayed deprecated memories into memory_archive.
3
+ *
4
+ * Eviction criteria (all must match):
5
+ * status = 'deprecated' AND
6
+ * importance <= 2 AND (high-importance memories only deprecate, never evict)
7
+ * recall_count <= 1 AND (recalled even once → second chance next cycle)
8
+ * decay_score < 0.10 (well below the 0.25 deprecation threshold)
9
+ *
10
+ * Full memory JSON is preserved in memory_archive before deletion.
11
+ * Max 100 evictions per run to bound transaction duration.
12
+ */
13
+ import { getDb } from "../shared-db.js";
14
+ import type { Memory } from "../db.js";
15
+ import { insertAudit, snapshotMemory } from "../dao/provenanceAudit.js";
16
+
17
+ const BATCH_SIZE = 100;
18
+
19
+ export interface ArchiveResult {
20
+ /** Memories moved to memory_archive and deleted from memories. */
21
+ archived: number;
22
+ }
23
+
24
+ export function runArchive(): ArchiveResult {
25
+ const db = getDb();
26
+
27
+ const candidates = db.query<Memory, []>(
28
+ `SELECT id, content, category, importance, confidence, source, project_scope, dedup_key,
29
+ created_at, last_confirmed_at, last_used_at, confirm_count, status,
30
+ first_recalled_at, last_recalled_at, recall_count, superseded_by, superseded_at,
31
+ workspace_id, agent_id, decay_score
32
+ FROM memories
33
+ WHERE status = 'deprecated'
34
+ AND importance <= 2
35
+ AND COALESCE(recall_count, 0) <= 1
36
+ AND COALESCE(decay_score, 0) < 0.10
37
+ LIMIT ${BATCH_SIZE}`,
38
+ ).all();
39
+
40
+ if (candidates.length === 0) return { archived: 0 };
41
+
42
+ const ids = candidates.map(m => m.id);
43
+ const placeholders = ids.map(() => "?").join(",");
44
+
45
+ const archive = db.transaction((mems: Memory[]) => {
46
+ for (const mem of mems) {
47
+ const beforeSnap = snapshotMemory(db, mem.id);
48
+ db.run(
49
+ `INSERT OR IGNORE INTO memory_archive (id, memory_json, reason, decay_score, project_scope)
50
+ VALUES (?, ?, 'decay', ?, ?)`,
51
+ [mem.id, JSON.stringify(mem), mem.decay_score ?? null, mem.project_scope ?? null],
52
+ );
53
+ insertAudit(db, {
54
+ memory_id: mem.id,
55
+ op: "archive",
56
+ actor: "janitor:archive",
57
+ before_json: beforeSnap ? JSON.stringify(beforeSnap) : null,
58
+ after_json: null,
59
+ });
60
+ }
61
+ db.run(`DELETE FROM memories WHERE id IN (${placeholders})`, ids);
62
+ return db.query<{ n: number }, []>("SELECT changes() AS n").get()!.n;
63
+ });
64
+
65
+ return { archived: archive(candidates) };
66
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * decay.ts — Batch decay refresh using materialised decay_score column (Phase 4).
3
+ * Two SQL UPDATE statements inside one transaction replace the old O(N) JS loop.
4
+ */
5
+ import { getDb } from "../shared-db.js";
6
+
7
+ export interface DecayResult {
8
+ /** Memories whose decay_score was refreshed. */
9
+ refreshed: number;
10
+ /** Memories that crossed the deprecation threshold this run. */
11
+ deprecated: number;
12
+ }
13
+
14
+ const SQL_REFRESH = `
15
+ UPDATE memories
16
+ SET decay_score = CASE
17
+ WHEN importance = 5 THEN CAST(importance AS REAL) * confidence
18
+ WHEN confirm_count >= 10 THEN CAST(importance AS REAL) * confidence
19
+ WHEN importance = 4 THEN CAST(importance AS REAL) * confidence
20
+ * power(0.5, (julianday('now') - julianday(COALESCE(last_used_at, last_confirmed_at, created_at))) / 180.0)
21
+ WHEN importance = 3 THEN CAST(importance AS REAL) * confidence
22
+ * power(0.5, (julianday('now') - julianday(COALESCE(last_used_at, last_confirmed_at, created_at))) / 90.0)
23
+ WHEN importance = 2 THEN CAST(importance AS REAL) * confidence
24
+ * power(0.5, (julianday('now') - julianday(COALESCE(last_used_at, last_confirmed_at, created_at))) / 30.0)
25
+ ELSE CAST(importance AS REAL) * confidence
26
+ * power(0.5, (julianday('now') - julianday(COALESCE(last_used_at, last_confirmed_at, created_at))) / 14.0)
27
+ END
28
+ WHERE status = 'active'
29
+ `;
30
+
31
+ const SQL_DEPRECATE = `
32
+ UPDATE memories
33
+ SET status = 'deprecated'
34
+ WHERE status = 'active'
35
+ AND importance < 5
36
+ AND confirm_count < 10
37
+ AND decay_score < 0.25
38
+ `;
39
+
40
+ export function runDecay(): DecayResult {
41
+ const db = getDb();
42
+
43
+ const run = db.transaction(() => {
44
+ db.run(SQL_REFRESH);
45
+ const refreshed = db.query<{ n: number }, []>("SELECT changes() AS n").get()!.n;
46
+
47
+ db.run(SQL_DEPRECATE);
48
+ const deprecated = db.query<{ n: number }, []>("SELECT changes() AS n").get()!.n;
49
+
50
+ return { refreshed, deprecated };
51
+ });
52
+
53
+ return run();
54
+ }
55
+
56
+ /** Touch a memory's last_used_at timestamp when it is recalled or confirmed. */
57
+ export function touchMemory(memoryId: number): void {
58
+ const db = getDb();
59
+ db.run(`UPDATE memories SET last_used_at = datetime('now') WHERE id = ?`, [memoryId]);
60
+ }