@openanonymity/nanomem 0.1.0 → 0.1.1

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -8
  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/cli/auth.js +1 -1
  8. package/src/cli/commands.js +51 -8
  9. package/src/cli/config.js +1 -1
  10. package/src/cli/help.js +5 -2
  11. package/src/cli/output.js +4 -0
  12. package/src/cli.js +5 -2
  13. package/src/engine/deleter.js +187 -0
  14. package/src/engine/executors.js +416 -4
  15. package/src/engine/ingester.js +83 -61
  16. package/src/engine/recentConversation.js +110 -0
  17. package/src/engine/retriever.js +238 -36
  18. package/src/engine/toolLoop.js +51 -9
  19. package/src/imports/importData.js +454 -0
  20. package/src/imports/index.js +5 -0
  21. package/src/index.js +95 -2
  22. package/src/llm/openai.js +204 -58
  23. package/src/llm/tinfoil.js +508 -0
  24. package/src/omf.js +343 -0
  25. package/src/prompt_sets/conversation/ingestion.js +101 -11
  26. package/src/prompt_sets/document/ingestion.js +92 -4
  27. package/src/prompt_sets/index.js +12 -4
  28. package/src/types.js +133 -3
  29. package/src/vendor/tinfoil.browser.d.ts +2 -0
  30. package/src/vendor/tinfoil.browser.js +41596 -0
  31. package/types/backends/BaseStorage.d.ts +19 -0
  32. package/types/backends/indexeddb.d.ts +1 -0
  33. package/types/browser.d.ts +17 -0
  34. package/types/engine/deleter.d.ts +67 -0
  35. package/types/engine/executors.d.ts +54 -0
  36. package/types/engine/recentConversation.d.ts +18 -0
  37. package/types/engine/retriever.d.ts +22 -9
  38. package/types/imports/importData.d.ts +29 -0
  39. package/types/imports/index.d.ts +1 -0
  40. package/types/index.d.ts +9 -0
  41. package/types/llm/openai.d.ts +6 -9
  42. package/types/llm/tinfoil.d.ts +13 -0
  43. package/types/omf.d.ts +40 -0
  44. package/types/prompt_sets/conversation/ingestion.d.ts +8 -3
  45. package/types/prompt_sets/document/ingestion.d.ts +8 -3
  46. package/types/types.d.ts +125 -2
  47. package/types/vendor/tinfoil.browser.d.ts +6348 -0
@@ -9,7 +9,7 @@
9
9
  * Each factory takes a storage backend and returns an object mapping
10
10
  * tool names to async functions: { tool_name: async (args) => resultString }
11
11
  */
