memorix 1.1.7 → 1.1.8

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 (174) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/CLAUDE.md +6 -1
  3. package/README.md +21 -0
  4. package/README.zh-CN.md +21 -0
  5. package/TEAM.md +86 -86
  6. package/dist/cli/index.js +730 -150
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/dashboard/static/index.html +201 -201
  9. package/dist/dashboard/static/style.css +3584 -3584
  10. package/dist/index.js +15 -8
  11. package/dist/index.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +13 -0
  13. package/dist/memcode-runtime/package.json +4 -4
  14. package/dist/sdk.js +15 -8
  15. package/dist/sdk.js.map +1 -1
  16. package/docs/AGENT_OPERATOR_PLAYBOOK.md +18 -0
  17. package/docs/DESIGN_DECISIONS.md +357 -357
  18. package/docs/SETUP.md +10 -0
  19. package/docs/dev-log/progress.txt +35 -26
  20. package/package.json +1 -1
  21. package/src/audit/index.ts +156 -156
  22. package/src/cli/commands/agent-integrations.ts +623 -0
  23. package/src/cli/commands/audit-list.ts +89 -89
  24. package/src/cli/commands/background.ts +659 -659
  25. package/src/cli/commands/cleanup.ts +255 -255
  26. package/src/cli/commands/doctor.ts +23 -0
  27. package/src/cli/commands/formation.ts +48 -48
  28. package/src/cli/commands/git-hook-install.ts +111 -111
  29. package/src/cli/commands/handoff.ts +66 -66
  30. package/src/cli/commands/hooks-status.ts +63 -63
  31. package/src/cli/commands/ingest-commit.ts +153 -153
  32. package/src/cli/commands/ingest-image.ts +73 -73
  33. package/src/cli/commands/ingest-log.ts +180 -180
  34. package/src/cli/commands/ingest.ts +44 -44
  35. package/src/cli/commands/integrate-shared.ts +15 -15
  36. package/src/cli/commands/lock.ts +96 -96
  37. package/src/cli/commands/message.ts +121 -121
  38. package/src/cli/commands/poll.ts +70 -70
  39. package/src/cli/commands/purge-all-memory.ts +85 -85
  40. package/src/cli/commands/purge-project-memory.ts +83 -83
  41. package/src/cli/commands/reasoning.ts +132 -132
  42. package/src/cli/commands/repair.ts +60 -0
  43. package/src/cli/commands/retention.ts +108 -108
  44. package/src/cli/commands/serve-shared.ts +118 -118
  45. package/src/cli/commands/setup.ts +3 -3
  46. package/src/cli/commands/skills.ts +123 -123
  47. package/src/cli/commands/task.ts +192 -192
  48. package/src/cli/commands/transfer.ts +73 -73
  49. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  50. package/src/cli/index.ts +3 -1
  51. package/src/cli/tui/ChatView.tsx +234 -234
  52. package/src/cli/tui/CommandBar.tsx +312 -312
  53. package/src/cli/tui/ContextRail.tsx +118 -118
  54. package/src/cli/tui/HeaderBar.tsx +72 -72
  55. package/src/cli/tui/LogoBanner.tsx +51 -51
  56. package/src/cli/tui/Panels.tsx +632 -632
  57. package/src/cli/tui/Sidebar.tsx +179 -179
  58. package/src/cli/tui/chat-service.ts +742 -742
  59. package/src/cli/tui/data.ts +547 -547
  60. package/src/cli/tui/index.ts +41 -41
  61. package/src/cli/tui/markdown-render.tsx +371 -371
  62. package/src/cli/tui/theme.ts +178 -178
  63. package/src/cli/tui/use-mouse.ts +157 -157
  64. package/src/cli/tui/useNavigation.ts +56 -56
  65. package/src/cli/update-checker.ts +211 -211
  66. package/src/cli/version.ts +7 -7
  67. package/src/cli/workbench.ts +1 -1
  68. package/src/codegraph/context-pack.ts +5 -1
  69. package/src/codegraph/lite-provider.ts +5 -1
  70. package/src/codegraph/project-context.ts +5 -1
  71. package/src/compact/token-budget.ts +74 -74
  72. package/src/config/behavior.ts +59 -59
  73. package/src/dashboard/project-classification.ts +64 -64
  74. package/src/dashboard/static/index.html +201 -201
  75. package/src/dashboard/static/style.css +3584 -3584
  76. package/src/embedding/fastembed-provider.ts +142 -142
  77. package/src/embedding/transformers-provider.ts +111 -111
  78. package/src/git/extractor.ts +209 -209
  79. package/src/git/hooks-path.ts +85 -85
  80. package/src/git/noise-filter.ts +210 -210
  81. package/src/hooks/installers/index.ts +4 -4
  82. package/src/hooks/official-skills.ts +1 -1
  83. package/src/hooks/pattern-detector.ts +173 -173
  84. package/src/hooks/rules/memorix-agent-rules.md +2 -2
  85. package/src/hooks/significance-filter.ts +250 -250
  86. package/src/llm/memory-manager.ts +328 -328
  87. package/src/llm/provider.ts +885 -885
  88. package/src/llm/quality.ts +248 -248
  89. package/src/memory/attribution-guard.ts +249 -249
  90. package/src/memory/auto-relations.ts +107 -107
  91. package/src/memory/consolidation.ts +302 -302
  92. package/src/memory/disclosure-policy.ts +141 -141
  93. package/src/memory/entity-extractor.ts +197 -197
  94. package/src/memory/formation/evaluate.ts +217 -217
  95. package/src/memory/formation/extract.ts +361 -361
  96. package/src/memory/formation/index.ts +417 -417
  97. package/src/memory/formation/resolve.ts +344 -344
  98. package/src/memory/formation/types.ts +315 -315
  99. package/src/memory/freshness.ts +122 -122
  100. package/src/memory/graph.ts +197 -197
  101. package/src/memory/refs.ts +94 -94
  102. package/src/memory/retention.ts +433 -433
  103. package/src/memory/secret-filter.ts +79 -79
  104. package/src/memory/session.ts +523 -523
  105. package/src/multimodal/image-loader.ts +143 -143
  106. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  107. package/src/orchestrate/adapters/claude.ts +111 -111
  108. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  109. package/src/orchestrate/adapters/codex.ts +41 -41
  110. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  111. package/src/orchestrate/adapters/gemini.ts +42 -42
  112. package/src/orchestrate/adapters/index.ts +73 -73
  113. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  114. package/src/orchestrate/adapters/opencode.ts +47 -47
  115. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  116. package/src/orchestrate/adapters/types.ts +77 -77
  117. package/src/orchestrate/capability-router.ts +284 -284
  118. package/src/orchestrate/context-compact.ts +188 -188
  119. package/src/orchestrate/cost-tracker.ts +219 -219
  120. package/src/orchestrate/error-recovery.ts +191 -191
  121. package/src/orchestrate/evidence.ts +140 -140
  122. package/src/orchestrate/ledger.ts +110 -110
  123. package/src/orchestrate/memorix-bridge.ts +380 -380
  124. package/src/orchestrate/output-budget.ts +80 -80
  125. package/src/orchestrate/permission.ts +152 -152
  126. package/src/orchestrate/pipeline-trace.ts +131 -131
  127. package/src/orchestrate/prompt-builder.ts +155 -155
  128. package/src/orchestrate/ring-buffer.ts +37 -37
  129. package/src/orchestrate/task-graph.ts +389 -389
  130. package/src/orchestrate/verify-gate.ts +219 -219
  131. package/src/orchestrate/worktree.ts +232 -232
  132. package/src/project/aliases.ts +374 -374
  133. package/src/project/detector.ts +268 -268
  134. package/src/rules/adapters/claude-code.ts +99 -99
  135. package/src/rules/adapters/codex.ts +97 -97
  136. package/src/rules/adapters/copilot.ts +124 -124
  137. package/src/rules/adapters/cursor.ts +114 -114
  138. package/src/rules/adapters/kiro.ts +126 -126
  139. package/src/rules/adapters/trae.ts +56 -56
  140. package/src/rules/adapters/windsurf.ts +83 -83
  141. package/src/rules/syncer.ts +235 -235
  142. package/src/sdk.ts +327 -327
  143. package/src/search/intent-detector.ts +289 -289
  144. package/src/search/query-expansion.ts +52 -52
  145. package/src/server/formation-timeout.ts +27 -27
  146. package/src/skills/mini-skills.ts +386 -386
  147. package/src/store/chat-store.ts +119 -119
  148. package/src/store/file-lock.ts +100 -100
  149. package/src/store/graph-store.ts +249 -249
  150. package/src/store/mini-skill-store.ts +349 -349
  151. package/src/store/obs-store.ts +255 -255
  152. package/src/store/persistence-json.ts +212 -212
  153. package/src/store/persistence.ts +291 -291
  154. package/src/store/project-affinity.ts +195 -195
  155. package/src/store/session-store.ts +259 -259
  156. package/src/store/sqlite-store.ts +339 -339
  157. package/src/team/event-bus.ts +76 -76
  158. package/src/team/file-locks.ts +173 -173
  159. package/src/team/handoff.ts +167 -167
  160. package/src/team/messages.ts +203 -203
  161. package/src/team/poll.ts +132 -132
  162. package/src/team/tasks.ts +211 -211
  163. package/src/wiki/generator.ts +237 -237
  164. package/src/wiki/knowledge-graph.ts +334 -334
  165. package/src/wiki/types.ts +85 -85
  166. package/src/workspace/mcp-adapters/codex.ts +191 -191
  167. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  168. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  169. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  170. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  171. package/src/workspace/mcp-adapters/trae.ts +134 -134
  172. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  173. package/src/workspace/sanitizer.ts +60 -60
  174. package/src/workspace/workflow-sync.ts +131 -131
