memorix 1.1.9 → 1.1.11

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 (188) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +2452 -845
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/dashboard/static/app.js +29 -3
  6. package/dist/dashboard/static/index.html +201 -201
  7. package/dist/dashboard/static/style.css +3762 -3584
  8. package/dist/index.js +2790 -1310
  9. package/dist/index.js.map +1 -1
  10. package/dist/maintenance-runner.d.ts +40 -0
  11. package/dist/maintenance-runner.js +8193 -0
  12. package/dist/maintenance-runner.js.map +1 -0
  13. package/dist/memcode-runtime/CHANGELOG.md +28 -0
  14. package/dist/memcode-runtime/package.json +4 -4
  15. package/dist/sdk.js +2787 -1307
  16. package/dist/sdk.js.map +1 -1
  17. package/docs/CONFIGURATION.md +15 -4
  18. package/docs/DESIGN_DECISIONS.md +357 -357
  19. package/docs/GIT_MEMORY.md +2 -2
  20. package/docs/PERFORMANCE.md +23 -0
  21. package/docs/dev-log/progress.txt +54 -24
  22. package/package.json +1 -1
  23. package/src/audit/index.ts +156 -156
  24. package/src/cli/commands/audit-list.ts +89 -89
  25. package/src/cli/commands/background.ts +659 -659
  26. package/src/cli/commands/cleanup.ts +279 -255
  27. package/src/cli/commands/codegraph.ts +7 -5
  28. package/src/cli/commands/formation.ts +48 -48
  29. package/src/cli/commands/git-hook-install.ts +111 -111
  30. package/src/cli/commands/handoff.ts +66 -66
  31. package/src/cli/commands/hooks-status.ts +63 -63
  32. package/src/cli/commands/ingest-commit.ts +153 -153
  33. package/src/cli/commands/ingest-image.ts +73 -73
  34. package/src/cli/commands/ingest-log.ts +180 -180
  35. package/src/cli/commands/ingest.ts +44 -44
  36. package/src/cli/commands/integrate-shared.ts +15 -15
  37. package/src/cli/commands/lock.ts +96 -96
  38. package/src/cli/commands/message.ts +121 -121
  39. package/src/cli/commands/poll.ts +70 -70
  40. package/src/cli/commands/purge-all-memory.ts +85 -85
  41. package/src/cli/commands/purge-project-memory.ts +83 -83
  42. package/src/cli/commands/reasoning.ts +132 -132
  43. package/src/cli/commands/retention.ts +107 -108
  44. package/src/cli/commands/serve-http.ts +49 -22
  45. package/src/cli/commands/serve-shared.ts +118 -118
  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/tui/ChatView.tsx +234 -234
  51. package/src/cli/tui/CommandBar.tsx +312 -312
  52. package/src/cli/tui/ContextRail.tsx +118 -118
  53. package/src/cli/tui/HeaderBar.tsx +72 -72
  54. package/src/cli/tui/LogoBanner.tsx +51 -51
  55. package/src/cli/tui/Panels.tsx +632 -632
  56. package/src/cli/tui/Sidebar.tsx +179 -179
  57. package/src/cli/tui/chat-service.ts +742 -742
  58. package/src/cli/tui/data.ts +547 -547
  59. package/src/cli/tui/index.ts +41 -41
  60. package/src/cli/tui/markdown-render.tsx +371 -371
  61. package/src/cli/tui/theme.ts +178 -178
  62. package/src/cli/tui/use-mouse.ts +157 -157
  63. package/src/cli/tui/useNavigation.ts +56 -56
  64. package/src/cli/update-checker.ts +211 -211
  65. package/src/cli/version.ts +7 -7
  66. package/src/cli/workbench.ts +1 -1
  67. package/src/codegraph/auto-context.ts +50 -29
  68. package/src/codegraph/binder.ts +109 -16
  69. package/src/codegraph/exclude.ts +10 -0
  70. package/src/codegraph/lite-provider.ts +148 -18
  71. package/src/codegraph/project-context.ts +58 -12
  72. package/src/codegraph/store.ts +182 -0
  73. package/src/compact/token-budget.ts +74 -74
  74. package/src/config/behavior.ts +97 -59
  75. package/src/config/resolved-config.ts +4 -0
  76. package/src/config/toml-loader.ts +2 -0
  77. package/src/config/yaml-loader.ts +3 -1
  78. package/src/dashboard/project-classification.ts +64 -64
  79. package/src/dashboard/server.ts +31 -30
  80. package/src/dashboard/static/index.html +201 -201
  81. package/src/dashboard/static/style.css +3762 -3584
  82. package/src/embedding/fastembed-provider.ts +142 -142
  83. package/src/embedding/transformers-provider.ts +111 -111
  84. package/src/git/extractor.ts +209 -209
  85. package/src/git/hooks-path.ts +85 -85
  86. package/src/git/noise-filter.ts +226 -210
  87. package/src/hooks/handler.ts +10 -2
  88. package/src/hooks/pattern-detector.ts +173 -173
  89. package/src/hooks/significance-filter.ts +250 -250
  90. package/src/llm/memory-manager.ts +328 -328
  91. package/src/llm/provider.ts +885 -885
  92. package/src/llm/quality.ts +248 -248
  93. package/src/memory/attribution-guard.ts +249 -249
  94. package/src/memory/auto-relations.ts +107 -107
  95. package/src/memory/consolidation.ts +328 -302
  96. package/src/memory/disclosure-policy.ts +141 -141
  97. package/src/memory/entity-extractor.ts +197 -197
  98. package/src/memory/export-import.ts +3 -1
  99. package/src/memory/formation/evaluate.ts +217 -217
  100. package/src/memory/formation/extract.ts +361 -361
  101. package/src/memory/formation/index.ts +417 -417
  102. package/src/memory/formation/resolve.ts +344 -344
  103. package/src/memory/formation/types.ts +315 -315
  104. package/src/memory/freshness.ts +122 -122
  105. package/src/memory/graph.ts +197 -197
  106. package/src/memory/observations.ts +91 -27
  107. package/src/memory/refs.ts +94 -94
  108. package/src/memory/retention.ts +511 -433
  109. package/src/memory/secret-filter.ts +79 -79
  110. package/src/memory/session.ts +542 -523
  111. package/src/multimodal/image-loader.ts +143 -143
  112. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  113. package/src/orchestrate/adapters/claude.ts +111 -111
  114. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  115. package/src/orchestrate/adapters/codex.ts +41 -41
  116. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  117. package/src/orchestrate/adapters/gemini.ts +42 -42
  118. package/src/orchestrate/adapters/index.ts +73 -73
  119. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  120. package/src/orchestrate/adapters/opencode.ts +47 -47
  121. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  122. package/src/orchestrate/adapters/types.ts +77 -77
  123. package/src/orchestrate/capability-router.ts +284 -284
  124. package/src/orchestrate/context-compact.ts +188 -188
  125. package/src/orchestrate/cost-tracker.ts +219 -219
  126. package/src/orchestrate/error-recovery.ts +191 -191
  127. package/src/orchestrate/evidence.ts +140 -140
  128. package/src/orchestrate/ledger.ts +110 -110
  129. package/src/orchestrate/memorix-bridge.ts +380 -380
  130. package/src/orchestrate/output-budget.ts +80 -80
  131. package/src/orchestrate/permission.ts +152 -152
  132. package/src/orchestrate/pipeline-trace.ts +131 -131
  133. package/src/orchestrate/prompt-builder.ts +155 -155
  134. package/src/orchestrate/ring-buffer.ts +37 -37
  135. package/src/orchestrate/task-graph.ts +389 -389
  136. package/src/orchestrate/verify-gate.ts +219 -219
  137. package/src/orchestrate/worktree.ts +232 -232
  138. package/src/project/aliases.ts +374 -374
  139. package/src/project/detector.ts +268 -268
  140. package/src/rules/adapters/claude-code.ts +99 -99
  141. package/src/rules/adapters/codex.ts +97 -97
  142. package/src/rules/adapters/copilot.ts +124 -124
  143. package/src/rules/adapters/cursor.ts +114 -114
  144. package/src/rules/adapters/kiro.ts +126 -126
  145. package/src/rules/adapters/trae.ts +56 -56
  146. package/src/rules/adapters/windsurf.ts +83 -83
  147. package/src/rules/syncer.ts +235 -235
  148. package/src/runtime/control-plane-maintenance.ts +62 -0
  149. package/src/runtime/isolated-maintenance.ts +187 -0
  150. package/src/runtime/maintenance-jobs.ts +512 -0
  151. package/src/runtime/maintenance-runner.ts +96 -0
  152. package/src/runtime/maintenance-targets.ts +59 -0
  153. package/src/runtime/project-maintenance.ts +274 -0
  154. package/src/sdk.ts +327 -327
  155. package/src/search/intent-detector.ts +289 -289
  156. package/src/search/query-expansion.ts +52 -52
  157. package/src/server/formation-timeout.ts +27 -27
  158. package/src/server.ts +212 -224
  159. package/src/skills/mini-skills.ts +386 -386
  160. package/src/store/chat-store.ts +119 -119
  161. package/src/store/file-lock.ts +100 -100
  162. package/src/store/graph-store.ts +249 -249
  163. package/src/store/mini-skill-store.ts +349 -349
  164. package/src/store/obs-store.ts +293 -255
  165. package/src/store/persistence-json.ts +212 -212
  166. package/src/store/persistence.ts +291 -291
  167. package/src/store/project-affinity.ts +195 -195
  168. package/src/store/session-store.ts +268 -259
  169. package/src/store/sqlite-db.ts +40 -0
  170. package/src/store/sqlite-store.ts +429 -339
  171. package/src/team/event-bus.ts +76 -76
  172. package/src/team/file-locks.ts +173 -173
  173. package/src/team/handoff.ts +167 -167
  174. package/src/team/messages.ts +203 -203
  175. package/src/team/poll.ts +132 -132
  176. package/src/team/tasks.ts +211 -211
  177. package/src/wiki/generator.ts +237 -237
  178. package/src/wiki/knowledge-graph.ts +334 -334
  179. package/src/wiki/types.ts +85 -85
  180. package/src/workspace/mcp-adapters/codex.ts +191 -191
  181. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  182. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  183. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  184. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  185. package/src/workspace/mcp-adapters/trae.ts +134 -134
  186. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  187. package/src/workspace/sanitizer.ts +60 -60
  188. 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
+ }