12
- /** @import { ExtractionExecutorHooks, StorageBackend } from '../types.js' */
12
+ /** @import { ChatCompletionResponse, ExtractionExecutorHooks, LLMClient, StorageBackend, ToolDefinition } from '../types.js' */
13
13
  import {
14
14
  compactBullets,
15
15
  inferTopicFromPath,
@@ -17,6 +17,176 @@ import {
17
17
  parseBullets,
18
18
  renderCompactedDocument,
19
19
  } from '../bullets/index.js';
20
+ import { trimRecentConversation } from './recentConversation.js';
21
+
22
+ const MAX_AUGMENT_QUERY_FILES = 8;
23
+ const MAX_AUGMENT_FILE_CHARS = 1800;
24
+ const MAX_AUGMENT_TOTAL_CHARS = 12000;
25
+ const MAX_AUGMENT_RECENT_CONTEXT_CHARS = 3000;
26
+ const AUGMENT_CRAFTER_MAX_ATTEMPTS = 3;
27
+ const AUGMENT_CRAFTER_RETRY_BASE_DELAY_MS = 350;
28
+
29
+ function normalizeLookupPath(value, { stripExtension = false } = {}) {
30
+ let normalized = String(value || '')
31
+ .trim()
32
+ .replace(/\\/g, '/')
33
+ .replace(/^\.\//, '')
34
+ .replace(/^\/+/, '')
35
+ .replace(/\/+/g, '/');
36
+
37
+ if (stripExtension) {
38
+ normalized = normalized.replace(/\.md$/i, '');
39
+ }
40
+
41
+ if (typeof normalized.normalize === 'function') {
42
+ normalized = normalized.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
43
+ }
44
+
45
+ return normalizeFactText(normalized.replace(/[\/_]/g, ' '));
46
+ }
47
+
48
+ function pathMatchesQuery(path, query) {
49
+ const rawPath = String(path || '');
50
+ const rawQuery = String(query || '').trim().toLowerCase();
51
+ if (!rawPath || !rawQuery) return false;
52
+ if (rawPath.toLowerCase().includes(rawQuery)) return true;
53
+
54
+ const normalizedQuery = normalizeFactText(rawQuery);
55
+ if (!normalizedQuery) return false;
56
+
57
+ return normalizeLookupPath(rawPath).includes(normalizedQuery)
58
+ || normalizeLookupPath(rawPath, { stripExtension: true }).includes(normalizedQuery);
59
+ }
60
+
61
+ const AUGMENT_QUERY_EXECUTOR_SYSTEM_PROMPT = `You craft delegation prompts for a frontier model.
62
+
63
+ Your job is to turn a user's request plus selected memory into a minimized, self-contained prompt with explicit [[user_data]] tagging.
64
+
65
+ Return JSON only with this exact shape:
66
+ {"reviewPrompt":"string"}
67
+
68
+ Core rules:
69
+ - The frontier model has zero prior context. Include everything it actually needs in one pass.
70
+ - Include only the minimum user-specific data required to answer well.
71
+ - If memory is not actually needed, keep the prompt generic.
72
+ - Keep the user's current request in normal prose.
73
+ - Every additional fact sourced from memory files or recent conversation that you include must be wrapped in [[user_data]]...[[/user_data]].
74
+ - Do not wrap generic instructions, output-format guidance, or your own reasoning in tags.
75
+ - Strip personal identifiers unless they are strictly necessary.
76
+ - No real names unless the task genuinely requires the specific name.
77
+ - No specific location unless the task depends on location.
78
+ - Put everything into one final minimized prompt in reviewPrompt.
79
+ - Do not include markdown fences or any text outside the JSON object.
80
+
81
+ Privacy and minimization:
82
+ - Every included fact should pass this test: "Does the frontier model need this specific fact to answer well?" If no, leave it out.
83
+ - If a memory fact only repeats or confirms what the current query already makes obvious, leave it out.
84
+ - Generalize when possible. Prefer "their partner is vegetarian" or just "vegetarian-friendly options" over a partner's real name.
85
+ - Open-ended everyday questions usually need less context than planning or personalized analysis questions.
86
+ - Do not assume household members are part of the request unless the user's question or the retrieved memory makes that clearly necessary.
87
+
88
+ Common over-sharing patterns to avoid:
89
+ - Do not include background facts that merely restate the topic, interest, or domain already obvious from the user's current query.
90
+ - Do not include descriptive biography when the answer only needs concrete constraints, preferences, specs, or requirements.
91
+ - Only include memory when it changes the answer: constraints, tradeoffs, personalization, or disambiguation.
92
+ - Prefer concise, answer-shaping facts over broad user background.
93
+
94
+ The user will review the exact prompt before it is sent. Keep it useful, minimal, and explicit.`;
95
+
96
+ function clipText(value, limit) {
97
+ const text = typeof value === 'string' ? value.trim() : '';
98
+ if (!text) return '';
99
+ if (text.length <= limit) return text;
100
+ return `${text.slice(0, limit)}\n...(truncated)`;
101
+ }
102
+
103
+ function renderFiles(files) {
104
+ const normalizedFiles = Array.isArray(files) ? files : [];
105
+ let usedChars = 0;
106
+
107
+ return normalizedFiles.map((file, index) => {
108
+ const path = typeof file?.path === 'string' ? file.path : `memory-${index + 1}.md`;
109
+ let content = typeof file?.content === 'string' ? file.content.trim() : '';
110
+ if (!content) content = '(empty)';
111
+
112
+ const remaining = MAX_AUGMENT_TOTAL_CHARS - usedChars;
113
+ if (remaining <= 0) {
114
+ content = '(omitted for length)';
115
+ } else {
116
+ content = clipText(content, Math.min(MAX_AUGMENT_FILE_CHARS, remaining));
117
+ usedChars += content.length;
118
+ }
119
+
120
+ return `## ${path}\n${content}`;
121
+ }).join('\n\n');
122
+ }
123
+
124
+ function buildCrafterInput({ userQuery, files, conversationText }) {
125
+ const sections = [
126
+ `User query:\n${userQuery.trim()}`,
127
+ `Retrieved memory files:\n${renderFiles(files)}`
128
+ ];
129
+
130
+ const clippedConversation = trimRecentContext(conversationText);
131
+ if (clippedConversation) {
132
+ sections.push(`Recent conversation:\n${clippedConversation}`);
133
+ }
134
+
135
+ sections.push(`Produce the JSON now. Remember:
136
+ - reviewPrompt should be the exact final prompt that will be shown to the user
137
+ - keep the current user request in normal prose
138
+ - any extra facts injected from memory or recent conversation must stay wrapped in [[user_data]] tags
139
+ - if a memory fact only restates the domain already obvious from the query, omit it
140
+ - omit names, relationship labels, and locations unless the prompt really needs them`);
141
+
142
+ return sections.join('\n\n');
143
+ }
144
+
145
+ function extractResponseText(response) {
146
+ if (!response) return '';
147
+ if (typeof response.content === 'string') return response.content;
148
+ return '';
149
+ }
150
+
151
+ function parseCrafterJson(rawText) {
152
+ const text = typeof rawText === 'string' ? rawText.trim() : '';
153
+ if (!text) {
154
+ throw new Error('augment_query prompt crafter returned an empty response.');
155
+ }
156
+
157
+ const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
158
+ const candidate = codeFenceMatch?.[1]?.trim() || text;
159
+ const start = candidate.indexOf('{');
160
+ const end = candidate.lastIndexOf('}');
161
+ const jsonText = (start !== -1 && end !== -1 && end >= start)
162
+ ? candidate.slice(start, end + 1)
163
+ : candidate;
164
+
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(jsonText);
168
+ } catch (error) {
169
+ throw new Error(`augment_query prompt crafter returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
171
+
172
+ return {
173
+ reviewPrompt: typeof parsed?.reviewPrompt === 'string' ? parsed.reviewPrompt.trim() : ''
174
+ };
175
+ }
176
+
177
+ function sleep(ms) {
178
+ return new Promise((resolve) => setTimeout(resolve, ms));
179
+ }
180
+
181
+ function getCrafterRetryDelay(attemptIndex) {
182
+ const exponential = AUGMENT_CRAFTER_RETRY_BASE_DELAY_MS * Math.pow(2, attemptIndex);
183
+ const jitter = Math.random() * AUGMENT_CRAFTER_RETRY_BASE_DELAY_MS;
184
+ return exponential + jitter;
185
+ }
186
+
187
+ function normalizeQueryText(text) {
188
+ return String(text || '').trim().replace(/\s+/g, ' ');
189
+ }
20
190
 
21
191
  /**
22
192
  * Build tool executors for the retrieval (read) flow.
@@ -33,9 +203,10 @@ export function createRetrievalExecutors(backend) {
33
203
  const contentPaths = results.map(r => r.path);
34
204
 
35
205
  const allFiles = await backend.exportAll();
36
- const queryLower = query.toLowerCase();
37
206
  const pathMatches = allFiles
38
- .filter(f => !f.path.endsWith('_tree.md') && f.path.toLowerCase().includes(queryLower))
207
+ .filter((file) => typeof file?.path === 'string' && typeof file?.content === 'string')
208
+ .filter((file) => !file.path.endsWith('_tree.md'))
209
+ .filter((file) => pathMatchesQuery(file.path, query))
39
210
  .map(f => f.path);
40
211
 
41
212
  const seen = new Set();
@@ -47,13 +218,178 @@ export function createRetrievalExecutors(backend) {
47
218
  return JSON.stringify({ paths: paths.slice(0, 5), count: Math.min(paths.length, 5) });
48
219
  },
49
220
  read_file: async ({ path }) => {
50
- const content = await backend.read(path);
221
+ const resolvedPath = typeof backend.resolvePath === 'function'
222
+ ? await backend.resolvePath(path)
223
+ : null;
224
+ const content = await backend.read(resolvedPath || path);
51
225
  if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
52
226
  return content.length > 1500 ? content.slice(0, 1500) + '...(truncated)' : content;
53
227
  }
54
228
  };
55
229
  }
56
230
 
231
+ /**
232
+ * Build the executed augment_query tool for the retrieval flow.
233
+ *
234
+ * The outer memory-agent loop chooses relevant files. This executor then runs a
235
+ * dedicated prompt-crafter pass that turns those raw inputs into the final
236
+ * tagged prompt, keeping prompt-crafting fully inside nanomem.
237
+ *
238
+ * @param {object} options
239
+ * @param {StorageBackend} options.backend
240
+ * @param {LLMClient} options.llmClient
241
+ * @param {string} options.model
242
+ * @param {string} options.query
243
+ * @param {string} [options.conversationText]
244
+ * @param {(event: { stage: 'loading', message: string, attempt?: number }) => void} [options.onProgress]
245
+ */
246
+ export function createAugmentQueryExecutor({ backend, llmClient, model, query, conversationText, onProgress }) {
247
+ return async ({ user_query, memory_files }) => {
248
+ const selectedPaths = Array.isArray(memory_files)
249
+ ? [...new Set(memory_files.filter((path) => typeof path === 'string' && path.trim()))].slice(0, MAX_AUGMENT_QUERY_FILES)
250
+ : [];
251
+ const originalQuery = normalizeQueryText(query);
252
+ const providedQuery = normalizeQueryText(user_query);
253
+ const effectiveQuery = (typeof user_query === 'string' && providedQuery && providedQuery === originalQuery)
254
+ ? user_query.trim()
255
+ : query;
256
+
257
+ if (!effectiveQuery || !effectiveQuery.trim()) {
258
+ return JSON.stringify({ error: 'augment_query requires the original user_query.' });
259
+ }
260
+
261
+ if (selectedPaths.length === 0) {
262
+ return JSON.stringify({
263
+ noRelevantMemory: true,
264
+ files: []
265
+ });
266
+ }
267
+
268
+ const files = [];
269
+ for (const path of selectedPaths) {
270
+ const resolvedPath = typeof backend.resolvePath === 'function'
271
+ ? await backend.resolvePath(path)
272
+ : null;
273
+ const canonicalPath = resolvedPath || path;
274
+ const raw = await backend.read(canonicalPath);
275
+ if (!raw) continue;
276
+ files.push({ path: canonicalPath, content: raw });
277
+ }
278
+
279
+ if (files.length === 0) {
280
+ return JSON.stringify({ error: 'augment_query could not load any selected memory files.' });
281
+ }
282
+
283
+ let reviewPrompt = '';
284
+ let crafterError = '';
285
+ const messages = /** @type {import('../types.js').LLMMessage[]} */ ([
286
+ { role: 'system', content: AUGMENT_QUERY_EXECUTOR_SYSTEM_PROMPT },
287
+ {
288
+ role: 'user',
289
+ content: buildCrafterInput({
290
+ userQuery: effectiveQuery,
291
+ files,
292
+ conversationText
293
+ })
294
+ }
295
+ ]);
296
+
297
+ for (let attempt = 1; attempt <= AUGMENT_CRAFTER_MAX_ATTEMPTS; attempt += 1) {
298
+ let response;
299
+ try {
300
+ onProgress?.({
301
+ stage: 'loading',
302
+ message: attempt === 1
303
+ ? 'Crafting minimized prompt...'
304
+ : `Retrying prompt crafting (${attempt}/${AUGMENT_CRAFTER_MAX_ATTEMPTS})...`,
305
+ attempt
306
+ });
307
+ if (typeof llmClient.streamChatCompletion === 'function') {
308
+ let emittedReasoningPhase = false;
309
+ let emittedOutputPhase = false;
310
+ response = /** @type {ChatCompletionResponse} */ (await llmClient.streamChatCompletion({
311
+ model,
312
+ messages,
313
+ temperature: 0,
314
+ onDelta: (chunk) => {
315
+ if (!chunk || emittedOutputPhase) return;
316
+ emittedOutputPhase = true;
317
+ onProgress?.({
318
+ stage: 'loading',
319
+ message: 'Finalizing prompt...',
320
+ attempt
321
+ });
322
+ },
323
+ onReasoning: (chunk) => {
324
+ if (!chunk || emittedReasoningPhase) return;
325
+ emittedReasoningPhase = true;
326
+ onProgress?.({
327
+ stage: 'loading',
328
+ message: 'Minimizing personal context...',
329
+ attempt
330
+ });
331
+ }
332
+ }));
333
+ } else {
334
+ response = /** @type {ChatCompletionResponse} */ (await llmClient.createChatCompletion({
335
+ model,
336
+ messages,
337
+ temperature: 0
338
+ }));
339
+ }
340
+ } catch (error) {
341
+ const message = error instanceof Error ? error.message : String(error);
342
+ return JSON.stringify({ error: `augment_query prompt crafting failed: ${message}` });
343
+ }
344
+
345
+ try {
346
+ const parsed = parseCrafterJson(extractResponseText(response));
347
+ reviewPrompt = parsed.reviewPrompt;
348
+ if (!reviewPrompt) {
349
+ throw new Error('augment_query did not produce a reviewPrompt.');
350
+ }
351
+ crafterError = '';
352
+ break;
353
+ } catch (error) {
354
+ crafterError = error instanceof Error ? error.message : String(error);
355
+ if (attempt >= AUGMENT_CRAFTER_MAX_ATTEMPTS) {
356
+ break;
357
+ }
358
+ const delay = getCrafterRetryDelay(attempt - 1);
359
+ onProgress?.({
360
+ stage: 'loading',
361
+ message: `Prompt crafter retry ${attempt + 1}/${AUGMENT_CRAFTER_MAX_ATTEMPTS} after: ${crafterError}`,
362
+ attempt: attempt + 1
363
+ });
364
+ console.warn(`[nanomem/augment_query] prompt crafter attempt ${attempt}/${AUGMENT_CRAFTER_MAX_ATTEMPTS} failed: ${crafterError}. Retrying in ${Math.round(delay)}ms.`);
365
+ await sleep(delay);
366
+ }
367
+ }
368
+
369
+ if (crafterError) {
370
+ return JSON.stringify({ error: `${crafterError} (after ${AUGMENT_CRAFTER_MAX_ATTEMPTS} attempts)` });
371
+ }
372
+
373
+ if (!/\[\[user_data\]\]/.test(reviewPrompt)) {
374
+ return JSON.stringify({
375
+ noRelevantMemory: true,
376
+ files: []
377
+ });
378
+ }
379
+
380
+ const apiPrompt = stripUserDataTags(reviewPrompt);
381
+
382
+ return JSON.stringify({
383
+ reviewPrompt,
384
+ apiPrompt,
385
+ files: files.map((file) => ({
386
+ path: file.path,
387
+ content: clipText(file.content, MAX_AUGMENT_FILE_CHARS)
388
+ }))
389
+ });
390
+ };
391
+ }
392
+
57
393
  /**
58
394
  * Build tool executors for the extraction (write) flow.
59
395
  * @param {StorageBackend} backend
@@ -117,6 +453,70 @@ export function createExtractionExecutors(backend, hooks = {}) {
117
453
  };
118
454
  }
119
455
 
456
+ /**
457
+ * Build tool executors for the deletion flow.
458
+ * @param {StorageBackend} backend
459
+ * @param {{ refreshIndex?: Function, onWrite?: Function }} [hooks]
460
+ */
461
+ export function createDeletionExecutors(backend, hooks = {}) {
462
+ const { refreshIndex, onWrite } = hooks;
463
+
464
+ return {
465
+ list_directory: async ({ dir_path }) => {
466
+ const { files, dirs } = await backend.ls(dir_path || '');
467
+ return JSON.stringify({ files, dirs });
468
+ },
469
+ retrieve_file: async ({ query }) => {
470
+ const results = await backend.search(query);
471
+ const contentPaths = results.map(r => r.path);
472
+
473
+ const allFiles = await backend.exportAll();
474
+ const queryLower = query.toLowerCase();
475
+ const pathMatches = allFiles
476
+ .filter(f => !f.path.endsWith('_tree.md') && f.path.toLowerCase().includes(queryLower))
477
+ .map(f => f.path);
478
+
479
+ const seen = new Set();
480
+ const paths = [];
481
+ for (const p of [...pathMatches, ...contentPaths]) {
482
+ if (!seen.has(p)) { seen.add(p); paths.push(p); }
483
+ }
484
+
485
+ return JSON.stringify({ paths: paths.slice(0, 5), count: Math.min(paths.length, 5) });
486
+ },
487
+ read_file: async ({ path }) => {
488
+ const content = await backend.read(path);
489
+ if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
490
+ return content;
491
+ },
492
+ delete_bullet: async ({ path, bullet_text }) => {
493
+ const before = await backend.read(path);
494
+ if (!before) return JSON.stringify({ error: `File not found: ${path}` });
495
+ // Strip pipe-delimited metadata if present — removeArchivedItem matches
496
+ // against bullet.text (fact text only), not the full line with metadata.
497
+ const factText = bullet_text.includes('|')
498
+ ? bullet_text.split('|')[0].trim()
499
+ : bullet_text.trim();
500
+ const after = removeArchivedItem(before, factText, path);
501
+ if (after === null) {
502
+ return JSON.stringify({ error: `No exact match found for the given bullet text in: ${path}` });
503
+ }
504
+ // If no bullets remain, delete the file entirely instead of leaving empty headers.
505
+ const remaining = parseBullets(after);
506
+ if (remaining.length === 0) {
507
+ await backend.delete(path);
508
+ if (refreshIndex) await refreshIndex(path);
509
+ onWrite?.(path, before, null);
510
+ return JSON.stringify({ success: true, path, action: 'file_deleted', removed: factText });
511
+ }
512
+ await backend.write(path, after);
513
+ if (refreshIndex) await refreshIndex(path);
514
+ onWrite?.(path, before, after);
515
+ return JSON.stringify({ success: true, path, action: 'deleted', removed: factText });
516
+ },
517
+ };
518
+ }
519
+
120
520
  function removeArchivedItem(content, itemText, path) {
121
521
  const raw = String(content || '');
122
522
  const target = normalizeFactText(itemText);
@@ -150,3 +550,15 @@ function removeArchivedItem(content, itemText, path) {
150
550
  if (!removed) return null;
151
551
  return filtered.join('\n').trim();
152
552
  }
553
+
554
+ function trimRecentContext(conversationText) {
555
+ return trimRecentConversation(conversationText, {
556
+ maxChars: MAX_AUGMENT_RECENT_CONTEXT_CHARS
557
+ });
558
+ }
559
+
560
+ function stripUserDataTags(text) {
561
+ return String(text ?? '')
562
+ .replace(/\[\[user_data\]\]/g, '')
563
+ .replace(/\[\[\/user_data\]\]/g, '');
564
+ }
@@ -19,68 +19,86 @@ 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_MEMORY = {
74
+ type: 'function',
75
+ function: {
76
+ name: 'update_memory',
77
+ description: 'Overwrite an existing memory file. Use when existing content is stale or contradicted.',
78
+ parameters: {
79
+ type: 'object',
80
+ properties: {
81
+ path: { type: 'string', description: 'File path to update' },
82
+ content: { type: 'string', description: 'Complete new content for the file' }
83
+ },
84
+ required: ['path', 'content']
81
85
  }
82
86
  }
83
- ];
87
+ };
88
+
89
+ /**
90
+ * Tool sets per ingestion mode.
91
+ * `add` — can only write new content (no update_memory).
92
+ * `update` — can only edit existing files (no create/append).
93
+ * Others — full access.
94
+ * @type {Record<string, ToolDefinition[]>}
95
+ */
96
+ const TOOLS_BY_MODE = {
97
+ add: [T_READ_FILE, T_CREATE_NEW_FILE, T_APPEND_MEMORY],
98
+ update: [T_READ_FILE, T_UPDATE_MEMORY],
99
+ };
100
+
101
+ const EXTRACTION_TOOLS = [T_READ_FILE, T_CREATE_NEW_FILE, T_APPEND_MEMORY, T_UPDATE_MEMORY];
84
102
 
85
103
  class MemoryIngester {
86
104
  constructor({ backend, bulletIndex, llmClient, model, onToolCall }) {
@@ -128,12 +146,14 @@ class MemoryIngester {
128
146
  ? `Document content:\n\`\`\`\n${conversationText}\n\`\`\``
129
147
  : `Conversation:\n\`\`\`\n${conversationText}\n\`\`\``;
130
148
 
149
+ const tools = TOOLS_BY_MODE[mode] || EXTRACTION_TOOLS;
150
+
131
151
  let toolCallLog;
132
152
  try {
133
153
  const result = await runAgenticToolLoop({
134
154
  llmClient: this._llmClient,
135
155
  model: this._model,
136
- tools: EXTRACTION_TOOLS,
156
+ tools,
137
157
  toolExecutors,
138
158
  messages: [
139
159
  { role: 'system', content: systemPrompt },
@@ -142,8 +162,8 @@ class MemoryIngester {
142
162
  maxIterations: 12,
143
163
  maxOutputTokens: 4000,
144
164
  temperature: 0,
145
- onToolCall: (name, args, result) => {
146
- onToolCall?.(name, args, result);
165
+ onToolCall: (name, args, result, meta) => {
166
+ onToolCall?.(name, args, result, meta);
147
167
  }
148
168
  });
149
169
  toolCallLog = result.toolCallLog;
@@ -187,7 +207,9 @@ class MemoryIngester {
187
207
 
188
208
  const defaultTopic = inferTopicFromPath(path);
189
209
  const normalized = incomingBullets.map((bullet) => {
190
- const b = ensureBulletMetadata({ ...bullet, updatedAt: null }, { defaultTopic, updatedAt });
210
+ // Preserve existing updatedAt so update_memory doesn't re-stamp unchanged bullets.
211
+ // Bullets without a date still fall back to updatedAt (today).
212
+ const b = ensureBulletMetadata(bullet, { defaultTopic, updatedAt });
191
213
  if (isDocument && b.source === 'user_statement') b.source = 'document';
192
214
  return b;
193
215
  });