@openanonymity/nanomem 0.1.0 → 0.1.2

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -18
  3. package/package.json +7 -3
  4. package/src/backends/BaseStorage.js +147 -3
  5. package/src/backends/indexeddb.js +21 -8
  6. package/src/browser.js +227 -0
  7. package/src/bullets/parser.js +8 -9
  8. package/src/cli/auth.js +1 -1
  9. package/src/cli/commands.js +58 -9
  10. package/src/cli/config.js +1 -1
  11. package/src/cli/help.js +5 -2
  12. package/src/cli/output.js +4 -0
  13. package/src/cli.js +6 -3
  14. package/src/engine/compactor.js +3 -6
  15. package/src/engine/deleter.js +187 -0
  16. package/src/engine/executors.js +474 -11
  17. package/src/engine/ingester.js +98 -63
  18. package/src/engine/recentConversation.js +110 -0
  19. package/src/engine/retriever.js +243 -37
  20. package/src/engine/toolLoop.js +51 -9
  21. package/src/imports/chatgpt.js +1 -1
  22. package/src/imports/claude.js +85 -0
  23. package/src/imports/importData.js +462 -0
  24. package/src/imports/index.js +10 -0
  25. package/src/index.js +95 -2
  26. package/src/llm/openai.js +204 -58
  27. package/src/llm/tinfoil.js +508 -0
  28. package/src/omf.js +343 -0
  29. package/src/prompt_sets/conversation/ingestion.js +111 -12
  30. package/src/prompt_sets/document/ingestion.js +98 -4
  31. package/src/prompt_sets/index.js +12 -4
  32. package/src/types.js +135 -4
  33. package/src/vendor/tinfoil.browser.d.ts +2 -0
  34. package/src/vendor/tinfoil.browser.js +41596 -0
  35. package/types/backends/BaseStorage.d.ts +19 -0
  36. package/types/backends/indexeddb.d.ts +1 -0
  37. package/types/browser.d.ts +17 -0
  38. package/types/engine/deleter.d.ts +67 -0
  39. package/types/engine/executors.d.ts +56 -2
  40. package/types/engine/recentConversation.d.ts +18 -0
  41. package/types/engine/retriever.d.ts +22 -9
  42. package/types/imports/claude.d.ts +14 -0
  43. package/types/imports/importData.d.ts +29 -0
  44. package/types/imports/index.d.ts +2 -0
  45. package/types/index.d.ts +9 -0
  46. package/types/llm/openai.d.ts +6 -9
  47. package/types/llm/tinfoil.d.ts +13 -0
  48. package/types/omf.d.ts +40 -0
  49. package/types/prompt_sets/conversation/ingestion.d.ts +8 -3
  50. package/types/prompt_sets/document/ingestion.d.ts +8 -3
  51. package/types/types.d.ts +127 -2
  52. package/types/vendor/tinfoil.browser.d.ts +6348 -0