@@ -1,746 +1,746 @@
1
- import { compactDetail, compactSearch } from '../../compact/engine.js';
2
- import { loadDotenv } from '../../config/dotenv-loader.js';
3
- import { initLLM, isLLMEnabled, getLLMConfig, callLLMWithTools } from '../../llm/provider.js';
4
- import type { ChatMessage, ToolDefinition, ToolCall } from '../../llm/provider.js';
5
- import { initObservations, storeObservation, resolveObservations, getObservation, getAllObservations } from '../../memory/observations.js';
6
- import type { ObservationType } from '../../types.js';
7
- import { detectProject } from '../../project/detector.js';
8
- import { initObservationStore } from '../../store/obs-store.js';
9
- import { getDb, getLastSearchMode, hydrateIndex } from '../../store/orama-store.js';
10
- import { getProjectDataDir } from '../../store/persistence.js';
11
- import type { MemorixDocument } from '../../types.js';
12
-
13
- export interface ChatHistoryTurn {
14
- role: 'user' | 'assistant';
15
- content: string;
16
- }
17
-
18
- export interface ChatSource {
19
- id: number;
20
- title: string;
21
- type: string;
22
- entityName: string;
23
- excerpt: string;
24
- score: number;
25
- createdAt?: string;
26
- }
27
-
28
- export interface ChatAnswer {
29
- question: string;
30
- answer: string;
31
- sources: ChatSource[];
32
- usedLLM: boolean;
33
- llmModel?: string;
34
- searchMode: string;
35
- warning?: string;
36
- toolCallsCount?: number;
37
- }
38
-
39
- const SEARCH_LIMIT = 6;
40
- const DETAIL_LIMIT = 4;
41
- const HISTORY_LIMIT = 8;
42
- const MAX_TOOL_ROUNDS = 5;
43
-
44
- // ── Tool definitions (harness pattern) ────────────────────────────
45
-
46
- const MEMORY_TOOLS: ToolDefinition[] = [
47
- {
48
- name: 'search_memories',
49
- description: 'Search the project memory knowledge base for relevant decisions, bugs, architecture notes, gotchas, or other engineering context. Use this when the user asks about project history, design rationale, known issues, or technical details.',
50
- parameters: {
51
- type: 'object',
52
- properties: {
53
- query: { type: 'string', description: 'Search query — keywords or natural language describing what to find' },
54
- limit: { type: 'number', description: 'Maximum results to return (default 6, max 10)' },
55
- },
56
- required: ['query'],
57
- },
58
- },
59
- {
60
- name: 'get_memory_detail',
61
- description: 'Retrieve the full narrative and facts of a specific memory observation by its ID. Use this after search_memories when you need more context about a specific result.',
62
- parameters: {
63
- type: 'object',
64
- properties: {
65
- id: { type: 'number', description: 'The observation ID (e.g. the number from obs:42)' },
66
- },
67
- required: ['id'],
68
- },
69
- },
70
- {
71
- name: 'store_memory',
72
- description: 'Store a new memory observation to the project knowledge base. Use this when the user wants to save a decision, gotcha, bug fix, architecture note, or any engineering context worth remembering.',
73
- parameters: {
74
- type: 'object',
75
- properties: {
76
- entityName: { type: 'string', description: 'The entity this observation belongs to (e.g. "auth-module", "database-schema")' },
77
- type: { type: 'string', description: 'Observation type: gotcha, decision, problem-solution, how-it-works, what-changed, discovery, why-it-exists, trade-off, reasoning, probe', enum: ['gotcha', 'decision', 'problem-solution', 'how-it-works', 'what-changed', 'discovery', 'why-it-exists', 'trade-off', 'reasoning', 'probe'] },
78
- title: { type: 'string', description: 'Short descriptive title (5-10 words)' },
79
- narrative: { type: 'string', description: 'Full description of the observation' },
80
- facts: { type: 'array', items: { type: 'string' }, description: 'Key facts as structured strings (e.g. "Default timeout: 60s")' },
81
- },
82
- required: ['entityName', 'type', 'title', 'narrative'],
83
- },
84
- },
85
- {
86
- name: 'update_memory',
87
- description: 'Update an existing memory observation by its ID. Use this when the user wants to modify or add to an existing memory. Provide the ID and the fields to update.',
88
- parameters: {
89
- type: 'object',
90
- properties: {
91
- id: { type: 'number', description: 'The observation ID to update' },
92
- narrative: { type: 'string', description: 'New or appended narrative text' },
93
- facts: { type: 'array', items: { type: 'string' }, description: 'New facts to add' },
94
- append: { type: 'boolean', description: 'If true, append to existing narrative/facts instead of replacing (default: true)' },
95
- },
96
- required: ['id', 'narrative'],
97
- },
98
- },
99
- {
100
- name: 'delete_memory',
101
- description: 'Delete/archive a memory observation by marking it as resolved. Use this when the user wants to remove outdated or incorrect memories.',
102
- parameters: {
103
- type: 'object',
104
- properties: {
105
- id: { type: 'number', description: 'The observation ID to delete/archive' },
106
- reason: { type: 'string', description: 'Reason for deletion (stored for audit trail)' },
107
- },
108
- required: ['id'],
109
- },
110
- },
111
- {
112
- name: 'list_recent_memories',
113
- description: 'List recent memory observations for the project. Use this when the user wants to see what has been stored recently or browse the knowledge base.',
114
- parameters: {
115
- type: 'object',
116
- properties: {
117
- limit: { type: 'number', description: 'Maximum results to return (default 10, max 20)' },
118
- type: { type: 'string', description: 'Filter by observation type (optional)' },
119
- },
120
- },
121
- },
122
- ];
123
-
124
- const SYSTEM_PROMPT = [
125
- 'You are Memorix, a memory-grounded AI assistant for a software project.',
126
- 'You have access to the project\'s memory knowledge base via tools.',
127
- '',
128
- 'Behavior guidelines:',
129
- '- For casual conversation (greetings, small talk, meta questions), respond naturally WITHOUT calling any tools.',
130
- '- When the user asks about project decisions, architecture, bugs, rationale, recent changes, or technical details, use search_memories to find relevant context.',
131
- '- If initial search results are sparse, try a different query or use get_memory_detail on promising results.',
132
- '- When the user wants to SAVE information (decisions, gotchas, fixes, notes), use store_memory to persist it.',
133
- '- When the user wants to UPDATE existing memories, use update_memory with the observation ID.',
134
- '- When the user wants to DELETE outdated memories, use delete_memory to archive them.',
135
- '- When the user wants to BROWSE recent memories, use list_recent_memories.',
136
- '- Always cite supporting memories inline as [obs:<id>].',
137
- '- Do not invent memories, IDs, files, or decisions that are not in the retrieved context.',
138
- '- If the retrieved context is insufficient, say so explicitly and suggest what to store.',
139
- '- Prefer concise, practical answers for engineers revisiting project history.',
140
- ].join('\n');
141
-
142
- // ── Helpers ─────────────────────────────────────────────────────────
143
-
144
- function truncate(text: string, max: number): string {
145
- if (text.length <= max) return text;
146
- return `${text.slice(0, max - 1)}…`;
147
- }
148
-
149
- function buildExcerpt(doc: MemorixDocument): string {
150
- const combined = [doc.narrative, doc.facts]
151
- .filter(Boolean)
152
- .join(' ')
153
- .replace(/\s+/g, ' ')
154
- .trim();
155
-
156
- if (!combined) return 'No narrative available.';
157
- return truncate(combined, 180);
158
- }
159
-
160
- function toSource(doc: MemorixDocument, score: number): ChatSource {
161
- return {
162
- id: doc.observationId,
163
- title: doc.title || '(untitled)',
164
- type: doc.type || 'discovery',
165
- entityName: doc.entityName || '',
166
- excerpt: buildExcerpt(doc),
167
- score,
168
- createdAt: doc.createdAt,
169
- };
170
- }
171
-
172
- function buildFallbackAnswer(question: string, sources: ChatSource[], warning?: string): string {
173
- if (sources.length === 0) {
174
- return [
175
- warning || 'I could not find relevant project memories for that question.',
176
- `Question: ${question}`,
177
- 'Try /search with narrower terms, or store the missing context first.',
178
- ].join('\n');
179
- }
180
-
181
- const lines = [
182
- warning || 'LLM chat is not configured, so here are the most relevant project memories I found:',
183
- '',
184
- ...sources.map((source) => `- [obs:${source.id}] ${source.title} — ${source.excerpt}`),
185
- ];
186
-
187
- return lines.join('\n');
188
- }
189
-
190
- function normalizeSearchMode(raw: string): string {
191
- if (raw.includes('vector')) return 'vector';
192
- if (raw.includes('rerank')) return 'rerank';
193
- if (raw.includes('hybrid')) return 'hybrid';
194
- return 'fulltext';
195
- }
196
-
197
- /** Track which projectIds have already been prepared to avoid redundant loadAll(). */
198
- const preparedProjects = new Set<string>();
199
-
200
- async function prepareProjectSearch(projectId: string, dataDir: string): Promise<void> {
201
- if (preparedProjects.has(projectId)) return;
202
-
203
- await initObservationStore(dataDir);
204
- await initObservations(dataDir); // loads observations into memory once
205
- await getDb();
206
- // Use the already-loaded in-memory observations (from initObservations)
207
- // instead of calling loadAll() a second time.
208
- const allObs = getAllObservations() as any[];
209
- await hydrateIndex(allObs); // idempotent: skips if index already populated
210
- preparedProjects.add(projectId);
211
- }
212
-
213
- // ── Tool execution ──────────────────────────────────────────────────
214
-
215
- interface ToolExecutionContext {
216
- projectId: string;
217
- collectedSources: ChatSource[];
218
- }
219
-
220
- function executeSearchMemories(args: { query: string; limit?: number }, ctx: ToolExecutionContext): Promise<string> {
221
- const limit = Math.min(args.limit ?? SEARCH_LIMIT, 10);
222
- return compactSearch({ query: args.query, limit, projectId: ctx.projectId, status: 'active' })
223
- .then((result) => {
224
- const entries = result.entries.slice(0, DETAIL_LIMIT);
225
- if (entries.length === 0) return 'No memories found for that query.';
226
-
227
- // Collect sources for citation tracking
228
- const refs = entries.map((e) => ({ id: e.id, projectId: ctx.projectId }));
229
- return compactDetail(refs).then((detail) => {
230
- for (let i = 0; i < detail.documents.length; i++) {
231
- const doc = detail.documents[i];
232
- const score = entries[i]?.score ?? 0;
233
- ctx.collectedSources.push(toSource(doc, score));
234
- }
235
- return detail.documents.map((doc) =>
236
- `[obs:${doc.observationId}] ${doc.title}\n Type: ${doc.type} | Entity: ${doc.entityName}\n ${truncate(doc.narrative || doc.facts || 'No details', 200)}`,
237
- ).join('\n\n');
238
- });
239
- })
240
- .catch((err) => `Search error: ${err instanceof Error ? err.message : String(err)}`);
241
- }
242
-
243
- function executeGetMemoryDetail(args: { id: number }, ctx: ToolExecutionContext): Promise<string> {
244
- return compactDetail([{ id: args.id, projectId: ctx.projectId }])
245
- .then((detail) => {
246
- if (detail.documents.length === 0) return `Observation ${args.id} not found.`;
247
- const doc = detail.documents[0];
248
- ctx.collectedSources.push(toSource(doc, 1.0));
249
- return [
250
- `[obs:${doc.observationId}] ${doc.title}`,
251
- `Type: ${doc.type}`,
252
- `Entity: ${doc.entityName}`,
253
- doc.narrative ? `Narrative: ${doc.narrative}` : '',
254
- doc.facts ? `Facts: ${doc.facts}` : '',
255
- doc.filesModified ? `Files: ${doc.filesModified}` : '',
256
- doc.concepts ? `Concepts: ${doc.concepts}` : '',
257
- doc.createdAt ? `Created: ${doc.createdAt}` : '',
258
- ].filter(Boolean).join('\n');
259
- })
260
- .catch((err) => `Detail error: ${err instanceof Error ? err.message : String(err)}`);
261
- }
262
-
263
- async function executeToolCall(tc: ToolCall, ctx: ToolExecutionContext): Promise<string> {
264
- let args: Record<string, unknown>;
265
- try {
266
- args = JSON.parse(tc.arguments);
267
- } catch {
268
- return `Error: invalid JSON arguments for tool ${tc.name}`;
269
- }
270
-
271
- switch (tc.name) {
272
- case 'search_memories':
273
- return executeSearchMemories(args as { query: string; limit?: number }, ctx);
274
- case 'get_memory_detail':
275
- return executeGetMemoryDetail(args as { id: number }, ctx);
276
- case 'store_memory':
277
- return executeStoreMemory(args as { entityName: string; type: string; title: string; narrative: string; facts?: string[] }, ctx);
278
- case 'update_memory':
279
- return executeUpdateMemory(args as { id: number; narrative: string; facts?: string[]; append?: boolean }, ctx);
280
- case 'delete_memory':
281
- return executeDeleteMemory(args as { id: number; reason?: string }, ctx);
282
- case 'list_recent_memories':
283
- return executeListRecentMemories(args as { limit?: number; type?: string }, ctx);
284
- default:
285
- return `Unknown tool: ${tc.name}`;
286
- }
287
- }
288
-
289
- function executeStoreMemory(
290
- args: { entityName: string; type: string; title: string; narrative: string; facts?: string[] },
291
- ctx: ToolExecutionContext,
292
- ): Promise<string> {
293
- const validTypes: ObservationType[] = ['gotcha', 'decision', 'problem-solution', 'how-it-works', 'what-changed', 'discovery', 'why-it-exists', 'trade-off', 'reasoning', 'session-request'];
294
- const type = validTypes.includes(args.type as ObservationType) ? args.type as ObservationType : 'discovery';
295
-
296
- return storeObservation({
297
- entityName: args.entityName,
298
- type,
299
- title: args.title,
300
- narrative: args.narrative,
301
- facts: args.facts,
302
- projectId: ctx.projectId,
303
- source: 'agent',
304
- }).then((result) => {
305
- const obs = result.observation;
306
- ctx.collectedSources.push({
307
- id: obs.id,
308
- title: obs.title,
309
- type: obs.type,
310
- entityName: obs.entityName,
311
- excerpt: truncate(obs.narrative, 180),
312
- score: 1.0,
313
- });
314
- return `Stored observation [obs:${obs.id}] "${obs.title}" (${type}, entity: ${args.entityName})${result.upserted ? ' [updated existing via topicKey]' : ''}`;
315
- }).catch((err) => `Error storing memory: ${err instanceof Error ? err.message : String(err)}`);
316
- }
317
-
318
- function executeUpdateMemory(
319
- args: { id: number; narrative: string; facts?: string[]; append?: boolean },
320
- ctx: ToolExecutionContext,
321
- ): string {
322
- const obs = getObservation(args.id, ctx.projectId);
323
- if (!obs) return `Observation ${args.id} not found.`;
324
-
325
- const shouldAppend = args.append !== false; // default true
326
- const newNarrative = shouldAppend
327
- ? `${obs.narrative}\n\n${args.narrative}`
328
- : args.narrative;
329
- const newFacts = shouldAppend && obs.facts
330
- ? [...obs.facts, ...(args.facts ?? [])]
331
- : args.facts ?? obs.facts;
332
-
333
- // Use storeObservation with topicKey for upsert
334
- storeObservation({
335
- entityName: obs.entityName,
336
- type: obs.type,
337
- title: obs.title,
338
- narrative: newNarrative,
339
- facts: newFacts,
340
- projectId: ctx.projectId,
341
- topicKey: obs.topicKey,
342
- source: 'agent',
343
- }).catch(() => {/* non-blocking */});
344
-
345
- return `Updated observation [obs:${args.id}] — ${shouldAppend ? 'appended to' : 'replaced'} narrative${args.facts?.length ? ` and added ${args.facts.length} facts` : ''}. Changes will be persisted asynchronously.`;
346
- }
347
-
348
- function executeDeleteMemory(
349
- args: { id: number; reason?: string },
350
- ctx: ToolExecutionContext,
351
- ): Promise<string> {
352
- const obs = getObservation(args.id, ctx.projectId);
353
- if (!obs) return Promise.resolve(`Observation ${args.id} not found.`);
354
-
355
- return resolveObservations([args.id], 'resolved')
356
- .then((result) => {
357
- if (result.resolved.includes(args.id)) {
358
- return `Archived observation [obs:${args.id}] "${obs.title}"${args.reason ? ` — reason: ${args.reason}` : ''}`;
359
- }
360
- return `Could not archive observation ${args.id} (not found or already resolved)`;
361
- })
362
- .catch((err) => `Error deleting memory: ${err instanceof Error ? err.message : String(err)}`);
363
- }
364
-
365
- function executeListRecentMemories(
366
- args: { limit?: number; type?: string },
367
- ctx: ToolExecutionContext,
368
- ): string {
369
- const limit = Math.min(args.limit ?? 10, 20);
370
- let allObs = getAllObservations()
371
- .filter(o => o.projectId === ctx.projectId && o.status !== 'archived' && o.status !== 'resolved');
372
-
373
- if (args.type) {
374
- allObs = allObs.filter(o => o.type === args.type);
375
- }
376
-
377
- // Sort by creation date, most recent first
378
- allObs.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
379
- const recent = allObs.slice(0, limit);
380
-
381
- if (recent.length === 0) {
382
- return args.type ? `No ${args.type} memories found.` : 'No memories found for this project.';
383
- }
384
-
385
- return recent.map(o =>
386
- `[obs:${o.id}] ${o.title}\n Type: ${o.type} | Entity: ${o.entityName} | ${o.createdAt?.slice(0, 10) || '?'}`
387
- ).join('\n\n');
388
- }
389
-
390
- // ── Agentic harness loop ────────────────────────────────────────────
391
-
392
- export async function askMemoryQuestion(
393
- question: string,
394
- history: ChatHistoryTurn[] = [],
395
- ): Promise<ChatAnswer> {
396
- const trimmedQuestion = question.trim();
397
- if (!trimmedQuestion) {
398
- return {
399
- question: '',
400
- answer: 'Ask a question about your project memories.',
401
- sources: [],
402
- usedLLM: false,
403
- searchMode: 'fulltext',
404
- warning: 'Empty question.',
405
- };
406
- }
407
-
408
- const project = detectProject(process.cwd());
409
-
410
- // No project + no LLM → dead end
411
- if (!project) {
412
- loadDotenv(process.cwd());
1
+ import { compactDetail, compactSearch } from '../../compact/engine.js';
2
+ import { loadDotenv } from '../../config/dotenv-loader.js';
3
+ import { initLLM, isLLMEnabled, getLLMConfig, callLLMWithTools } from '../../llm/provider.js';
4
+ import type { ChatMessage, ToolDefinition, ToolCall } from '../../llm/provider.js';
5
+ import { initObservations, storeObservation, resolveObservations, getObservation, getAllObservations } from '../../memory/observations.js';
6
+ import type { ObservationType } from '../../types.js';
7
+ import { detectProject } from '../../project/detector.js';
8
+ import { initObservationStore } from '../../store/obs-store.js';
9
+ import { getDb, getLastSearchMode, hydrateIndex } from '../../store/orama-store.js';
10
+ import { getProjectDataDir } from '../../store/persistence.js';
11
+ import type { MemorixDocument } from '../../types.js';
12
+
13
+ export interface ChatHistoryTurn {
14
+ role: 'user' | 'assistant';
15
+ content: string;
16
+ }
17
+
18
+ export interface ChatSource {
19
+ id: number;
20
+ title: string;
21
+ type: string;
22
+ entityName: string;
23
+ excerpt: string;
24
+ score: number;
25
+ createdAt?: string;
26
+ }
27
+
28
+ export interface ChatAnswer {
29
+ question: string;
30
+ answer: string;
31
+ sources: ChatSource[];
32
+ usedLLM: boolean;
33
+ llmModel?: string;
34
+ searchMode: string;
35
+ warning?: string;
36
+ toolCallsCount?: number;
37
+ }
38
+
39
+ const SEARCH_LIMIT = 6;
40
+ const DETAIL_LIMIT = 4;
41
+ const HISTORY_LIMIT = 8;
42
+ const MAX_TOOL_ROUNDS = 5;
43
+
44
+ // ── Tool definitions (harness pattern) ────────────────────────────
45
+
46
+ const MEMORY_TOOLS: ToolDefinition[] = [
47
+ {
48
+ name: 'search_memories',
49
+ description: 'Search the project memory knowledge base for relevant decisions, bugs, architecture notes, gotchas, or other engineering context. Use this when the user asks about project history, design rationale, known issues, or technical details.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {
53
+ query: { type: 'string', description: 'Search query — keywords or natural language describing what to find' },
54
+ limit: { type: 'number', description: 'Maximum results to return (default 6, max 10)' },
55
+ },
56
+ required: ['query'],
57
+ },
58
+ },
59
+ {
60
+ name: 'get_memory_detail',
61
+ description: 'Retrieve the full narrative and facts of a specific memory observation by its ID. Use this after search_memories when you need more context about a specific result.',
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ id: { type: 'number', description: 'The observation ID (e.g. the number from obs:42)' },
66
+ },
67
+ required: ['id'],
68
+ },
69
+ },
70
+ {
71
+ name: 'store_memory',
72
+ description: 'Store a new memory observation to the project knowledge base. Use this when the user wants to save a decision, gotcha, bug fix, architecture note, or any engineering context worth remembering.',
73
+ parameters: {
74
+ type: 'object',
75
+ properties: {
76
+ entityName: { type: 'string', description: 'The entity this observation belongs to (e.g. "auth-module", "database-schema")' },
77
+ type: { type: 'string', description: 'Observation type: gotcha, decision, problem-solution, how-it-works, what-changed, discovery, why-it-exists, trade-off, reasoning, probe', enum: ['gotcha', 'decision', 'problem-solution', 'how-it-works', 'what-changed', 'discovery', 'why-it-exists', 'trade-off', 'reasoning', 'probe'] },
78
+ title: { type: 'string', description: 'Short descriptive title (5-10 words)' },
79
+ narrative: { type: 'string', description: 'Full description of the observation' },
80
+ facts: { type: 'array', items: { type: 'string' }, description: 'Key facts as structured strings (e.g. "Default timeout: 60s")' },
81
+ },
82
+ required: ['entityName', 'type', 'title', 'narrative'],
83
+ },
84
+ },
85
+ {
86
+ name: 'update_memory',
87
+ description: 'Update an existing memory observation by its ID. Use this when the user wants to modify or add to an existing memory. Provide the ID and the fields to update.',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: {
91
+ id: { type: 'number', description: 'The observation ID to update' },
92
+ narrative: { type: 'string', description: 'New or appended narrative text' },
93
+ facts: { type: 'array', items: { type: 'string' }, description: 'New facts to add' },
94
+ append: { type: 'boolean', description: 'If true, append to existing narrative/facts instead of replacing (default: true)' },
95
+ },
96
+ required: ['id', 'narrative'],
97
+ },
98
+ },
99
+ {
100
+ name: 'delete_memory',
101
+ description: 'Delete/archive a memory observation by marking it as resolved. Use this when the user wants to remove outdated or incorrect memories.',
102
+ parameters: {
103
+ type: 'object',
104
+ properties: {
105
+ id: { type: 'number', description: 'The observation ID to delete/archive' },
106
+ reason: { type: 'string', description: 'Reason for deletion (stored for audit trail)' },
107
+ },
108
+ required: ['id'],
109
+ },
110
+ },
111
+ {
112
+ name: 'list_recent_memories',
113
+ description: 'List recent memory observations for the project. Use this when the user wants to see what has been stored recently or browse the knowledge base.',
114
+ parameters: {
115
+ type: 'object',
116
+ properties: {
117
+ limit: { type: 'number', description: 'Maximum results to return (default 10, max 20)' },
118
+ type: { type: 'string', description: 'Filter by observation type (optional)' },
119
+ },
120
+ },
121
+ },
122
+ ];
123
+
124
+ const SYSTEM_PROMPT = [
125
+ 'You are Memorix, a memory-grounded AI assistant for a software project.',
126
+ 'You have access to the project\'s memory knowledge base via tools.',
127
+ '',
128
+ 'Behavior guidelines:',
129
+ '- For casual conversation (greetings, small talk, meta questions), respond naturally WITHOUT calling any tools.',
130
+ '- When the user asks about project decisions, architecture, bugs, rationale, recent changes, or technical details, use search_memories to find relevant context.',
131
+ '- If initial search results are sparse, try a different query or use get_memory_detail on promising results.',
132
+ '- When the user wants to SAVE information (decisions, gotchas, fixes, notes), use store_memory to persist it.',
133
+ '- When the user wants to UPDATE existing memories, use update_memory with the observation ID.',
134
+ '- When the user wants to DELETE outdated memories, use delete_memory to archive them.',
135
+ '- When the user wants to BROWSE recent memories, use list_recent_memories.',
136
+ '- Always cite supporting memories inline as [obs:<id>].',
137
+ '- Do not invent memories, IDs, files, or decisions that are not in the retrieved context.',
138
+ '- If the retrieved context is insufficient, say so explicitly and suggest what to store.',
139
+ '- Prefer concise, practical answers for engineers revisiting project history.',
140
+ ].join('\n');
141
+
142
+ // ── Helpers ─────────────────────────────────────────────────────────
143
+
144
+ function truncate(text: string, max: number): string {
145
+ if (text.length <= max) return text;
146
+ return `${text.slice(0, max - 1)}…`;
147
+ }
148
+
149
+ function buildExcerpt(doc: MemorixDocument): string {
150
+ const combined = [doc.narrative, doc.facts]
151
+ .filter(Boolean)
152
+ .join(' ')
153
+ .replace(/\s+/g, ' ')
154
+ .trim();
155
+
156
+ if (!combined) return 'No narrative available.';
157
+ return truncate(combined, 180);
158
+ }
159
+
160
+ function toSource(doc: MemorixDocument, score: number): ChatSource {
161
+ return {
162
+ id: doc.observationId,
163
+ title: doc.title || '(untitled)',
164
+ type: doc.type || 'discovery',
165
+ entityName: doc.entityName || '',
166
+ excerpt: buildExcerpt(doc),
167
+ score,
168
+ createdAt: doc.createdAt,
169
+ };
170
+ }
171
+
172
+ function buildFallbackAnswer(question: string, sources: ChatSource[], warning?: string): string {
173
+ if (sources.length === 0) {
174
+ return [
175
+ warning || 'I could not find relevant project memories for that question.',
176
+ `Question: ${question}`,
177
+ 'Try /search with narrower terms, or store the missing context first.',
178
+ ].join('\n');
179
+ }
180
+
181
+ const lines = [
182
+ warning || 'LLM chat is not configured, so here are the most relevant project memories I found:',
183
+ '',
184
+ ...sources.map((source) => `- [obs:${source.id}] ${source.title} — ${source.excerpt}`),
185
+ ];
186
+
187
+ return lines.join('\n');
188
+ }
189
+
190
+ function normalizeSearchMode(raw: string): string {
191
+ if (raw.includes('vector')) return 'vector';
192
+ if (raw.includes('rerank')) return 'rerank';
193
+ if (raw.includes('hybrid')) return 'hybrid';
194
+ return 'fulltext';
195
+ }
196
+
197
+ /** Track which projectIds have already been prepared to avoid redundant loadAll(). */
198
+ const preparedProjects = new Set<string>();
199
+
200
+ async function prepareProjectSearch(projectId: string, dataDir: string): Promise<void> {
201
+ if (preparedProjects.has(projectId)) return;
202
+
203
+ await initObservationStore(dataDir);
204
+ await initObservations(dataDir); // loads observations into memory once
205
+ await getDb();
206
+ // Use the already-loaded in-memory observations (from initObservations)
207
+ // instead of calling loadAll() a second time.
208
+ const allObs = getAllObservations() as any[];
209
+ await hydrateIndex(allObs); // idempotent: skips if index already populated
210
+ preparedProjects.add(projectId);
211
+ }
212
+
213
+ // ── Tool execution ──────────────────────────────────────────────────
214
+
215
+ interface ToolExecutionContext {
216
+ projectId: string;
217
+ collectedSources: ChatSource[];
218
+ }
219
+
220
+ function executeSearchMemories(args: { query: string; limit?: number }, ctx: ToolExecutionContext): Promise<string> {
221
+ const limit = Math.min(args.limit ?? SEARCH_LIMIT, 10);
222
+ return compactSearch({ query: args.query, limit, projectId: ctx.projectId, status: 'active' })
223
+ .then((result) => {
224
+ const entries = result.entries.slice(0, DETAIL_LIMIT);
225
+ if (entries.length === 0) return 'No memories found for that query.';
226
+
227
+ // Collect sources for citation tracking
228
+ const refs = entries.map((e) => ({ id: e.id, projectId: ctx.projectId }));
229
+ return compactDetail(refs).then((detail) => {
230
+ for (let i = 0; i < detail.documents.length; i++) {
231
+ const doc = detail.documents[i];
232
+ const score = entries[i]?.score ?? 0;
233
+ ctx.collectedSources.push(toSource(doc, score));
234
+ }
235
+ return detail.documents.map((doc) =>
236
+ `[obs:${doc.observationId}] ${doc.title}\n Type: ${doc.type} | Entity: ${doc.entityName}\n ${truncate(doc.narrative || doc.facts || 'No details', 200)}`,
237
+ ).join('\n\n');
238
+ });
239
+ })
240
+ .catch((err) => `Search error: ${err instanceof Error ? err.message : String(err)}`);
241
+ }
242
+
243
+ function executeGetMemoryDetail(args: { id: number }, ctx: ToolExecutionContext): Promise<string> {
244
+ return compactDetail([{ id: args.id, projectId: ctx.projectId }])
245
+ .then((detail) => {
246
+ if (detail.documents.length === 0) return `Observation ${args.id} not found.`;
247
+ const doc = detail.documents[0];
248
+ ctx.collectedSources.push(toSource(doc, 1.0));
249
+ return [
250
+ `[obs:${doc.observationId}] ${doc.title}`,
251
+ `Type: ${doc.type}`,
252
+ `Entity: ${doc.entityName}`,
253
+ doc.narrative ? `Narrative: ${doc.narrative}` : '',
254
+ doc.facts ? `Facts: ${doc.facts}` : '',
255
+ doc.filesModified ? `Files: ${doc.filesModified}` : '',
256
+ doc.concepts ? `Concepts: ${doc.concepts}` : '',
257
+ doc.createdAt ? `Created: ${doc.createdAt}` : '',
258
+ ].filter(Boolean).join('\n');
259
+ })
260
+ .catch((err) => `Detail error: ${err instanceof Error ? err.message : String(err)}`);
261
+ }
262
+
263
+ async function executeToolCall(tc: ToolCall, ctx: ToolExecutionContext): Promise<string> {
264
+ let args: Record<string, unknown>;
265
+ try {
266
+ args = JSON.parse(tc.arguments);
267
+ } catch {
268
+ return `Error: invalid JSON arguments for tool ${tc.name}`;
269
+ }
270
+
271
+ switch (tc.name) {
272
+ case 'search_memories':
273
+ return executeSearchMemories(args as { query: string; limit?: number }, ctx);
274
+ case 'get_memory_detail':
275
+ return executeGetMemoryDetail(args as { id: number }, ctx);
276
+ case 'store_memory':
277
+ return executeStoreMemory(args as { entityName: string; type: string; title: string; narrative: string; facts?: string[] }, ctx);
278
+ case 'update_memory':
279
+ return executeUpdateMemory(args as { id: number; narrative: string; facts?: string[]; append?: boolean }, ctx);
280
+ case 'delete_memory':
281
+ return executeDeleteMemory(args as { id: number; reason?: string }, ctx);
282
+ case 'list_recent_memories':
283
+ return executeListRecentMemories(args as { limit?: number; type?: string }, ctx);
284
+ default:
285
+ return `Unknown tool: ${tc.name}`;
286
+ }
287
+ }
288
+
289
+ function executeStoreMemory(
290
+ args: { entityName: string; type: string; title: string; narrative: string; facts?: string[] },
291
+ ctx: ToolExecutionContext,
292
+ ): Promise<string> {
293
+ const validTypes: ObservationType[] = ['gotcha', 'decision', 'problem-solution', 'how-it-works', 'what-changed', 'discovery', 'why-it-exists', 'trade-off', 'reasoning', 'session-request'];
294
+ const type = validTypes.includes(args.type as ObservationType) ? args.type as ObservationType : 'discovery';
295
+
296
+ return storeObservation({
297
+ entityName: args.entityName,
298
+ type,
299
+ title: args.title,
300
+ narrative: args.narrative,
301
+ facts: args.facts,
302
+ projectId: ctx.projectId,
303
+ source: 'agent',
304
+ }).then((result) => {
305
+ const obs = result.observation;
306
+ ctx.collectedSources.push({
307
+ id: obs.id,
308
+ title: obs.title,
309
+ type: obs.type,
310
+ entityName: obs.entityName,
311
+ excerpt: truncate(obs.narrative, 180),
312
+ score: 1.0,
313
+ });
314
+ return `Stored observation [obs:${obs.id}] "${obs.title}" (${type}, entity: ${args.entityName})${result.upserted ? ' [updated existing via topicKey]' : ''}`;
315
+ }).catch((err) => `Error storing memory: ${err instanceof Error ? err.message : String(err)}`);
316
+ }
317
+
318
+ function executeUpdateMemory(
319
+ args: { id: number; narrative: string; facts?: string[]; append?: boolean },
320
+ ctx: ToolExecutionContext,
321
+ ): string {
322
+ const obs = getObservation(args.id, ctx.projectId);
323
+ if (!obs) return `Observation ${args.id} not found.`;
324
+
325
+ const shouldAppend = args.append !== false; // default true
326
+ const newNarrative = shouldAppend
327
+ ? `${obs.narrative}\n\n${args.narrative}`
328
+ : args.narrative;
329
+ const newFacts = shouldAppend && obs.facts
330
+ ? [...obs.facts, ...(args.facts ?? [])]
331
+ : args.facts ?? obs.facts;
332
+
333
+ // Use storeObservation with topicKey for upsert
334
+ storeObservation({
335
+ entityName: obs.entityName,
336
+ type: obs.type,
337
+ title: obs.title,
338
+ narrative: newNarrative,
339
+ facts: newFacts,
340
+ projectId: ctx.projectId,
341
+ topicKey: obs.topicKey,
342
+ source: 'agent',
343
+ }).catch(() => {/* non-blocking */});
344
+
345
+ return `Updated observation [obs:${args.id}] — ${shouldAppend ? 'appended to' : 'replaced'} narrative${args.facts?.length ? ` and added ${args.facts.length} facts` : ''}. Changes will be persisted asynchronously.`;
346
+ }
347
+
348
+ function executeDeleteMemory(
349
+ args: { id: number; reason?: string },
350
+ ctx: ToolExecutionContext,
351
+ ): Promise<string> {
352
+ const obs = getObservation(args.id, ctx.projectId);
353
+ if (!obs) return Promise.resolve(`Observation ${args.id} not found.`);
354
+
355
+ return resolveObservations([args.id], 'resolved')
356
+ .then((result) => {
357
+ if (result.resolved.includes(args.id)) {
358
+ return `Archived observation [obs:${args.id}] "${obs.title}"${args.reason ? ` — reason: ${args.reason}` : ''}`;
359
+ }
360
+ return `Could not archive observation ${args.id} (not found or already resolved)`;
361
+ })
362
+ .catch((err) => `Error deleting memory: ${err instanceof Error ? err.message : String(err)}`);
363
+ }
364
+
365
+ function executeListRecentMemories(
366
+ args: { limit?: number; type?: string },
367
+ ctx: ToolExecutionContext,
368
+ ): string {
369
+ const limit = Math.min(args.limit ?? 10, 20);
370
+ let allObs = getAllObservations()
371
+ .filter(o => o.projectId === ctx.projectId && o.status !== 'archived' && o.status !== 'resolved');
372
+
373
+ if (args.type) {
374
+ allObs = allObs.filter(o => o.type === args.type);
375
+ }
376
+
377
+ // Sort by creation date, most recent first
378
+ allObs.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
379
+ const recent = allObs.slice(0, limit);
380
+
381
+ if (recent.length === 0) {
382
+ return args.type ? `No ${args.type} memories found.` : 'No memories found for this project.';
383
+ }
384
+
385
+ return recent.map(o =>
386
+ `[obs:${o.id}] ${o.title}\n Type: ${o.type} | Entity: ${o.entityName} | ${o.createdAt?.slice(0, 10) || '?'}`
387
+ ).join('\n\n');
388
+ }
389
+
390
+ // ── Agentic harness loop ────────────────────────────────────────────
391
+
392
+ export async function askMemoryQuestion(
393
+ question: string,
394
+ history: ChatHistoryTurn[] = [],
395
+ ): Promise<ChatAnswer> {
396
+ const trimmedQuestion = question.trim();
397
+ if (!trimmedQuestion) {
398
+ return {
399
+ question: '',
400
+ answer: 'Ask a question about your project memories.',
401
+ sources: [],
402
+ usedLLM: false,
403
+ searchMode: 'fulltext',
404
+ warning: 'Empty question.',
405
+ };
406
+ }
407
+
408
+ const project = detectProject(process.cwd());
409
+
410
+ // No project + no LLM → dead end
411
+ if (!project) {
412
+ loadDotenv(process.cwd());
413
413
  initLLM({ scope: 'agent' });
414
- if (!isLLMEnabled()) {
415
- return {
416
- question: trimmedQuestion,
417
- answer: 'No project detected. Open Memorix inside a git repository to chat with project memories.',
418
- sources: [],
419
- usedLLM: false,
420
- searchMode: 'fulltext',
421
- warning: 'No project detected.',
422
- };
423
- }
424
-
425
- // No project but LLM available → chat without memory tools
426
- const messages: ChatMessage[] = [
427
- { role: 'system', content: SYSTEM_PROMPT + '\n\nNote: No project is currently detected. You can chat normally, but memory search tools are not available. Suggest the user run Memorix inside a git repository to enable memory features.' },
428
- ];
429
- const recentHistory = history.slice(-HISTORY_LIMIT);
430
- for (const turn of recentHistory) {
431
- messages.push({ role: turn.role, content: turn.content });
432
- }
433
- messages.push({ role: 'user', content: trimmedQuestion });
434
-
435
- const response = await callLLMWithTools(messages, []);
436
- return {
437
- question: trimmedQuestion,
438
- answer: response.content.trim() || 'I could not generate a response.',
439
- sources: [],
440
- usedLLM: true,
441
- llmModel: getLLMConfig()?.model,
442
- searchMode: 'fulltext',
443
- warning: 'No project detected — memory tools unavailable.',
444
- };
445
- }
446
-
447
- loadDotenv(project.rootPath);
414
+ if (!isLLMEnabled()) {
415
+ return {
416
+ question: trimmedQuestion,
417
+ answer: 'No project detected. Open Memorix inside a git repository to chat with project memories.',
418
+ sources: [],
419
+ usedLLM: false,
420
+ searchMode: 'fulltext',
421
+ warning: 'No project detected.',
422
+ };
423
+ }
424
+
425
+ // No project but LLM available → chat without memory tools
426
+ const messages: ChatMessage[] = [
427
+ { role: 'system', content: SYSTEM_PROMPT + '\n\nNote: No project is currently detected. You can chat normally, but memory search tools are not available. Suggest the user run Memorix inside a git repository to enable memory features.' },
428
+ ];
429
+ const recentHistory = history.slice(-HISTORY_LIMIT);
430
+ for (const turn of recentHistory) {
431
+ messages.push({ role: turn.role, content: turn.content });
432
+ }
433
+ messages.push({ role: 'user', content: trimmedQuestion });
434
+
435
+ const response = await callLLMWithTools(messages, []);
436
+ return {
437
+ question: trimmedQuestion,
438
+ answer: response.content.trim() || 'I could not generate a response.',
439
+ sources: [],
440
+ usedLLM: true,
441
+ llmModel: getLLMConfig()?.model,
442
+ searchMode: 'fulltext',
443
+ warning: 'No project detected — memory tools unavailable.',
444
+ };
445
+ }
446
+
447
+ loadDotenv(project.rootPath);
448
448
  initLLM({ scope: 'agent' });
