memorix 1.2.2 → 1.2.3

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 (139) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +34 -18
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/index.js +17 -8
  6. package/dist/index.js.map +1 -1
  7. package/dist/maintenance-runner.js.map +1 -1
  8. package/dist/memcode-runtime/CHANGELOG.md +7 -0
  9. package/dist/sdk.js +17 -8
  10. package/dist/sdk.js.map +1 -1
  11. package/docs/DESIGN_DECISIONS.md +357 -357
  12. package/docs/dev-log/progress.txt +18 -8
  13. package/package.json +1 -1
  14. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  15. package/src/audit/index.ts +156 -156
  16. package/src/cli/commands/audit-list.ts +89 -89
  17. package/src/cli/commands/background.ts +659 -659
  18. package/src/cli/commands/formation.ts +48 -48
  19. package/src/cli/commands/git-hook-install.ts +111 -111
  20. package/src/cli/commands/handoff.ts +54 -54
  21. package/src/cli/commands/hooks-status.ts +63 -63
  22. package/src/cli/commands/ingest-commit.ts +153 -153
  23. package/src/cli/commands/ingest-image.ts +66 -66
  24. package/src/cli/commands/ingest-log.ts +180 -180
  25. package/src/cli/commands/ingest.ts +44 -44
  26. package/src/cli/commands/integrate-shared.ts +15 -15
  27. package/src/cli/commands/lock.ts +82 -82
  28. package/src/cli/commands/message.ts +104 -104
  29. package/src/cli/commands/poll.ts +58 -58
  30. package/src/cli/commands/purge-all-memory.ts +85 -85
  31. package/src/cli/commands/purge-project-memory.ts +83 -83
  32. package/src/cli/commands/reasoning.ts +118 -118
  33. package/src/cli/commands/serve-shared.ts +118 -118
  34. package/src/cli/commands/session.ts +15 -7
  35. package/src/cli/commands/skills.ts +114 -114
  36. package/src/cli/commands/task.ts +167 -167
  37. package/src/cli/commands/transfer.ts +47 -47
  38. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  39. package/src/cli/tui/ChatView.tsx +234 -234
  40. package/src/cli/tui/CommandBar.tsx +312 -312
  41. package/src/cli/tui/ContextRail.tsx +118 -118
  42. package/src/cli/tui/HeaderBar.tsx +72 -72
  43. package/src/cli/tui/LogoBanner.tsx +51 -51
  44. package/src/cli/tui/Sidebar.tsx +179 -179
  45. package/src/cli/tui/index.ts +41 -41
  46. package/src/cli/tui/markdown-render.tsx +371 -371
  47. package/src/cli/tui/session-service.ts +3 -2
  48. package/src/cli/tui/use-mouse.ts +157 -157
  49. package/src/cli/tui/useNavigation.ts +56 -56
  50. package/src/cli/update-checker.ts +211 -211
  51. package/src/cli/version.ts +7 -7
  52. package/src/cli/workbench.ts +1 -1
  53. package/src/compact/token-budget.ts +74 -74
  54. package/src/dashboard/project-classification.ts +64 -64
  55. package/src/embedding/fastembed-provider.ts +142 -142
  56. package/src/embedding/transformers-provider.ts +111 -111
  57. package/src/git/extractor.ts +209 -209
  58. package/src/git/hooks-path.ts +85 -85
  59. package/src/hooks/pattern-detector.ts +173 -173
  60. package/src/hooks/significance-filter.ts +250 -250
  61. package/src/llm/memory-manager.ts +328 -328
  62. package/src/llm/provider.ts +885 -885
  63. package/src/llm/quality.ts +248 -248
  64. package/src/memory/attribution-guard.ts +249 -249
  65. package/src/memory/disclosure-policy.ts +135 -135
  66. package/src/memory/entity-extractor.ts +197 -197
  67. package/src/memory/formation/evaluate.ts +217 -217
  68. package/src/memory/formation/extract.ts +361 -361
  69. package/src/memory/formation/index.ts +417 -417
  70. package/src/memory/formation/resolve.ts +344 -344
  71. package/src/memory/formation/types.ts +315 -315
  72. package/src/memory/freshness.ts +122 -122
  73. package/src/memory/graph.ts +197 -197
  74. package/src/memory/refs.ts +94 -94
  75. package/src/memory/secret-filter.ts +79 -79
  76. package/src/memory/session.ts +24 -9
  77. package/src/multimodal/image-loader.ts +143 -143
  78. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  79. package/src/orchestrate/adapters/claude.ts +111 -111
  80. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  81. package/src/orchestrate/adapters/codex.ts +41 -41
  82. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  83. package/src/orchestrate/adapters/gemini.ts +42 -42
  84. package/src/orchestrate/adapters/index.ts +73 -73
  85. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  86. package/src/orchestrate/adapters/opencode.ts +47 -47
  87. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  88. package/src/orchestrate/adapters/types.ts +77 -77
  89. package/src/orchestrate/capability-router.ts +284 -284
  90. package/src/orchestrate/context-compact.ts +188 -188
  91. package/src/orchestrate/cost-tracker.ts +219 -219
  92. package/src/orchestrate/error-recovery.ts +191 -191
  93. package/src/orchestrate/evidence.ts +140 -140
  94. package/src/orchestrate/ledger.ts +110 -110
  95. package/src/orchestrate/memorix-bridge.ts +343 -343
  96. package/src/orchestrate/output-budget.ts +80 -80
  97. package/src/orchestrate/permission.ts +152 -152
  98. package/src/orchestrate/pipeline-trace.ts +131 -131
  99. package/src/orchestrate/prompt-builder.ts +155 -155
  100. package/src/orchestrate/ring-buffer.ts +37 -37
  101. package/src/orchestrate/task-graph.ts +389 -389
  102. package/src/orchestrate/worktree.ts +232 -232
  103. package/src/project/aliases.ts +374 -374
  104. package/src/project/detector.ts +268 -268
  105. package/src/rules/adapters/claude-code.ts +99 -99
  106. package/src/rules/adapters/codex.ts +97 -97
  107. package/src/rules/adapters/copilot.ts +124 -124
  108. package/src/rules/adapters/cursor.ts +114 -114
  109. package/src/rules/adapters/kiro.ts +126 -126
  110. package/src/rules/adapters/trae.ts +56 -56
  111. package/src/rules/adapters/windsurf.ts +83 -83
  112. package/src/rules/syncer.ts +235 -235
  113. package/src/sdk.ts +299 -299
  114. package/src/search/intent-detector.ts +289 -289
  115. package/src/search/query-expansion.ts +52 -52
  116. package/src/server/formation-timeout.ts +27 -27
  117. package/src/server.ts +7 -2
  118. package/src/skills/mini-skills.ts +386 -386
  119. package/src/store/chat-store.ts +119 -119
  120. package/src/store/graph-store.ts +249 -249
  121. package/src/store/mini-skill-store.ts +349 -349
  122. package/src/store/persistence-json.ts +212 -212
  123. package/src/store/persistence.ts +291 -291
  124. package/src/store/project-affinity.ts +195 -195
  125. package/src/team/event-bus.ts +76 -76
  126. package/src/team/file-locks.ts +173 -173
  127. package/src/team/handoff.ts +161 -161
  128. package/src/team/messages.ts +203 -203
  129. package/src/team/poll.ts +132 -132
  130. package/src/team/tasks.ts +211 -211
  131. package/src/workspace/mcp-adapters/codex.ts +191 -191
  132. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  133. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  134. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  135. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  136. package/src/workspace/mcp-adapters/trae.ts +134 -134
  137. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  138. package/src/workspace/sanitizer.ts +60 -60
  139. package/src/workspace/workflow-sync.ts +131 -131
