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.
- package/CHANGELOG.md +13 -0
- package/CLAUDE.md +6 -1
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +730 -150
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/index.html +201 -201
- package/dist/dashboard/static/style.css +3584 -3584
- package/dist/index.js +15 -8
- package/dist/index.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +13 -0
- package/dist/memcode-runtime/package.json +4 -4
- package/dist/sdk.js +15 -8
- package/dist/sdk.js.map +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +18 -0
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/SETUP.md +10 -0
- package/docs/dev-log/progress.txt +35 -26
- package/package.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/agent-integrations.ts +623 -0
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/cleanup.ts +255 -255
- package/src/cli/commands/doctor.ts +23 -0
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +66 -66
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +73 -73
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +96 -96
- package/src/cli/commands/message.ts +121 -121
- package/src/cli/commands/poll.ts +70 -70
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +132 -132
- package/src/cli/commands/repair.ts +60 -0
- package/src/cli/commands/retention.ts +108 -108
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/setup.ts +3 -3
- package/src/cli/commands/skills.ts +123 -123
- package/src/cli/commands/task.ts +192 -192
- package/src/cli/commands/transfer.ts +73 -73
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Panels.tsx +632 -632
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/chat-service.ts +742 -742
- package/src/cli/tui/data.ts +547 -547
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/theme.ts +178 -178
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/context-pack.ts +5 -1
- package/src/codegraph/lite-provider.ts +5 -1
- package/src/codegraph/project-context.ts +5 -1
- package/src/compact/token-budget.ts +74 -74
- package/src/config/behavior.ts +59 -59
- package/src/dashboard/project-classification.ts +64 -64
- package/src/dashboard/static/index.html +201 -201
- package/src/dashboard/static/style.css +3584 -3584
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/git/noise-filter.ts +210 -210
- package/src/hooks/installers/index.ts +4 -4
- package/src/hooks/official-skills.ts +1 -1
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +2 -2
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/auto-relations.ts +107 -107
- package/src/memory/consolidation.ts +302 -302
- package/src/memory/disclosure-policy.ts +141 -141
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/retention.ts +433 -433
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +523 -523
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +380 -380
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/verify-gate.ts +219 -219
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +327 -327
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/file-lock.ts +100 -100
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +255 -255
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/session-store.ts +259 -259
- package/src/store/sqlite-store.ts +339 -339
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +167 -167
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/wiki/generator.ts +237 -237
- package/src/wiki/knowledge-graph.ts +334 -334
- package/src/wiki/types.ts +85 -85
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- 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
|
+
}
|