449
-
450
- const dataDir = await getProjectDataDir(project.id);
451
- await prepareProjectSearch(project.id, dataDir);
452
-
453
- const searchMode = normalizeSearchMode(getLastSearchMode(project.id) || 'fulltext');
454
-
455
- // LLM not configured — legacy fallback
456
- if (!isLLMEnabled()) {
457
- const searchResult = await compactSearch({
458
- query: trimmedQuestion,
459
- limit: SEARCH_LIMIT,
460
- projectId: project.id,
461
- status: 'active',
462
- });
463
- const topEntries = searchResult.entries.slice(0, DETAIL_LIMIT);
464
- const detailRefs = topEntries.map((entry) => ({ id: entry.id, projectId: project.id }));
465
- const detailResult = detailRefs.length > 0
466
- ? await compactDetail(detailRefs)
467
- : { documents: [], formatted: '', totalTokens: 0 };
468
- const sources = detailResult.documents.map((doc, index) => toSource(doc, topEntries[index]?.score ?? 0));
469
-
470
- return {
471
- question: trimmedQuestion,
472
- answer: buildFallbackAnswer(trimmedQuestion, sources),
473
- sources,
474
- usedLLM: false,
475
- searchMode,
476
- warning: sources.length === 0 ? 'No relevant memories found.' : 'LLM not configured.',
477
- };
478
- }
479
-
480
- // ── Agentic harness loop ──────────────────────────────────────
481
- // Build the message history for the LLM
482
- const messages: ChatMessage[] = [
483
- { role: 'system', content: SYSTEM_PROMPT },
484
- ];
485
-
486
- // Inject recent conversation history
487
- const recentHistory = history.slice(-HISTORY_LIMIT);
488
- for (const turn of recentHistory) {
489
- messages.push({ role: turn.role, content: turn.content });
490
- }
491
-
492
- // Add the current user question
493
- messages.push({ role: 'user', content: trimmedQuestion });
494
-
495
- const ctx: ToolExecutionContext = {
496
- projectId: project.id,
497
- collectedSources: [],
498
- };
499
-
500
- let totalToolCalls = 0;
501
- let finalContent = '';
502
-
503
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
504
- const response = await callLLMWithTools(messages, MEMORY_TOOLS);
505
-
506
- // No tool calls — LLM is done, return its text response
507
- if (response.toolCalls.length === 0) {
508
- finalContent = response.content.trim();
509
- break;
510
- }
511
-
512
- // LLM requested tool calls — execute them and feed results back
513
- totalToolCalls += response.toolCalls.length;
514
-
515
- // Add the assistant's message (with tool calls) to the conversation
516
- messages.push({
517
- role: 'assistant',
518
- content: response.content,
519
- toolCalls: response.toolCalls,
520
- });
521
-
522
- // Execute each tool call and add results
523
- for (const tc of response.toolCalls) {
524
- const result = await executeToolCall(tc, ctx);
525
- messages.push({
526
- role: 'tool',
527
- content: result,
528
- toolCallId: tc.id,
529
- name: tc.name,
530
- });
531
- }
532
-
533
- // If this is the last allowed round, force a final answer
534
- if (round === MAX_TOOL_ROUNDS - 1) {
535
- const finalResponse = await callLLMWithTools(messages, []); // no tools = force text answer
536
- finalContent = finalResponse.content.trim();
537
- break;
538
- }
539
- }
540
-
541
- // Deduplicate sources by ID
542
- const seenIds = new Set<number>();
543
- const sources = ctx.collectedSources.filter((s) => {
544
- if (seenIds.has(s.id)) return false;
545
- seenIds.add(s.id);
546
- return true;
547
- });
548
-
549
- return {
550
- question: trimmedQuestion,
551
- answer: finalContent || buildFallbackAnswer(trimmedQuestion, sources, 'The LLM returned an empty answer.'),
552
- sources,
553
- usedLLM: true,
554
- llmModel: getLLMConfig()?.model,
555
- searchMode,
556
- toolCallsCount: totalToolCalls,
557
- };
558
- }
559
-
560
- // ── Streaming version ──────────────────────────────────────────────
561
-
562
- export interface StreamingChatCallbacks {
563
- /** Called for each text chunk from the LLM */
564
- onChunk?: (text: string) => void;
565
- /** Called when a tool is being executed */
566
- onToolCall?: (name: string, args: string) => void;
567
- }
568
-
569
- /**
570
- * Streaming version of askMemoryQuestion.
571
- * Uses onChunk callback to deliver the LLM response to the UI.
572
- * Currently uses non-streaming API calls for reliability;
573
- * streaming can be enabled later when SSE parsing is hardened.
574
- */
575
- export async function askMemoryQuestionStream(
576
- question: string,
577
- history: ChatHistoryTurn[] = [],
578
- callbacks?: StreamingChatCallbacks,
579
- signal?: AbortSignal,
580
- ): Promise<ChatAnswer> {
581
- const trimmedQuestion = question.trim();
582
- if (!trimmedQuestion) {
583
- return {
584
- question: '',
585
- answer: 'Ask a question about your project memories.',
586
- sources: [],
587
- usedLLM: false,
588
- searchMode: 'fulltext',
589
- warning: 'Empty question.',
590
- };
591
- }
592
-
593
- const project = detectProject(process.cwd());
594
-
595
- // No project + no LLM → dead end
596
- if (!project) {
597
- loadDotenv(process.cwd());
449
+
450
+ const dataDir = await getProjectDataDir(project.id);
451
+ await prepareProjectSearch(project.id, dataDir);
452
+
453
+ const searchMode = normalizeSearchMode(getLastSearchMode(project.id) || 'fulltext');
454
+
455
+ // LLM not configured — legacy fallback
456
+ if (!isLLMEnabled()) {
457
+ const searchResult = await compactSearch({
458
+ query: trimmedQuestion,
459
+ limit: SEARCH_LIMIT,
460
+ projectId: project.id,
461
+ status: 'active',
462
+ });
463
+ const topEntries = searchResult.entries.slice(0, DETAIL_LIMIT);
464
+ const detailRefs = topEntries.map((entry) => ({ id: entry.id, projectId: project.id }));
465
+ const detailResult = detailRefs.length > 0
466
+ ? await compactDetail(detailRefs)
467
+ : { documents: [], formatted: '', totalTokens: 0 };
468
+ const sources = detailResult.documents.map((doc, index) => toSource(doc, topEntries[index]?.score ?? 0));
469
+
470
+ return {
471
+ question: trimmedQuestion,
472
+ answer: buildFallbackAnswer(trimmedQuestion, sources),
473
+ sources,
474
+ usedLLM: false,
475
+ searchMode,
476
+ warning: sources.length === 0 ? 'No relevant memories found.' : 'LLM not configured.',
477
+ };
478
+ }
479
+
480
+ // ── Agentic harness loop ──────────────────────────────────────
481
+ // Build the message history for the LLM
482
+ const messages: ChatMessage[] = [
483
+ { role: 'system', content: SYSTEM_PROMPT },
484
+ ];
485
+
486
+ // Inject recent conversation history
487
+ const recentHistory = history.slice(-HISTORY_LIMIT);
488
+ for (const turn of recentHistory) {
489
+ messages.push({ role: turn.role, content: turn.content });
490
+ }
491
+
492
+ // Add the current user question
493
+ messages.push({ role: 'user', content: trimmedQuestion });
494
+
495
+ const ctx: ToolExecutionContext = {
496
+ projectId: project.id,
497
+ collectedSources: [],
498
+ };
499
+
500
+ let totalToolCalls = 0;
501
+ let finalContent = '';
502
+
503
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
504
+ const response = await callLLMWithTools(messages, MEMORY_TOOLS);
505
+
506
+ // No tool calls — LLM is done, return its text response
507
+ if (response.toolCalls.length === 0) {
508
+ finalContent = response.content.trim();
509
+ break;
510
+ }
511
+
512
+ // LLM requested tool calls — execute them and feed results back
513
+ totalToolCalls += response.toolCalls.length;
514
+
515
+ // Add the assistant's message (with tool calls) to the conversation
516
+ messages.push({
517
+ role: 'assistant',
518
+ content: response.content,
519
+ toolCalls: response.toolCalls,
520
+ });
521
+
522
+ // Execute each tool call and add results
523
+ for (const tc of response.toolCalls) {
524
+ const result = await executeToolCall(tc, ctx);
525
+ messages.push({
526
+ role: 'tool',
527
+ content: result,
528
+ toolCallId: tc.id,
529
+ name: tc.name,
530
+ });
531
+ }
532
+
533
+ // If this is the last allowed round, force a final answer
534
+ if (round === MAX_TOOL_ROUNDS - 1) {
535
+ const finalResponse = await callLLMWithTools(messages, []); // no tools = force text answer
536
+ finalContent = finalResponse.content.trim();
537
+ break;
538
+ }
539
+ }
540
+
541
+ // Deduplicate sources by ID
542
+ const seenIds = new Set<number>();
543
+ const sources = ctx.collectedSources.filter((s) => {
544
+ if (seenIds.has(s.id)) return false;
545
+ seenIds.add(s.id);
546
+ return true;
547
+ });
548
+
549
+ return {
550
+ question: trimmedQuestion,
551
+ answer: finalContent || buildFallbackAnswer(trimmedQuestion, sources, 'The LLM returned an empty answer.'),
552
+ sources,
553
+ usedLLM: true,
554
+ llmModel: getLLMConfig()?.model,
555
+ searchMode,
556
+ toolCallsCount: totalToolCalls,
557
+ };
558
+ }
559
+
560
+ // ── Streaming version ──────────────────────────────────────────────
561
+
562
+ export interface StreamingChatCallbacks {
563
+ /** Called for each text chunk from the LLM */
564
+ onChunk?: (text: string) => void;
565
+ /** Called when a tool is being executed */
566
+ onToolCall?: (name: string, args: string) => void;
567
+ }
568
+
569
+ /**
570
+ * Streaming version of askMemoryQuestion.
571
+ * Uses onChunk callback to deliver the LLM response to the UI.
572
+ * Currently uses non-streaming API calls for reliability;
573
+ * streaming can be enabled later when SSE parsing is hardened.
574
+ */
575
+ export async function askMemoryQuestionStream(
576
+ question: string,
577
+ history: ChatHistoryTurn[] = [],
578
+ callbacks?: StreamingChatCallbacks,
579
+ signal?: AbortSignal,
580
+ ): Promise<ChatAnswer> {
581
+ const trimmedQuestion = question.trim();
582
+ if (!trimmedQuestion) {
583
+ return {
584
+ question: '',
585
+ answer: 'Ask a question about your project memories.',
586
+ sources: [],
587
+ usedLLM: false,
588
+ searchMode: 'fulltext',
589
+ warning: 'Empty question.',
590
+ };
591
+ }
592
+
593
+ const project = detectProject(process.cwd());
594
+
595
+ // No project + no LLM → dead end
596
+ if (!project) {
597
+ loadDotenv(process.cwd());
598
598
  initLLM({ scope: 'agent' });
599
- if (!isLLMEnabled()) {
600
- return {
601
- question: trimmedQuestion,
602
- answer: 'No project detected. Open Memorix inside a git repository to chat with project memories.',
603
- sources: [],
604
- usedLLM: false,
605
- searchMode: 'fulltext',
606
- warning: 'No project detected.',
607
- };
608
- }
609
-
610
- // No project but LLM available — use non-streaming for reliability
611
- const messages: ChatMessage[] = [
612
- { role: 'system', content: SYSTEM_PROMPT + '\n\nNote: No project is currently detected. You can chat normally, but memory search tools are not available.' },
613
- ];
614
- const recentHistory = history.slice(-HISTORY_LIMIT);
615
- for (const turn of recentHistory) {
616
- messages.push({ role: turn.role, content: turn.content });
617
- }
618
- messages.push({ role: 'user', content: trimmedQuestion });
619
-
620
- const response = await callLLMWithTools(messages, [], signal);
621
- const fullContent = response.content.trim();
622
- callbacks?.onChunk?.(fullContent);
623
-
624
- return {
625
- question: trimmedQuestion,
626
- answer: fullContent || 'I could not generate a response.',
627
- sources: [],
628
- usedLLM: true,
629
- llmModel: getLLMConfig()?.model,
630
- searchMode: 'fulltext',
631
- warning: 'No project detected — memory tools unavailable.',
632
- };
633
- }
634
-
635
- loadDotenv(project.rootPath);
599
+ if (!isLLMEnabled()) {
600
+ return {
601
+ question: trimmedQuestion,
602
+ answer: 'No project detected. Open Memorix inside a git repository to chat with project memories.',
603
+ sources: [],
604
+ usedLLM: false,
605
+ searchMode: 'fulltext',
606
+ warning: 'No project detected.',
607
+ };
608
+ }
609
+
610
+ // No project but LLM available — use non-streaming for reliability
611
+ const messages: ChatMessage[] = [
612
+ { role: 'system', content: SYSTEM_PROMPT + '\n\nNote: No project is currently detected. You can chat normally, but memory search tools are not available.' },
613
+ ];
614
+ const recentHistory = history.slice(-HISTORY_LIMIT);
615
+ for (const turn of recentHistory) {
616
+ messages.push({ role: turn.role, content: turn.content });
617
+ }
618
+ messages.push({ role: 'user', content: trimmedQuestion });
619
+
620
+ const response = await callLLMWithTools(messages, [], signal);
621
+ const fullContent = response.content.trim();
622
+ callbacks?.onChunk?.(fullContent);
623
+
624
+ return {
625
+ question: trimmedQuestion,
626
+ answer: fullContent || 'I could not generate a response.',
627
+ sources: [],
628
+ usedLLM: true,
629
+ llmModel: getLLMConfig()?.model,
630
+ searchMode: 'fulltext',
631
+ warning: 'No project detected — memory tools unavailable.',
632
+ };
633
+ }
634
+
635
+ loadDotenv(project.rootPath);
636
636
  initLLM({ scope: 'agent' });