@@ -1,82 +1,82 @@
1
- /**
2
- * Memory Formation — Stage 2: Resolve
3
- *
4
- * Determines what to do with an enriched memory: create new, merge into
5
- * existing, evolve (supersede), or discard as redundant.
6
- *
7
- * This stage absorbs and replaces the previous "Compact on Write" logic
8
- * (src/llm/memory-manager.ts) with a richer resolution model:
9
- *
10
- * - new: Truly new knowledge → proceed to store
11
- * - merge: Same topic as existing → UPDATE with combined content
12
- * - evolve: Existing is outdated → UPDATE with new content as primary
13
- * - discard: Redundant or noise → skip storage entirely
14
- *
15
- * Rules-based mode uses similarity scores, entity overlap, fact comparison,
16
- * and contradiction detection to make decisions without LLM.
17
- */
18
-
19
- import type { ExtractResult, ResolveResult, SearchHit, ExistingMemoryRef } from './types.js';
20
-
21
- // ── Thresholds ───────────────────────────────────────────────────
22
-
23
- /** Above this: very likely same topic */
24
- const SIMILARITY_HIGH = 0.75;
25
- /** Above this: related topic */
26
- const SIMILARITY_MEDIUM = 0.50;
27
- /** Above this: exact duplicate — discard */
28
- const SIMILARITY_DUPLICATE = 0.90;
29
-
30
- // ── Content Comparison Utilities ─────────────────────────────────
31
-
32
- /**
33
- * Compute Jaccard similarity between two sets of normalized words.
34
- */
35
- function wordOverlap(a: string, b: string): number {
36
- const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
37
- const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
38
- if (wordsA.size === 0 || wordsB.size === 0) return 0;
39
-
40
- let intersection = 0;
41
- for (const w of wordsA) {
42
- if (wordsB.has(w)) intersection++;
43
- }
44
- return intersection / Math.max(wordsA.size, wordsB.size);
45
- }
46
-
47
- /**
48
- * Check if two entity names refer to the same concept.
49
- */
50
- function entitiesMatch(a: string, b: string): boolean {
51
- const na = a.toLowerCase().replace(/[-_]/g, '');
52
- const nb = b.toLowerCase().replace(/[-_]/g, '');
53
- if (na === nb) return true;
54
- if (na.length >= 3 && nb.length >= 3) {
55
- if (na.includes(nb) || nb.includes(na)) return true;
56
- }
57
- return false;
58
- }
59
-
60
- /**
61
- * Detect potential contradiction between old and new content.
62
- * Looks for negation patterns and opposing statements.
63
- */
1
+ /**
2
+ * Memory Formation — Stage 2: Resolve
3
+ *
4
+ * Determines what to do with an enriched memory: create new, merge into
5
+ * existing, evolve (supersede), or discard as redundant.
6
+ *
7
+ * This stage absorbs and replaces the previous "Compact on Write" logic
8
+ * (src/llm/memory-manager.ts) with a richer resolution model:
9
+ *
10
+ * - new: Truly new knowledge → proceed to store
11
+ * - merge: Same topic as existing → UPDATE with combined content
12
+ * - evolve: Existing is outdated → UPDATE with new content as primary
13
+ * - discard: Redundant or noise → skip storage entirely
14
+ *
15
+ * Rules-based mode uses similarity scores, entity overlap, fact comparison,
16
+ * and contradiction detection to make decisions without LLM.
17
+ */
18
+
19
+ import type { ExtractResult, ResolveResult, SearchHit, ExistingMemoryRef } from './types.js';
20
+
21
+ // ── Thresholds ───────────────────────────────────────────────────
22
+
23
+ /** Above this: very likely same topic */
24
+ const SIMILARITY_HIGH = 0.75;
25
+ /** Above this: related topic */
26
+ const SIMILARITY_MEDIUM = 0.50;
27
+ /** Above this: exact duplicate — discard */
28
+ const SIMILARITY_DUPLICATE = 0.90;
29
+
30
+ // ── Content Comparison Utilities ─────────────────────────────────
31
+
32
+ /**
33
+ * Compute Jaccard similarity between two sets of normalized words.
34
+ */
35
+ function wordOverlap(a: string, b: string): number {
36
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
37
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
38
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
39
+
40
+ let intersection = 0;
41
+ for (const w of wordsA) {
42
+ if (wordsB.has(w)) intersection++;
43
+ }
44
+ return intersection / Math.max(wordsA.size, wordsB.size);
45
+ }
46
+
47
+ /**
48
+ * Check if two entity names refer to the same concept.
49
+ */
50
+ function entitiesMatch(a: string, b: string): boolean {
51
+ const na = a.toLowerCase().replace(/[-_]/g, '');
52
+ const nb = b.toLowerCase().replace(/[-_]/g, '');
53
+ if (na === nb) return true;
54
+ if (na.length >= 3 && nb.length >= 3) {
55
+ if (na.includes(nb) || nb.includes(na)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Detect potential contradiction between old and new content.
62
+ * Looks for negation patterns and opposing statements.
63
+ */
64
64
  function hasContradiction(oldText: string, newText: string): boolean {
65
- // Simple heuristic: check for "not X" in new when "X" is in old
66
- const negationPatterns = [
67
- /\bnot\s+(\w+)/gi,
68
- /\bno longer\b/i,
69
- /\binstead of\b/i,
70
- /\breplaced\b.*\bwith\b/i,
71
- /\bremoved\b/i,
72
- /\bdeprecated\b/i,
73
- /\bobsolete\b/i,
74
- /不再/,
75
- /已弃用/,
76
- /替换为/,
77
- /改为/,
78
- ];
79
-
65
+ // Simple heuristic: check for "not X" in new when "X" is in old
66
+ const negationPatterns = [
67
+ /\bnot\s+(\w+)/gi,
68
+ /\bno longer\b/i,
69
+ /\binstead of\b/i,
70
+ /\breplaced\b.*\bwith\b/i,
71
+ /\bremoved\b/i,
72
+ /\bdeprecated\b/i,
73
+ /\bobsolete\b/i,
74
+ /不再/,
75
+ /已弃用/,
76
+ /替换为/,
77
+ /改为/,
78
+ ];
79
+
80
80
  return negationPatterns.some(p => p.test(newText));
81
81
  }
82
82
 
@@ -90,141 +90,141 @@ function normalizedSearchSimilarity(score: number): number {
90
90
  if (score <= 1) return score;
91
91
  return 0;
92
92
  }
93
-
94
- /**
95
- * Merge two narratives, keeping the most comprehensive version.
96
- */
97
- function mergeNarratives(oldNarrative: string, newNarrative: string): string {
98
- if (newNarrative.length > oldNarrative.length * 1.5) return newNarrative;
99
- if (oldNarrative.length > newNarrative.length * 1.5) return oldNarrative;
100
- return `${newNarrative}\n\n[Previous context]: ${oldNarrative}`;
101
- }
102
-
103
- /**
104
- * Merge two fact lists, deduplicating by normalized text.
105
- */
106
- function mergeFacts(oldFacts: string[], newFacts: string[]): string[] {
107
- const seen = new Set<string>();
108
- const merged: string[] = [];
109
-
110
- // New facts first (more recent)
111
- for (const f of newFacts) {
112
- const norm = f.toLowerCase().trim();
113
- if (!seen.has(norm) && f.trim().length > 0) {
114
- seen.add(norm);
115
- merged.push(f);
116
- }
117
- }
118
- for (const f of oldFacts) {
119
- const norm = f.toLowerCase().trim();
120
- if (!seen.has(norm) && f.trim().length > 0) {
121
- seen.add(norm);
122
- merged.push(f);
123
- }
124
- }
125
-
126
- return merged;
127
- }
128
-
129
- // ── LLM Resolution (Mem0-style) ───────────────────────────────
130
-
131
- const LLM_RESOLVE_PROMPT = `You are a Memory Consolidation Manager for a software engineering knowledge base.
132
-
133
- You must decide what to do with a NEW memory given EXISTING memories that are similar.
134
-
135
- Operations:
136
- - ADD: The new memory contains genuinely new information not present in existing memories.
137
- - UPDATE: The new memory adds to or refines an existing memory. Specify which existing memory ID to update.
138
- - DELETE: The new memory contradicts an existing memory. Specify which existing memory ID to delete.
139
- - NOOP: The new memory is redundant (already covered by existing memories). Skip storage.
140
-
141
- Rules:
142
- - Return ONLY a JSON object
143
- - If UPDATE: merge the best information from both old and new
144
- - If DELETE: the new memory supersedes the old (contradiction detected)
145
- - Prefer UPDATE over ADD when the topic is the same but information differs
146
- - Prefer NOOP over ADD when the information is essentially the same
147
-
148
- Response format:
149
- {"action": "ADD|UPDATE|DELETE|NOOP", "targetId": <number or null>, "reason": "<brief explanation>", "mergedText": "<merged content for UPDATE, or null>"}`;
150
-
151
- async function resolveWithLLM(
152
- extracted: ExtractResult,
153
- hits: SearchHit[],
154
- getObservation: (id: number) => ExistingMemoryRef | null,
155
- ): Promise<ResolveResult | null> {
156
- try {
157
- const { callLLM } = await import('../../llm/provider.js');
158
-
159
- // Build context for LLM
160
- const existingMemories = hits.slice(0, 5).map((h, i) => ({
161
- id: h.observationId,
162
- index: i,
163
- title: h.title,
164
- content: h.narrative.substring(0, 300),
165
- facts: h.facts.substring(0, 200),
166
- }));
167
-
168
- const input = `NEW MEMORY:
169
- Title: ${extracted.title}
170
- Content: ${extracted.narrative.substring(0, 500)}
171
- Facts: ${extracted.facts.join('; ')}
172
-
173
- EXISTING MEMORIES:
174
- ${existingMemories.map(m => `[ID:${m.id}] ${m.title} | ${m.content} | Facts: ${m.facts}`).join('\n')}`;
175
-
176
- const response = await callLLM(LLM_RESOLVE_PROMPT, input);
177
- const text = response.content.trim();
178
-
179
- const jsonMatch = text.match(/\{[\s\S]*\}/);
180
- if (!jsonMatch) return null;
181
- const parsed = JSON.parse(jsonMatch[0]);
182
-
183
- const action = parsed.action?.toUpperCase();
184
- const targetId = parsed.targetId ? Number(parsed.targetId) : undefined;
185
- const reason = parsed.reason || 'LLM decision';
186
-
187
- if (action === 'NOOP') {
188
- return { action: 'discard', targetId, reason: `LLM: ${reason}` };
189
- }
190
- if (action === 'ADD') {
191
- return { action: 'new', reason: `LLM: ${reason}` };
192
- }
193
- if (action === 'UPDATE' && targetId) {
194
- const existing = getObservation(targetId);
195
- const oldFacts = existing?.facts ?? [];
196
- return {
197
- action: 'merge',
198
- targetId,
199
- reason: `LLM: ${reason}`,
200
- mergedNarrative: parsed.mergedText || mergeNarratives(existing?.narrative ?? '', extracted.narrative),
201
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
202
- };
203
- }
204
- if (action === 'DELETE' && targetId) {
205
- const existing = getObservation(targetId);
206
- const oldFacts = existing?.facts ?? [];
207
- return {
208
- action: 'evolve',
209
- targetId,
210
- reason: `LLM: ${reason}`,
211
- mergedNarrative: extracted.narrative,
212
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
213
- };
214
- }
215
-
216
- return null; // Unrecognized action
217
- } catch {
218
- return null; // LLM failure → fall back to rules
219
- }
220
- }
221
-
222
- // ── Resolve Implementation ───────────────────────────────────────
223
-
224
- /**
225
- * Score a candidate match for resolution.
226
- * Returns a composite score considering similarity, entity overlap, and content richness.
227
- */
93
+
94
+ /**
95
+ * Merge two narratives, keeping the most comprehensive version.
96
+ */
97
+ function mergeNarratives(oldNarrative: string, newNarrative: string): string {
98
+ if (newNarrative.length > oldNarrative.length * 1.5) return newNarrative;
99
+ if (oldNarrative.length > newNarrative.length * 1.5) return oldNarrative;
100
+ return `${newNarrative}\n\n[Previous context]: ${oldNarrative}`;
101
+ }
102
+
103
+ /**
104
+ * Merge two fact lists, deduplicating by normalized text.
105
+ */
106
+ function mergeFacts(oldFacts: string[], newFacts: string[]): string[] {
107
+ const seen = new Set<string>();
108
+ const merged: string[] = [];
109
+
110
+ // New facts first (more recent)
111
+ for (const f of newFacts) {
112
+ const norm = f.toLowerCase().trim();
113
+ if (!seen.has(norm) && f.trim().length > 0) {
114
+ seen.add(norm);
115
+ merged.push(f);
116
+ }
117
+ }
118
+ for (const f of oldFacts) {
119
+ const norm = f.toLowerCase().trim();
120
+ if (!seen.has(norm) && f.trim().length > 0) {
121
+ seen.add(norm);
122
+ merged.push(f);
123
+ }
124
+ }
125
+
126
+ return merged;
127
+ }
128
+
129
+ // ── LLM Resolution (Mem0-style) ───────────────────────────────
130
+
131
+ const LLM_RESOLVE_PROMPT = `You are a Memory Consolidation Manager for a software engineering knowledge base.
132
+
133
+ You must decide what to do with a NEW memory given EXISTING memories that are similar.
134
+
135
+ Operations:
136
+ - ADD: The new memory contains genuinely new information not present in existing memories.
137
+ - UPDATE: The new memory adds to or refines an existing memory. Specify which existing memory ID to update.
138
+ - DELETE: The new memory contradicts an existing memory. Specify which existing memory ID to delete.
139
+ - NOOP: The new memory is redundant (already covered by existing memories). Skip storage.
140
+
141
+ Rules:
142
+ - Return ONLY a JSON object
143
+ - If UPDATE: merge the best information from both old and new
144
+ - If DELETE: the new memory supersedes the old (contradiction detected)
145
+ - Prefer UPDATE over ADD when the topic is the same but information differs
146
+ - Prefer NOOP over ADD when the information is essentially the same
147
+
148
+ Response format:
149
+ {"action": "ADD|UPDATE|DELETE|NOOP", "targetId": <number or null>, "reason": "<brief explanation>", "mergedText": "<merged content for UPDATE, or null>"}`;
150
+
151
+ async function resolveWithLLM(
152
+ extracted: ExtractResult,
153
+ hits: SearchHit[],
154
+ getObservation: (id: number) => ExistingMemoryRef | null,
155
+ ): Promise<ResolveResult | null> {
156
+ try {
157
+ const { callLLM } = await import('../../llm/provider.js');
158
+
159
+ // Build context for LLM
160
+ const existingMemories = hits.slice(0, 5).map((h, i) => ({
161
+ id: h.observationId,
162
+ index: i,
163
+ title: h.title,
164
+ content: h.narrative.substring(0, 300),
165
+ facts: h.facts.substring(0, 200),
166
+ }));
167
+
168
+ const input = `NEW MEMORY:
169
+ Title: ${extracted.title}
170
+ Content: ${extracted.narrative.substring(0, 500)}
171
+ Facts: ${extracted.facts.join('; ')}
172
+
173
+ EXISTING MEMORIES:
174
+ ${existingMemories.map(m => `[ID:${m.id}] ${m.title} | ${m.content} | Facts: ${m.facts}`).join('\n')}`;
175
+
176
+ const response = await callLLM(LLM_RESOLVE_PROMPT, input);
177
+ const text = response.content.trim();
178
+
179
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
180
+ if (!jsonMatch) return null;
181
+ const parsed = JSON.parse(jsonMatch[0]);
182
+
183
+ const action = parsed.action?.toUpperCase();
184
+ const targetId = parsed.targetId ? Number(parsed.targetId) : undefined;
185
+ const reason = parsed.reason || 'LLM decision';
186
+
187
+ if (action === 'NOOP') {
188
+ return { action: 'discard', targetId, reason: `LLM: ${reason}` };
189
+ }
190
+ if (action === 'ADD') {
191
+ return { action: 'new', reason: `LLM: ${reason}` };
192
+ }
193
+ if (action === 'UPDATE' && targetId) {
194
+ const existing = getObservation(targetId);
195
+ const oldFacts = existing?.facts ?? [];
196
+ return {
197
+ action: 'merge',
198
+ targetId,
199
+ reason: `LLM: ${reason}`,
200
+ mergedNarrative: parsed.mergedText || mergeNarratives(existing?.narrative ?? '', extracted.narrative),
201
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
202
+ };
203
+ }
204
+ if (action === 'DELETE' && targetId) {
205
+ const existing = getObservation(targetId);
206
+ const oldFacts = existing?.facts ?? [];
207
+ return {
208
+ action: 'evolve',
209
+ targetId,
210
+ reason: `LLM: ${reason}`,
211
+ mergedNarrative: extracted.narrative,
212
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
213
+ };
214
+ }
215
+
216
+ return null; // Unrecognized action
217
+ } catch {
218
+ return null; // LLM failure → fall back to rules
219
+ }
220
+ }
221
+
222
+ // ── Resolve Implementation ───────────────────────────────────────
223
+
224
+ /**
225
+ * Score a candidate match for resolution.
226
+ * Returns a composite score considering similarity, entity overlap, and content richness.
227
+ */
228
228
  function scoreCandidate(
229
229
  extracted: ExtractResult,
230
230
  candidate: SearchHit,
@@ -240,139 +240,139 @@ function scoreCandidate(
240
240
  const score = searchSimilarity * 0.6
241
241
  + (entityMatch ? 0.2 : 0)
242
242
  + contentOverlap * 0.2;
243
-
244
- // Is new memory richer?
245
- const newLength = extracted.narrative.length + extracted.facts.join(' ').length;
246
- const oldLength = candidate.narrative.length + candidate.facts.length;
247
- const richer = newLength > oldLength * 1.15;
248
-
249
- const contradiction = hasContradiction(candidate.narrative, extracted.narrative);
250
-
243
+
244
+ // Is new memory richer?
245
+ const newLength = extracted.narrative.length + extracted.facts.join(' ').length;
246
+ const oldLength = candidate.narrative.length + candidate.facts.length;
247
+ const richer = newLength > oldLength * 1.15;
248
+
249
+ const contradiction = hasContradiction(candidate.narrative, extracted.narrative);
250
+
251
251
  return { score, searchSimilarity, entityMatch, richer, contradiction };
252
252
  }
253
-
254
- /**
255
- * Run Stage 2: Resolve.
256
- *
257
- * Determines the resolution action for an enriched memory by comparing
258
- * it against existing memories found via search.
259
- */
260
- export async function runResolve(
261
- extracted: ExtractResult,
262
- projectId: string,
263
- searchMemories: (query: string, limit: number, projectId: string) => Promise<SearchHit[]>,
264
- getObservation: (id: number) => ExistingMemoryRef | null,
265
- useLLM = false,
266
- ): Promise<ResolveResult> {
267
- // Search for similar existing memories
268
- const query = `${extracted.title} ${extracted.narrative.substring(0, 200)}`;
269
- let hits: SearchHit[];
270
- try {
271
- hits = await searchMemories(query, 5, projectId);
272
- } catch {
273
- // Search failed — default to ADD
274
- return { action: 'new', reason: 'Search unavailable, defaulting to new' };
275
- }
276
-
277
- if (hits.length === 0) {
278
- return { action: 'new', reason: 'No similar existing memories found' };
279
- }
280
-
281
- // LLM-powered resolution (Mem0-style, quality-first)
282
- if (useLLM) {
283
- const llmResult = await resolveWithLLM(extracted, hits, getObservation);
284
- if (llmResult) return llmResult;
285
- // LLM failed → fall through to rules-based resolution
286
- }
287
-
288
- // Rules-based resolution (free mode fallback)
289
- const scored = hits.map(hit => ({
290
- hit,
291
- ...scoreCandidate(extracted, hit),
292
- }));
293
-
294
- // Sort by composite score descending
295
- scored.sort((a, b) => b.score - a.score);
296
- const best = scored[0];
297
-
298
- // ── Decision logic ──
299
-
253
+
254
+ /**
255
+ * Run Stage 2: Resolve.
256
+ *
257
+ * Determines the resolution action for an enriched memory by comparing
258
+ * it against existing memories found via search.
259
+ */
260
+ export async function runResolve(
261
+ extracted: ExtractResult,
262
+ projectId: string,
263
+ searchMemories: (query: string, limit: number, projectId: string) => Promise<SearchHit[]>,
264
+ getObservation: (id: number) => ExistingMemoryRef | null,
265
+ useLLM = false,
266
+ ): Promise<ResolveResult> {
267
+ // Search for similar existing memories
268
+ const query = `${extracted.title} ${extracted.narrative.substring(0, 200)}`;
269
+ let hits: SearchHit[];
270
+ try {
271
+ hits = await searchMemories(query, 5, projectId);
272
+ } catch {
273
+ // Search failed — default to ADD
274
+ return { action: 'new', reason: 'Search unavailable, defaulting to new' };
275
+ }
276
+
277
+ if (hits.length === 0) {
278
+ return { action: 'new', reason: 'No similar existing memories found' };
279
+ }
280
+
281
+ // LLM-powered resolution (Mem0-style, quality-first)
282
+ if (useLLM) {
283
+ const llmResult = await resolveWithLLM(extracted, hits, getObservation);
284
+ if (llmResult) return llmResult;
285
+ // LLM failed → fall through to rules-based resolution
286
+ }
287
+
288
+ // Rules-based resolution (free mode fallback)
289
+ const scored = hits.map(hit => ({
290
+ hit,
291
+ ...scoreCandidate(extracted, hit),
292
+ }));
293
+
294
+ // Sort by composite score descending
295
+ scored.sort((a, b) => b.score - a.score);
296
+ const best = scored[0];
297
+
298
+ // ── Decision logic ──
299
+
300
300
  // Very high normalized search similarity → likely duplicate.
301
301
  // Raw backend ranking scores must not be compared to 0..1 thresholds.
302
302
  if (best.searchSimilarity >= SIMILARITY_DUPLICATE) {
303
- if (best.richer) {
304
- // New is richer → evolve (supersede)
305
- const existing = getObservation(best.hit.observationId);
306
- const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
307
- return {
308
- action: 'evolve',
309
- targetId: best.hit.observationId,
310
- reason: `Near-duplicate of #${best.hit.observationId} but richer content (score: ${best.score.toFixed(2)})`,
311
- mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
312
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
313
- };
314
- }
315
- return {
316
- action: 'discard',
317
- targetId: best.hit.observationId,
318
- reason: `Duplicate of #${best.hit.observationId} (score: ${best.score.toFixed(2)})`,
319
- };
320
- }
321
-
322
- // High similarity → same topic
323
- if (best.score >= SIMILARITY_HIGH) {
324
- if (best.contradiction) {
325
- // Content contradicts existing → evolve
326
- const existing = getObservation(best.hit.observationId);
327
- const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
328
- return {
329
- action: 'evolve',
330
- targetId: best.hit.observationId,
331
- reason: `Supersedes #${best.hit.observationId}: contradiction detected (score: ${best.score.toFixed(2)})`,
332
- mergedNarrative: extracted.narrative,
333
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
334
- };
335
- }
336
-
337
- if (best.richer) {
338
- // New is richer → merge
339
- const existing = getObservation(best.hit.observationId);
340
- const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
341
- return {
342
- action: 'merge',
343
- targetId: best.hit.observationId,
344
- reason: `Merging with #${best.hit.observationId}: same topic, new content is richer (score: ${best.score.toFixed(2)})`,
345
- mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
346
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
347
- };
348
- }
349
-
350
- // Not richer → discard
351
- return {
352
- action: 'discard',
353
- targetId: best.hit.observationId,
354
- reason: `Already covered by #${best.hit.observationId} (score: ${best.score.toFixed(2)})`,
355
- };
356
- }
357
-
358
- // Medium similarity + entity match → merge
359
- if (best.score >= SIMILARITY_MEDIUM && best.entityMatch) {
360
- const existing = getObservation(best.hit.observationId);
361
- const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
362
- const newFactCount = extracted.facts.length;
363
- const oldFactCount = oldFacts.length;
364
-
365
- if (newFactCount > oldFactCount) {
366
- return {
367
- action: 'merge',
368
- targetId: best.hit.observationId,
369
- reason: `Same entity "${extracted.entityName}", new memory has more facts (${newFactCount} > ${oldFactCount})`,
370
- mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
371
- mergedFacts: mergeFacts(oldFacts, extracted.facts),
372
- };
373
- }
374
- }
375
-
376
- // Low similarity or different entity → new memory
377
- return { action: 'new', reason: `Different from existing memories (best score: ${best.score.toFixed(2)})` };
378
- }
303
+ if (best.richer) {
304
+ // New is richer → evolve (supersede)
305
+ const existing = getObservation(best.hit.observationId);
306
+ const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
307
+ return {
308
+ action: 'evolve',
309
+ targetId: best.hit.observationId,
310
+ reason: `Near-duplicate of #${best.hit.observationId} but richer content (score: ${best.score.toFixed(2)})`,
311
+ mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
312
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
313
+ };
314
+ }
315
+ return {
316
+ action: 'discard',
317
+ targetId: best.hit.observationId,
318
+ reason: `Duplicate of #${best.hit.observationId} (score: ${best.score.toFixed(2)})`,
319
+ };
320
+ }
321
+
322
+ // High similarity → same topic
323
+ if (best.score >= SIMILARITY_HIGH) {
324
+ if (best.contradiction) {
325
+ // Content contradicts existing → evolve
326
+ const existing = getObservation(best.hit.observationId);
327
+ const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
328
+ return {
329
+ action: 'evolve',
330
+ targetId: best.hit.observationId,
331
+ reason: `Supersedes #${best.hit.observationId}: contradiction detected (score: ${best.score.toFixed(2)})`,
332
+ mergedNarrative: extracted.narrative,
333
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
334
+ };
335
+ }
336
+
337
+ if (best.richer) {
338
+ // New is richer → merge
339
+ const existing = getObservation(best.hit.observationId);
340
+ const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
341
+ return {
342
+ action: 'merge',
343
+ targetId: best.hit.observationId,
344
+ reason: `Merging with #${best.hit.observationId}: same topic, new content is richer (score: ${best.score.toFixed(2)})`,
345
+ mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
346
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
347
+ };
348
+ }
349
+
350
+ // Not richer → discard
351
+ return {
352
+ action: 'discard',
353
+ targetId: best.hit.observationId,
354
+ reason: `Already covered by #${best.hit.observationId} (score: ${best.score.toFixed(2)})`,
355
+ };
356
+ }
357
+
358
+ // Medium similarity + entity match → merge
359
+ if (best.score >= SIMILARITY_MEDIUM && best.entityMatch) {
360
+ const existing = getObservation(best.hit.observationId);
361
+ const oldFacts = existing?.facts ?? best.hit.facts.split('\n').filter(Boolean);
362
+ const newFactCount = extracted.facts.length;
363
+ const oldFactCount = oldFacts.length;
364
+
365
+ if (newFactCount > oldFactCount) {
366
+ return {
367
+ action: 'merge',
368
+ targetId: best.hit.observationId,
369
+ reason: `Same entity "${extracted.entityName}", new memory has more facts (${newFactCount} > ${oldFactCount})`,
370
+ mergedNarrative: mergeNarratives(best.hit.narrative, extracted.narrative),
371
+ mergedFacts: mergeFacts(oldFacts, extracted.facts),
372
+ };
373
+ }
374
+ }
375
+
376
+ // Low similarity or different entity → new memory
377
+ return { action: 'new', reason: `Different from existing memories (best score: ${best.score.toFixed(2)})` };
378
+ }