@@ -19,68 +19,97 @@ import {
19
19
 
20
20
  const MAX_CONVERSATION_CHARS = 128000;
21
21
 
22
- /** @type {ToolDefinition[]} */
23
- const EXTRACTION_TOOLS = [
24
- {
25
- type: 'function',
26
- function: {
27
- name: 'read_file',
28
- description: 'Read an existing memory file before writing.',
29
- parameters: {
30
- type: 'object',
31
- properties: {
32
- path: { type: 'string', description: 'File path (e.g. personal/about.md)' }
33
- },
34
- required: ['path']
35
- }
22
+ /** @type {ToolDefinition} */
23
+ const T_READ_FILE = {
24
+ type: 'function',
25
+ function: {
26
+ name: 'read_file',
27
+ description: 'Read an existing memory file before writing.',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: {
31
+ path: { type: 'string', description: 'File path (e.g. personal/about.md)' }
32
+ },
33
+ required: ['path']
36
34
  }
37
- },
38
- {
39
- type: 'function',
40
- function: {
41
- name: 'create_new_file',
42
- description: 'Create a new memory file for a topic not covered by any existing file.',
43
- parameters: {
44
- type: 'object',
45
- properties: {
46
- path: { type: 'string', description: 'File path (e.g. projects/recipe-app.md)' },
47
- content: { type: 'string', description: 'Bullet-point content to write' }
48
- },
49
- required: ['path', 'content']
50
- }
35
+ }
36
+ };
37
+
38
+ /** @type {ToolDefinition} */
39
+ const T_CREATE_NEW_FILE = {
40
+ type: 'function',
41
+ function: {
42
+ name: 'create_new_file',
43
+ description: 'Create a new memory file for a topic not covered by any existing file.',
44
+ parameters: {
45
+ type: 'object',
46
+ properties: {
47
+ path: { type: 'string', description: 'File path (e.g. projects/recipe-app.md)' },
48
+ content: { type: 'string', description: 'Bullet-point content to write' }
49
+ },
50
+ required: ['path', 'content']
51
51
  }
52
- },
53
- {
54
- type: 'function',
55
- function: {
56
- name: 'append_memory',
57
- description: 'Append new bullet points to an existing memory file.',
58
- parameters: {
59
- type: 'object',
60
- properties: {
61
- path: { type: 'string', description: 'File path to append to' },
62
- content: { type: 'string', description: 'Bullet-point content to append' }
63
- },
64
- required: ['path', 'content']
65
- }
52
+ }
53
+ };
54
+
55
+ /** @type {ToolDefinition} */
56
+ const T_APPEND_MEMORY = {
57
+ type: 'function',
58
+ function: {
59
+ name: 'append_memory',
60
+ description: 'Append new bullet points to an existing memory file.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ path: { type: 'string', description: 'File path to append to' },
65
+ content: { type: 'string', description: 'Bullet-point content to append' }
66
+ },
67
+ required: ['path', 'content']
66
68
  }
67
- },
68
- {
69
- type: 'function',
70
- function: {
71
- name: 'update_memory',
72
- description: 'Overwrite an existing memory file. Use when existing content is stale or contradicted.',
73
- parameters: {
74
- type: 'object',
75
- properties: {
76
- path: { type: 'string', description: 'File path to update' },
77
- content: { type: 'string', description: 'Complete new content for the file' }
78
- },
79
- required: ['path', 'content']
80
- }
69
+ }
70
+ };
71
+
72
+ /** @type {ToolDefinition} */
73
+ const T_UPDATE_BULLETS = {
74
+ type: 'function',
75
+ function: {
76
+ name: 'update_bullets',
77
+ description: 'Replace one or more bullet facts in an existing memory file in a single call. Each entry requires the exact existing fact text and its corrected replacement. Only matched bullets are changed — the rest of the file is untouched.',
78
+ parameters: {
79
+ type: 'object',
80
+ properties: {
81
+ path: { type: 'string', description: 'File path containing the bullets to update' },
82
+ updates: {
83
+ type: 'array',
84
+ description: 'List of bullet updates to apply',
85
+ items: {
86
+ type: 'object',
87
+ properties: {
88
+ old_fact: { type: 'string', description: 'Exact fact text of the bullet to replace (pipe-delimited metadata is fine)' },
89
+ new_fact: { type: 'string', description: 'Corrected fact text (plain text only, no metadata)' }
90
+ },
91
+ required: ['old_fact', 'new_fact']
92
+ }
93
+ }
94
+ },
95
+ required: ['path', 'updates']
81
96
  }
82
97
  }
83
- ];
98
+ };
99
+
100
+ /**
101
+ * Tool sets per ingestion mode.
102
+ * `add` — can only write new content.
103
+ * `update` — can only edit existing files (no create/append).
104
+ * Others — full access.
105
+ * @type {Record<string, ToolDefinition[]>}
106
+ */
107
+ const TOOLS_BY_MODE = {
108
+ add: [T_READ_FILE, T_CREATE_NEW_FILE, T_APPEND_MEMORY],
109
+ update: [T_READ_FILE, T_UPDATE_BULLETS, T_APPEND_MEMORY, T_CREATE_NEW_FILE],
110
+ };
111
+
112
+ const EXTRACTION_TOOLS = [T_READ_FILE, T_CREATE_NEW_FILE, T_APPEND_MEMORY, T_UPDATE_BULLETS];
84
113
 
85
114
  class MemoryIngester {
86
115
  constructor({ backend, bulletIndex, llmClient, model, onToolCall }) {
@@ -122,18 +151,22 @@ class MemoryIngester {
122
151
  mergeWithExisting: (existing, incoming, path) => this._mergeWithExisting(existing, incoming, path, updatedAt, isDocument),
123
152
  refreshIndex: (path) => this._bulletIndex.refreshPath(path),
124
153
  onWrite: (path, before, after) => writes.push({ path, before, after }),
154
+ updatedAt,
125
155
  });
126
156
 
157
+ const dateNote = `\nFrom ${updatedAt}. Use this date when writing date references in facts.\n`;
127
158
  const userMessage = isDocument
128
- ? `Document content:\n\`\`\`\n${conversationText}\n\`\`\``
129
- : `Conversation:\n\`\`\`\n${conversationText}\n\`\`\``;
159
+ ? `${dateNote}Document content:\n\`\`\`\n${conversationText}\n\`\`\``
160
+ : `${dateNote}Conversation:\n\`\`\`\n${conversationText}\n\`\`\``;
161
+
162
+ const tools = TOOLS_BY_MODE[mode] || EXTRACTION_TOOLS;
130
163
 
131
164
  let toolCallLog;
132
165
  try {
133
166
  const result = await runAgenticToolLoop({
134
167
  llmClient: this._llmClient,
135
168
  model: this._model,
136
- tools: EXTRACTION_TOOLS,
169
+ tools,
137
170
  toolExecutors,
138
171
  messages: [
139
172
  { role: 'system', content: systemPrompt },
@@ -142,8 +175,8 @@ class MemoryIngester {
142
175
  maxIterations: 12,
143
176
  maxOutputTokens: 4000,
144
177
  temperature: 0,
145
- onToolCall: (name, args, result) => {
146
- onToolCall?.(name, args, result);
178
+ onToolCall: (name, args, result, meta) => {
179
+ onToolCall?.(name, args, result, meta);
147
180
  }
148
181
  });
149
182
  toolCallLog = result.toolCallLog;
@@ -152,7 +185,7 @@ class MemoryIngester {
152
185
  return { status: 'error', writeCalls: 0, error: message };
153
186
  }
154
187
 
155
- const writeTools = ['create_new_file', 'append_memory', 'update_memory', 'archive_memory', 'delete_memory'];
188
+ const writeTools = ['create_new_file', 'append_memory', 'update_bullets', 'archive_memory', 'delete_memory'];
156
189
  const writeCalls = toolCallLog.filter(e => writeTools.includes(e.name));
157
190
 
158
191
  return { status: 'processed', writeCalls: writeCalls.length, writes };
@@ -187,6 +220,8 @@ class MemoryIngester {
187
220
 
188
221
  const defaultTopic = inferTopicFromPath(path);
189
222
  const normalized = incomingBullets.map((bullet) => {
223
+ // Clear the LLM-written date so the conversation-level updatedAt
224
+ // is used as the fallback (falls back to today when not provided).
190
225
  const b = ensureBulletMetadata({ ...bullet, updatedAt: null }, { defaultTopic, updatedAt });
191
226
  if (isDocument && b.source === 'user_statement') b.source = 'document';
192
227
  return b;
@@ -0,0 +1,110 @@
1
+ const TURN_PREFIX_RE = /^(User|Assistant):\s*(.*)$/;
2
+
3
+ function clipTurnText(text, maxChars) {
4
+ const raw = String(text || '').trim();
5
+ if (!raw) return '';
6
+ if (!maxChars || raw.length <= maxChars) return raw;
7
+ if (maxChars <= 1) return raw.slice(0, maxChars);
8
+ return `${raw.slice(0, maxChars - 1).trimEnd()}…`;
9
+ }
10
+
11
+ function parseTranscript(conversationText) {
12
+ const lines = String(conversationText || '').split('\n');
13
+ const turns = [];
14
+ let current = null;
15
+
16
+ for (const line of lines) {
17
+ const match = line.match(TURN_PREFIX_RE);
18
+ if (match) {
19
+ if (current?.text?.trim()) {
20
+ turns.push({ ...current, text: current.text.trim() });
21
+ }
22
+ current = {
23
+ role: match[1],
24
+ text: match[2] || ''
25
+ };
26
+ continue;
27
+ }
28
+
29
+ if (!current) continue;
30
+ current.text += `${current.text ? '\n' : ''}${line}`;
31
+ }
32
+
33
+ if (current?.text?.trim()) {
34
+ turns.push({ ...current, text: current.text.trim() });
35
+ }
36
+
37
+ return turns;
38
+ }
39
+
40
+ function trimByTail(raw, maxChars) {
41
+ let tail = raw.slice(-maxChars);
42
+ const firstNewline = tail.indexOf('\n');
43
+ if (firstNewline > 0 && firstNewline < 200) {
44
+ tail = tail.slice(firstNewline + 1);
45
+ }
46
+ return tail.trim() || null;
47
+ }
48
+
49
+ /**
50
+ * Trim a conversation transcript while preserving turn boundaries and preferring
51
+ * to keep user turns visible even when assistant replies are long.
52
+ *
53
+ * @param {string} conversationText
54
+ * @param {object} options
55
+ * @param {number} [options.maxChars]
56
+ * @param {number} [options.maxTurns]
57
+ * @param {number} [options.maxUserChars]
58
+ * @param {number} [options.maxAssistantChars]
59
+ * @returns {string | null}
60
+ */
61
+ export function trimRecentConversation(conversationText, {
62
+ maxChars,
63
+ maxTurns = 6,
64
+ maxUserChars = 500,
65
+ maxAssistantChars = 900
66
+ } = {}) {
67
+ const raw = String(conversationText || '').trim();
68
+ if (!raw || raw.length < 20) return null;
69
+ if (!maxChars || raw.length <= maxChars) {
70
+ return /\n/.test(raw) ? raw : null;
71
+ }
72
+
73
+ const turns = parseTranscript(raw);
74
+ if (turns.length === 0) {
75
+ return trimByTail(raw, maxChars);
76
+ }
77
+
78
+ const clippedTurns = turns
79
+ .slice(-Math.max(2, maxTurns))
80
+ .map((turn) => {
81
+ const maxTurnChars = turn.role === 'Assistant'
82
+ ? maxAssistantChars
83
+ : maxUserChars;
84
+ return `${turn.role}: ${clipTurnText(turn.text, maxTurnChars)}`;
85
+ });
86
+
87
+ const selected = [];
88
+ let totalChars = 0;
89
+ for (let i = clippedTurns.length - 1; i >= 0; i -= 1) {
90
+ const entry = clippedTurns[i];
91
+ const separatorChars = selected.length > 0 ? 2 : 0;
92
+ if (selected.length > 0 && totalChars + separatorChars + entry.length > maxChars) {
93
+ continue;
94
+ }
95
+ if (selected.length === 0 && entry.length > maxChars) {
96
+ selected.unshift(clipTurnText(entry, maxChars));
97
+ totalChars = selected[0].length;
98
+ break;
99
+ }
100
+ selected.unshift(entry);
101
+ totalChars += separatorChars + entry.length;
102
+ }
103
+
104
+ const result = selected.join('\n\n').trim();
105
+ if (result.length >= 20 && /\n/.test(result)) {
106
+ return result;
107
+ }
108
+
109
+ return trimByTail(raw, maxChars);
110
+ }
@@ -5,9 +5,10 @@
5
5
  * and assemble relevant memory context. Falls back to brute-force text
6
6
  * search if the LLM call fails.
7
7
  */
8
- /** @import { LLMClient, Message, ProgressEvent, RetrievalResult, StorageBackend, ToolDefinition } from '../types.js' */
8
+ /** @import { LLMClient, Message, ProgressEvent, RetrievalResult, AugmentQueryResult, StorageBackend, ToolDefinition } from '../types.js' */
9
9
  import { runAgenticToolLoop } from './toolLoop.js';
10
- import { createRetrievalExecutors } from './executors.js';
10
+ import { createAugmentQueryExecutor, createRetrievalExecutors } from './executors.js';
11
+ import { trimRecentConversation } from './recentConversation.js';
11
12
  import {
12
13
  normalizeFactText,
13
14
  parseBullets,
@@ -105,6 +106,68 @@ When recent conversation context is provided alongside the query, use it to reso
105
106
 
106
107
  Only include content that genuinely helps answer this specific query. Do not include unrelated files from other domains.`;
107
108
 
109
+ /** @type {ToolDefinition} */
110
+ const AUGMENT_QUERY_TOOL = {
111
+ type: 'function',
112
+ function: {
113
+ name: 'augment_query',
114
+ description: 'Hand off the original user query plus the minimal relevant memory file paths to the prompt crafter. Call this exactly once after you have identified the relevant files.',
115
+ parameters: {
116
+ type: 'object',
117
+ properties: {
118
+ user_query: {
119
+ type: 'string',
120
+ description: 'The original user query copied verbatim. Do not paraphrase it.'
121
+ },
122
+ memory_files: {
123
+ type: 'array',
124
+ description: 'The minimal set of relevant memory file paths needed by the prompt crafter.'
125
+ }
126
+ },
127
+ required: ['user_query', 'memory_files']
128
+ }
129
+ }
130
+ };
131
+
132
+ const AUGMENT_SYSTEM_ADDENDUM = `
133
+
134
+ ## Augment Query
135
+
136
+ After reading memory files, you MUST call augment_query with the original user query plus the minimal relevant memory file paths. Do NOT draft the final prompt in the tool arguments. The augment_query tool itself will run the prompt-crafting pass.
137
+
138
+ Rules:
139
+ - Read the relevant files first so you know which paths matter.
140
+ - Set user_query to the original user message verbatim.
141
+ - Pass only the minimum set of memory file paths needed for a high-quality answer.
142
+ - Do not include any facts, summaries, names, or rewritten instructions in the tool arguments.
143
+ - If a file does not materially improve the final answer, leave it out.
144
+ - If a file only confirms a general interest already obvious from the query, leave it out.
145
+ - If nothing relevant is found, call augment_query with an empty memory_files array.
146
+ - Make exactly one augment_query call for this user message.
147
+ - Do NOT call assemble_context in this mode.
148
+ `;
149
+
150
+ async function collectReadFiles(toolCallLog, backend) {
151
+ const files = [];
152
+ const seenPaths = new Set();
153
+ for (const entry of toolCallLog) {
154
+ if (entry.name !== 'read_file' || !entry.args?.path || !entry.result) continue;
155
+ const path = typeof backend.resolvePath === 'function'
156
+ ? (await backend.resolvePath(entry.args.path)) || entry.args.path
157
+ : entry.args.path;
158
+ if (seenPaths.has(path)) continue;
159
+ try {
160
+ const parsed = JSON.parse(entry.result);
161
+ if (parsed.error) continue;
162
+ } catch {
163
+ // Non-JSON results are file contents.
164
+ }
165
+ seenPaths.add(path);
166
+ files.push({ path, content: entry.result });
167
+ }
168
+ return files;
169
+ }
170
+
108
171
 
109
172
  class MemoryRetriever {
110
173
  constructor({ backend, bulletIndex, llmClient, model, onProgress, onModelText }) {
@@ -140,7 +203,7 @@ class MemoryRetriever {
140
203
  let result;
141
204
  try {
142
205
  onProgress?.({ stage: 'retrieval', message: 'Selecting relevant memory files...' });
143
- result = await this._toolCallingRetrieval(query, index, onProgress, conversationText, onModelText);
206
+ result = await this._toolCallingRetrieval(query, index, onProgress, conversationText, onModelText, { mode: 'retrieve' });
144
207
  } catch (err) {
145
208
  const message = err instanceof Error ? err.message : String(err);
146
209
  onProgress?.({ stage: 'fallback', message: `LLM unavailable (${message}) — falling back to keyword search. Results may be less accurate.` });
@@ -155,10 +218,46 @@ class MemoryRetriever {
155
218
  return result;
156
219
  }
157
220
 
158
- async _toolCallingRetrieval(query, index, onProgress, conversationText, onModelText) {
159
- const systemPrompt = RETRIEVAL_SYSTEM_PROMPT
160
- .replace('{INDEX}', index);
161
- const toolExecutors = createRetrievalExecutors(this._backend);
221
+ /**
222
+ * @param {string} query
223
+ * @param {string} index
224
+ * @param {(event: ProgressEvent) => void | null} onProgress
225
+ * @param {string | undefined} conversationText
226
+ * @param {((text: string, iteration: number) => void) | null} onModelText
227
+ * @param {{ mode: 'retrieve' | 'augment' }} options
228
+ * @returns {Promise<RetrievalResult | AugmentQueryResult | null>}
229
+ */
230
+ async _toolCallingRetrieval(query, index, onProgress, conversationText, onModelText, options = { mode: 'retrieve' }) {
231
+ const isAugmentMode = options.mode === 'augment';
232
+ const systemPrompt = (
233
+ RETRIEVAL_SYSTEM_PROMPT.replace('{INDEX}', index) +
234
+ (isAugmentMode ? AUGMENT_SYSTEM_ADDENDUM : '')
235
+ );
236
+ const toolExecutors = {
237
+ ...createRetrievalExecutors(this._backend),
238
+ ...(isAugmentMode ? {
239
+ augment_query: createAugmentQueryExecutor({
240
+ backend: this._backend,
241
+ llmClient: this._llmClient,
242
+ model: this._model,
243
+ query,
244
+ conversationText,
245
+ onProgress: (event) => {
246
+ if (!event?.stage || !event?.message) return;
247
+ onProgress?.({
248
+ stage: event.stage,
249
+ message: event.message
250
+ });
251
+ }
252
+ })
253
+ } : {})
254
+ };
255
+ const tools = isAugmentMode
256
+ ? [
257
+ ...RETRIEVAL_TOOLS.filter((tool) => tool.function.name !== 'assemble_context'),
258
+ AUGMENT_QUERY_TOOL
259
+ ]
260
+ : RETRIEVAL_TOOLS;
162
261
 
163
262
  const recentContext = this._buildRecentContext(conversationText);
164
263
  const userContent = recentContext
@@ -168,18 +267,64 @@ class MemoryRetriever {
168
267
  const { terminalToolResult, toolCallLog } = await runAgenticToolLoop({
169
268
  llmClient: this._llmClient,
170
269
  model: this._model,
171
- tools: RETRIEVAL_TOOLS,
270
+ tools,
172
271
  toolExecutors,
173
272
  messages: [
174
273
  { role: 'system', content: systemPrompt },
175
274
  { role: 'user', content: userContent }
176
275
  ],
177
- terminalTool: 'assemble_context',
178
- maxIterations: 8,
276
+ terminalTool: isAugmentMode ? 'augment_query' : 'assemble_context',
277
+ maxIterations: isAugmentMode ? 12 : 10,
179
278
  maxOutputTokens: 4000,
180
279
  temperature: 0,
181
- onToolCall: (name, args, result) => {
182
- onProgress?.({ stage: 'tool_call', message: `Tool: ${name}`, tool: name, args, result });
280
+ executeTerminalTool: isAugmentMode,
281
+ onToolCall: (name, args, result, meta) => {
282
+ const toolState = meta?.status || 'finished';
283
+ let progressArgs = args;
284
+ let progressResult = toolState === 'started' ? '' : (result || '');
285
+ if (toolState === 'finished' && isAugmentMode && name === 'augment_query') {
286
+ let toolError = '';
287
+ let noRelevantMemory = false;
288
+ let canonicalPaths = null;
289
+ if (typeof result === 'string') {
290
+ try {
291
+ const parsed = JSON.parse(result);
292
+ toolError = typeof parsed?.error === 'string' ? parsed.error : '';
293
+ noRelevantMemory = parsed?.noRelevantMemory === true;
294
+ canonicalPaths = Array.isArray(parsed?.files)
295
+ ? parsed.files
296
+ .map((file) => (typeof file?.path === 'string' ? file.path : null))
297
+ .filter(Boolean)
298
+ : null;
299
+ } catch {
300
+ toolError = '';
301
+ canonicalPaths = null;
302
+ }
303
+ }
304
+
305
+ if (toolError) {
306
+ progressResult = `error: ${toolError}`;
307
+ } else if (noRelevantMemory) {
308
+ progressResult = 'no relevant memory kept';
309
+ } else {
310
+ if (Array.isArray(canonicalPaths) && canonicalPaths.length > 0) {
311
+ progressArgs = { ...(args || {}), memory_files: canonicalPaths };
312
+ }
313
+ const selectedCount = Array.isArray(progressArgs?.memory_files) ? progressArgs.memory_files.length : 0;
314
+ progressResult = selectedCount === 0
315
+ ? 'no relevant memory selected'
316
+ : `crafted augmented prompt from ${selectedCount} file${selectedCount === 1 ? '' : 's'}`;
317
+ }
318
+ }
319
+ onProgress?.({
320
+ stage: 'tool_call',
321
+ message: `Tool: ${name}`,
322
+ tool: name,
323
+ args: progressArgs,
324
+ result: progressResult,
325
+ toolState,
326
+ toolCallId: meta?.toolCallId
327
+ });
183
328
  },
184
329
  onModelText,
185
330
  onReasoning: (chunk, iteration) => {
@@ -187,27 +332,60 @@ class MemoryRetriever {
187
332
  }
188
333
  });
189
334
 
190
- const files = [];
191
- const seenPaths = new Set();
192
- for (const entry of toolCallLog) {
193
- if (entry.name === 'read_file' && entry.args?.path && entry.result) {
194
- const path = entry.args.path;
195
- if (seenPaths.has(path)) continue;
196
- try {
197
- const parsed = JSON.parse(entry.result);
198
- if (parsed.error) continue;
199
- } catch { /* not JSON, it's file content */ }
200
- seenPaths.add(path);
201
- files.push({ path, content: entry.result });
335
+ if (isAugmentMode) {
336
+ let augmentPayload = null;
337
+ try {
338
+ augmentPayload = terminalToolResult?.result
339
+ ? JSON.parse(terminalToolResult.result)
340
+ : null;
341
+ } catch {
342
+ augmentPayload = null;
343
+ }
344
+
345
+ if (augmentPayload?.noRelevantMemory === true) {
346
+ return null;
202
347
  }
348
+
349
+ const reviewPrompt = typeof augmentPayload?.reviewPrompt === 'string'
350
+ ? augmentPayload.reviewPrompt
351
+ : '';
352
+ const apiPrompt = typeof augmentPayload?.apiPrompt === 'string'
353
+ ? augmentPayload.apiPrompt
354
+ : MemoryRetriever._stripUserDataTags(reviewPrompt);
355
+ const files = Array.isArray(augmentPayload?.files)
356
+ ? augmentPayload.files.filter((file) => typeof file?.path === 'string' && typeof file?.content === 'string')
357
+ : await collectReadFiles(toolCallLog, this._backend);
358
+ const paths = files.map((file) => file.path);
359
+
360
+ if (!reviewPrompt || files.length === 0) return null;
361
+
362
+ onProgress?.({
363
+ stage: 'complete',
364
+ message: `Crafted prompt from ${files.length} memory file${files.length === 1 ? '' : 's'}.`,
365
+ paths
366
+ });
367
+
368
+ return {
369
+ files,
370
+ paths,
371
+ reviewPrompt,
372
+ apiPrompt,
373
+ assembledContext: null
374
+ };
203
375
  }
204
376
 
205
- const assembledContext = terminalToolResult?.arguments?.content || null;
377
+ const files = await collectReadFiles(toolCallLog, this._backend);
206
378
  const paths = files.map(f => f.path);
207
379
 
380
+ const terminalWasCalled = terminalToolResult != null;
381
+ const assembledContext = terminalToolResult?.arguments?.content || null;
382
+
383
+ // LLM explicitly said nothing relevant — respect that, don't fall back to snippet context.
384
+ if (terminalWasCalled && !assembledContext) return null;
385
+
208
386
  if (files.length === 0 && !assembledContext) return null;
209
387
 
210
- const snippetContext = await this._buildSnippetContext(paths, query, conversationText);
388
+ const snippetContext = terminalWasCalled ? null : await this._buildSnippetContext(paths, query, conversationText);
211
389
 
212
390
  onProgress?.({
213
391
  stage: 'complete',
@@ -218,6 +396,37 @@ class MemoryRetriever {
218
396
  return { files, paths, assembledContext: assembledContext || snippetContext };
219
397
  }
220
398
 
399
+ /**
400
+ * @param {string} query
401
+ * @param {string} [conversationText]
402
+ * @returns {Promise<AugmentQueryResult | null>}
403
+ */
404
+ async augmentQueryForPrompt(query, conversationText) {
405
+ if (!query || !query.trim()) return null;
406
+
407
+ const onProgress = this._onProgress;
408
+ const onModelText = this._onModelText;
409
+
410
+ onProgress?.({ stage: 'init', message: 'Reading memory index...' });
411
+ await this._backend.init();
412
+ const index = await this._backend.getTree();
413
+
414
+ if (!index || await this._isMemoryEmpty(index)) {
415
+ return null;
416
+ }
417
+
418
+ try {
419
+ onProgress?.({ stage: 'retrieval', message: 'Reading memory and crafting a review prompt...' });
420
+ return /** @type {Promise<AugmentQueryResult | null>} */ (
421
+ this._toolCallingRetrieval(query, index, onProgress, conversationText, onModelText, { mode: 'augment' })
422
+ );
423
+ } catch (err) {
424
+ const message = err instanceof Error ? err.message : String(err);
425
+ onProgress?.({ stage: 'fallback', message: `Memory prompt crafting unavailable (${message}).` });
426
+ return null;
427
+ }
428
+ }
429
+
221
430
  async _textSearchFallbackWithLoad(query, onProgress, conversationText) {
222
431
  const paths = await this._textSearchFallback(query);
223
432
  if (!paths || paths.length === 0) return null;
@@ -390,17 +599,9 @@ class MemoryRetriever {
390
599
  }
391
600
 
392
601
  _buildRecentContext(conversationText) {
393
- if (!conversationText || conversationText.length < 20) return null;
394
- if (conversationText.length <= MAX_RECENT_CONTEXT_CHARS) {
395
- const hasMultipleTurns = /\n/.test(conversationText.trim());
396
- return hasMultipleTurns ? conversationText : null;
397
- }
398
- let tail = conversationText.slice(-MAX_RECENT_CONTEXT_CHARS);
399
- const firstNewline = tail.indexOf('\n');
400
- if (firstNewline > 0 && firstNewline < 200) {
401
- tail = tail.slice(firstNewline + 1);
402
- }
403
- return tail.trim() || null;
602
+ return trimRecentConversation(conversationText, {
603
+ maxChars: MAX_RECENT_CONTEXT_CHARS
604
+ });
404
605
  }
405
606
 
406
607
  async _isMemoryEmpty(index) {
@@ -409,6 +610,11 @@ class MemoryRetriever {
409
610
  if (realFiles.length === 0) return true;
410
611
  return !realFiles.some(f => (f.itemCount || 0) > 0);
411
612
  }
613
+
614
+ static _stripUserDataTags(text) {
615
+ if (!text) return text;
616
+ return text.replace(/\[\[user_data\]\]/g, '').replace(/\[\[\/user_data\]\]/g, '');
617
+ }
412
618
  }
413
619
 
414
620
  export { MemoryRetriever };