637
-
638
- const dataDir = await getProjectDataDir(project.id);
639
- await prepareProjectSearch(project.id, dataDir);
640
-
641
- const searchMode = normalizeSearchMode(getLastSearchMode(project.id) || 'fulltext');
642
-
643
- // LLM not configured — legacy fallback (no streaming possible)
644
- if (!isLLMEnabled()) {
645
- const searchResult = await compactSearch({
646
- query: trimmedQuestion,
647
- limit: SEARCH_LIMIT,
648
- projectId: project.id,
649
- status: 'active',
650
- });
651
- const topEntries = searchResult.entries.slice(0, DETAIL_LIMIT);
652
- const detailRefs = topEntries.map((entry) => ({ id: entry.id, projectId: project.id }));
653
- const detailResult = detailRefs.length > 0
654
- ? await compactDetail(detailRefs)
655
- : { documents: [], formatted: '', totalTokens: 0 };
656
- const sources = detailResult.documents.map((doc, index) => toSource(doc, topEntries[index]?.score ?? 0));
657
-
658
- return {
659
- question: trimmedQuestion,
660
- answer: buildFallbackAnswer(trimmedQuestion, sources),
661
- sources,
662
- usedLLM: false,
663
- searchMode,
664
- warning: sources.length === 0 ? 'No relevant memories found.' : 'LLM not configured.',
665
- };
666
- }
667
-
668
- // ── Agentic harness loop with streaming final answer ──────────
669
- const messages: ChatMessage[] = [
670
- { role: 'system', content: SYSTEM_PROMPT },
671
- ];
672
-
673
- const recentHistory = history.slice(-HISTORY_LIMIT);
674
- for (const turn of recentHistory) {
675
- messages.push({ role: turn.role, content: turn.content });
676
- }
677
- messages.push({ role: 'user', content: trimmedQuestion });
678
-
679
- const ctx: ToolExecutionContext = {
680
- projectId: project.id,
681
- collectedSources: [],
682
- };
683
-
684
- let totalToolCalls = 0;
685
- let finalContent = '';
686
-
687
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
688
- signal?.throwIfAborted();
689
- const response = await callLLMWithTools(messages, MEMORY_TOOLS, signal);
690
-
691
- // No tool calls — LLM is done, deliver the answer
692
- if (response.toolCalls.length === 0) {
693
- finalContent = response.content.trim();
694
- // Deliver the full content via onChunk for UI update
695
- callbacks?.onChunk?.(finalContent);
696
- break;
697
- }
698
-
699
- // LLM requested tool calls — execute them and feed results back
700
- totalToolCalls += response.toolCalls.length;
701
-
702
- messages.push({
703
- role: 'assistant',
704
- content: response.content,
705
- toolCalls: response.toolCalls,
706
- });
707
-
708
- for (const tc of response.toolCalls) {
709
- signal?.throwIfAborted();
710
- callbacks?.onToolCall?.(tc.name, tc.arguments);
711
- const result = await executeToolCall(tc, ctx);
712
- messages.push({
713
- role: 'tool',
714
- content: result,
715
- toolCallId: tc.id,
716
- name: tc.name,
717
- });
718
- }
719
-
720
- // If this is the last allowed round, force a final answer
721
- if (round === MAX_TOOL_ROUNDS - 1) {
722
- const finalResponse = await callLLMWithTools(messages, [], signal);
723
- finalContent = finalResponse.content.trim();
724
- callbacks?.onChunk?.(finalContent);
725
- break;
726
- }
727
- }
728
-
729
- // Deduplicate sources by ID
730
- const seenIds = new Set<number>();
731
- const sources = ctx.collectedSources.filter((s) => {
732
- if (seenIds.has(s.id)) return false;
733
- seenIds.add(s.id);
734
- return true;
735
- });
736
-
737
- return {
738
- question: trimmedQuestion,
739
- answer: finalContent || buildFallbackAnswer(trimmedQuestion, sources, 'The LLM returned an empty answer.'),
740
- sources,
741
- usedLLM: true,
742
- llmModel: getLLMConfig()?.model,
743
- searchMode,
744
- toolCallsCount: totalToolCalls,
745
- };
746
- }
637
+
638
+ const dataDir = await getProjectDataDir(project.id);
639
+ await prepareProjectSearch(project.id, dataDir);
640
+
641
+ const searchMode = normalizeSearchMode(getLastSearchMode(project.id) || 'fulltext');
642
+
643
+ // LLM not configured — legacy fallback (no streaming possible)
644
+ if (!isLLMEnabled()) {
645
+ const searchResult = await compactSearch({
646
+ query: trimmedQuestion,
647
+ limit: SEARCH_LIMIT,
648
+ projectId: project.id,
649
+ status: 'active',
650
+ });
651
+ const topEntries = searchResult.entries.slice(0, DETAIL_LIMIT);
652
+ const detailRefs = topEntries.map((entry) => ({ id: entry.id, projectId: project.id }));
653
+ const detailResult = detailRefs.length > 0
654
+ ? await compactDetail(detailRefs)
655
+ : { documents: [], formatted: '', totalTokens: 0 };
656
+ const sources = detailResult.documents.map((doc, index) => toSource(doc, topEntries[index]?.score ?? 0));
657
+
658
+ return {
659
+ question: trimmedQuestion,
660
+ answer: buildFallbackAnswer(trimmedQuestion, sources),
661
+ sources,
662
+ usedLLM: false,
663
+ searchMode,
664
+ warning: sources.length === 0 ? 'No relevant memories found.' : 'LLM not configured.',
665
+ };
666
+ }
667
+
668
+ // ── Agentic harness loop with streaming final answer ──────────
669
+ const messages: ChatMessage[] = [
670
+ { role: 'system', content: SYSTEM_PROMPT },
671
+ ];
672
+
673
+ const recentHistory = history.slice(-HISTORY_LIMIT);
674
+ for (const turn of recentHistory) {
675
+ messages.push({ role: turn.role, content: turn.content });
676
+ }
677
+ messages.push({ role: 'user', content: trimmedQuestion });
678
+
679
+ const ctx: ToolExecutionContext = {
680
+ projectId: project.id,
681
+ collectedSources: [],
682
+ };
683
+
684
+ let totalToolCalls = 0;
685
+ let finalContent = '';
686
+
687
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
688
+ signal?.throwIfAborted();
689
+ const response = await callLLMWithTools(messages, MEMORY_TOOLS, signal);
690
+
691
+ // No tool calls — LLM is done, deliver the answer
692
+ if (response.toolCalls.length === 0) {
693
+ finalContent = response.content.trim();
694
+ // Deliver the full content via onChunk for UI update
695
+ callbacks?.onChunk?.(finalContent);
696
+ break;
697
+ }
698
+
699
+ // LLM requested tool calls — execute them and feed results back
700
+ totalToolCalls += response.toolCalls.length;
701
+
702
+ messages.push({
703
+ role: 'assistant',
704
+ content: response.content,
705
+ toolCalls: response.toolCalls,
706
+ });
707
+
708
+ for (const tc of response.toolCalls) {
709
+ signal?.throwIfAborted();
710
+ callbacks?.onToolCall?.(tc.name, tc.arguments);
711
+ const result = await executeToolCall(tc, ctx);
712
+ messages.push({
713
+ role: 'tool',
714
+ content: result,
715
+ toolCallId: tc.id,
716
+ name: tc.name,
717
+ });
718
+ }
719
+
720
+ // If this is the last allowed round, force a final answer
721
+ if (round === MAX_TOOL_ROUNDS - 1) {
722
+ const finalResponse = await callLLMWithTools(messages, [], signal);
723
+ finalContent = finalResponse.content.trim();
724
+ callbacks?.onChunk?.(finalContent);
725
+ break;
726
+ }
727
+ }
728
+
729
+ // Deduplicate sources by ID
730
+ const seenIds = new Set<number>();
731
+ const sources = ctx.collectedSources.filter((s) => {
732
+ if (seenIds.has(s.id)) return false;
733
+ seenIds.add(s.id);
734
+ return true;
735
+ });
736
+
737
+ return {
738
+ question: trimmedQuestion,
739
+ answer: finalContent || buildFallbackAnswer(trimmedQuestion, sources, 'The LLM returned an empty answer.'),
740
+ sources,
741
+ usedLLM: true,
742
+ llmModel: getLLMConfig()?.model,
743
+ searchMode,
744
+ toolCallsCount: totalToolCalls,
745
+ };
746
+ }