@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
@@ -161,10 +161,18 @@ export async function importCmd(positionals, flags, mem, config, { showProgress,
161
161
 
162
162
  export async function add(positionals, flags, mem, config, { showProgress, spinnerHolder } = {}) {
163
163
  const input = positionals[0] ?? (!process.stdin.isTTY ? await readStdin() : null);
164
- if (!input) throw new Error('Usage: memory add <text>');
164
+ if (!input) throw new Error('Usage: nanomem add <text>');
165
165
 
166
166
  const conversations = parseConversations(input, flags);
167
- return ingestConversations(conversations, 'conversation', mem, { showProgress, spinnerHolder, status: 'added', showDiff: true });
167
+ return ingestConversations(conversations, 'add', mem, { showProgress, spinnerHolder, status: 'added', showDiff: true });
168
+ }
169
+
170
+ export async function update(positionals, flags, mem, config, { showProgress, spinnerHolder } = {}) {
171
+ const input = positionals[0] ?? (!process.stdin.isTTY ? await readStdin() : null);
172
+ if (!input) throw new Error('Usage: nanomem update <text>');
173
+
174
+ const conversations = parseConversations(input, flags);
175
+ return ingestConversations(conversations, 'update', mem, { showProgress, spinnerHolder, status: 'updated', showDiff: true });
168
176
  }
169
177
 
170
178
  async function ingestConversations(conversations, extractionMode, mem, { showProgress, spinnerHolder, status, showDiff = false }) {
@@ -180,7 +188,7 @@ async function ingestConversations(conversations, extractionMode, mem, { showPro
180
188
 
181
189
  for (let i = 0; i < total; i++) {
182
190
  const conv = conversations[i];
183
- const label = conv.title || `conversation ${i + 1}`;
191
+ const label = conv.title || (total > 1 ? `conversation ${i + 1}` : 'conversation');
184
192
 
185
193
  if (showProgress) {
186
194
  const counter = total > 1 ? `${c.gray}(${i + 1}/${total})${c.reset} ` : '';
@@ -259,13 +267,48 @@ export async function write(positionals, flags, mem) {
259
267
  return { status: 'written', path };
260
268
  }
261
269
 
262
- export async function del(positionals, flags, mem) {
263
- const path = positionals[0];
264
- if (!path) throw new Error('Usage: memory delete <path>');
270
+ export async function del(positionals, flags, mem, config, { showProgress, spinnerHolder } = {}) {
271
+ const query = positionals[0] ?? (!process.stdin.isTTY ? await readStdin() : null);
272
+ if (!query) throw new Error('Usage: nanomem delete <query>');
265
273
 
266
274
  await mem.init();
267
- await mem.storage.delete(path);
268
- return { status: 'deleted', path };
275
+
276
+ const isTTY = process.stderr.isTTY;
277
+ const c = isTTY ? { green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m' }
278
+ : { green: '', yellow: '', dim: '', bold: '', reset: '' };
279
+
280
+ let spinner = null;
281
+ if (showProgress && isTTY) {
282
+ spinner = createSpinner('thinking…');
283
+ if (spinnerHolder) spinnerHolder.current = spinner;
284
+ }
285
+
286
+ const result = await mem.deleteContent(query, { deep: !!flags.deep });
287
+
288
+ if (spinnerHolder) spinnerHolder.current = null;
289
+
290
+ if (showProgress) {
291
+ if (result.status === 'error') {
292
+ spinner?.stop(` ${c.yellow}⚠ ${result.error}${c.reset}`);
293
+ } else if (result.deleteCalls > 0) {
294
+ spinner?.stop(` ${c.green}✓ ${result.deleteCalls} fact${result.deleteCalls === 1 ? '' : 's'} deleted${c.reset}`);
295
+ } else {
296
+ spinner?.stop(` ${c.dim}– nothing matched${c.reset}`);
297
+ }
298
+ if (result.writes?.length) {
299
+ for (const { path, before, after } of result.writes) {
300
+ if (after === null) {
301
+ // Entire file was deleted (no bullets remained)
302
+ process.stderr.write(`\n \x1b[1m\x1b[36m${path}\x1b[0m \x1b[2mfile deleted\x1b[0m\n`);
303
+ } else {
304
+ printFileDiff(path, before, after);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ const status = result.status === 'error' ? 'error' : 'deleted_content';
311
+ return { status, deleteCalls: result.deleteCalls, error: result.error };
269
312
  }
270
313
 
271
314
  export async function search(positionals, flags, mem) {
package/src/cli/config.js CHANGED
@@ -83,7 +83,7 @@ export async function resolveConfig(flags) {
83
83
 
84
84
  // ─── Create a memory instance from resolved config ──────────────
85
85
 
86
- const LLM_COMMANDS = new Set(['retrieve', 'extract', 'compact', 'import', 'add']);
86
+ const LLM_COMMANDS = new Set(['retrieve', 'extract', 'compact', 'import', 'add', 'update', 'delete']);
87
87
 
88
88
  export function createMemoryFromConfig(config, command, { onToolCall, onProgress, onCompactProgress } = {}) {
89
89
  const needsLlm = LLM_COMMANDS.has(command);
package/src/cli/help.js CHANGED
@@ -11,7 +11,8 @@ Commands:
11
11
  status Show current config and storage stats
12
12
 
13
13
  Memory:
14
- add <text> Add raw text directly and extract facts
14
+ add <text> Add new facts from text (creates or appends files)
15
+ update <text> Edit existing facts from text (only modifies existing files)
15
16
  import <file|dir|-> Import conversations or notes and extract facts
16
17
  retrieve <query> [--context <file>] Retrieve relevant context for a query
17
18
  compact Deduplicate and archive stale facts
@@ -39,6 +40,7 @@ Flags:
39
40
  Examples:
40
41
  nanomem login
41
42
  nanomem add "User: I moved to Seattle."
43
+ nanomem update "User: Actually I moved to Portland, not Seattle."
42
44
  nanomem import conversations.json
43
45
  nanomem import my-notes.md
44
46
  nanomem import ./notes/
@@ -48,7 +50,8 @@ Examples:
48
50
  `;
49
51
 
50
52
  export const COMMAND_HELP = {
51
- add: 'Usage: nanomem add <text>\n\nAdd raw text directly and extract facts into memory.\nAccepts quoted text or piped stdin.\nRequires an LLM API key.',
53
+ add: 'Usage: nanomem add <text>\n\nAdd new facts from text. The LLM will create a new file or append to an existing one.\nAccepts quoted text or piped stdin.\nRequires an LLM API key.',
54
+ update: 'Usage: nanomem update <text>\n\nEdit existing facts from text. The LLM will only modify files that already exist — no new files are created.\nAccepts quoted text or piped stdin.\nRequires an LLM API key.',
52
55
  retrieve: 'Usage: nanomem retrieve <query> [--context <file>]\n\nRetrieve relevant memory context for a query.\nRequires an LLM API key.',
53
56
  compact: 'Usage: nanomem compact\n\nDeduplicate and archive stale facts across all memory files.\nRequires an LLM API key.',
54
57
  ls: 'Usage: nanomem ls [path]\n\nList files and directories in storage.',
package/src/cli/output.js CHANGED
@@ -129,6 +129,10 @@ function formatAction(result) {
129
129
  return section(green('✓ Facts extracted'), [
130
130
  ['Files updated', result.writeCalls],
131
131
  ]);
132
+ case 'deleted_content':
133
+ return result.deleteCalls > 0
134
+ ? section(green('✓ Memory updated'), [['Facts removed', result.deleteCalls]])
135
+ : dim('– Nothing matched');
132
136
  case 'skipped':
133
137
  return dim('– Nothing to extract (conversation too short)');
134
138
  case 'imported':
package/src/cli.js CHANGED
@@ -34,11 +34,13 @@ const OPTIONS = {
34
34
  'session-id': { type: 'string' },
35
35
  'session-title': { type: 'string' },
36
36
  'confirm': { type: 'boolean', default: false },
37
- 'render': { type: 'boolean', default: false },
37
+ 'render': { type: 'boolean', default: false },
38
+ 'deep': { type: 'boolean', default: false },
38
39
  };
39
40
 
40
41
  const COMMAND_MAP = {
41
42
  add: commands.add,
43
+ update: commands.update,
42
44
  login: commands.login,
43
45
  init: commands.init,
44
46
  retrieve: commands.retrieve,
@@ -95,7 +97,7 @@ async function main() {
95
97
  const memOpts = {};
96
98
 
97
99
  // Wire progress for import/extract — spinner per session with live tool call updates
98
- const isImport = commandName === 'import' || commandName === 'add' || commandName === 'extract';
100
+ const isImport = commandName === 'import' || commandName === 'add' || commandName === 'update' || commandName === 'extract' || commandName === 'delete';
99
101
  const showProgress = isImport && !values.json && process.stderr.isTTY;
100
102
  const spinnerHolder = { current: null }; // shared mutable ref between onToolCall and import loop
101
103
  if (showProgress) {
@@ -107,6 +109,7 @@ async function main() {
107
109
  delete_memory: 'cleaning up',
108
110
  read_file: 'reading',
109
111
  list_files: 'scanning',
112
+ delete_bullet: 'deleting',
110
113
  };
111
114
  memOpts.onToolCall = (name) => {
112
115
  const label = TOOL_LABELS[name] || name;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * MemoryDeleter — targeted bullet deletion via agentic tool-calling.
3
+ *
4
+ * Takes a plain-text query (e.g. "my job at Acme") and uses the LLM to find
5
+ * and delete only the matching bullets. Mirrors the retriever pattern but
6
+ * writes instead of reads.
7
+ *
8
+ * Two modes:
9
+ * default — LLM searches the index for relevant files, reads and deletes.
10
+ * deep — all files are enumerated upfront; LLM reads every one.
11
+ */
12
+ /** @import { LLMClient, StorageBackend } from '../types.js' */
13
+ import { runAgenticToolLoop } from './toolLoop.js';
14
+ import { createDeletionExecutors } from './executors.js';
15
+ import { resolvePromptSet } from '../prompt_sets/index.js';
16
+
17
+ /** Tools used in default (index-guided) delete mode. */
18
+ const DELETION_TOOLS = [
19
+ {
20
+ type: 'function',
21
+ function: {
22
+ name: 'list_directory',
23
+ description: 'List all files and subdirectories in a directory.',
24
+ parameters: {
25
+ type: 'object',
26
+ properties: {
27
+ dir_path: { type: 'string', description: 'Directory path (e.g. "health", "personal", "work"). Use empty string for root.' }
28
+ },
29
+ required: ['dir_path']
30
+ }
31
+ }
32
+ },
33
+ {
34
+ type: 'function',
35
+ function: {
36
+ name: 'retrieve_file',
37
+ description: 'Search memory files by keyword. Returns paths of files whose content or path matches the query.',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: {
41
+ query: { type: 'string', description: 'Keyword to search for in file contents.' }
42
+ },
43
+ required: ['query']
44
+ }
45
+ }
46
+ },
47
+ {
48
+ type: 'function',
49
+ function: {
50
+ name: 'read_file',
51
+ description: 'Read the full content of a memory file by its path.',
52
+ parameters: {
53
+ type: 'object',
54
+ properties: {
55
+ path: { type: 'string', description: 'File path to read (e.g. personal/about.md)' }
56
+ },
57
+ required: ['path']
58
+ }
59
+ }
60
+ },
61
+ {
62
+ type: 'function',
63
+ function: {
64
+ name: 'delete_bullet',
65
+ description: 'PERMANENTLY delete a specific bullet from a memory file. Use ONLY for bullets that are about the target subject. This cannot be undone. Pass the EXACT bullet text as it appears in the file, including all | metadata.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ path: { type: 'string', description: 'File path containing the bullet (e.g. personal/about.md)' },
70
+ bullet_text: { type: 'string', description: 'The EXACT text of the bullet to delete, as it appears in the file, including all | metadata.' }
71
+ },
72
+ required: ['path', 'bullet_text']
73
+ }
74
+ }
75
+ }
76
+ ];
77
+
78
+ /** Tools used in deep delete mode — no discovery tools needed since all paths are listed upfront. */
79
+ const DEEP_DELETION_TOOLS = DELETION_TOOLS.filter(t =>
80
+ ['read_file', 'delete_bullet'].includes(t.function.name)
81
+ );
82
+
83
+ export class MemoryDeleter {
84
+ /**
85
+ * @param {{ backend: StorageBackend, bulletIndex: object, llmClient: LLMClient, model: string, onToolCall?: Function }} options
86
+ */
87
+ constructor({ backend, bulletIndex, llmClient, model, onToolCall }) {
88
+ this._backend = backend;
89
+ this._bulletIndex = bulletIndex;
90
+ this._llmClient = llmClient;
91
+ this._model = model;
92
+ this._onToolCall = onToolCall || null;
93
+ }
94
+
95
+ /**
96
+ * Delete memory content matching the given query.
97
+ *
98
+ * @param {string} query Plain-text description of what to delete.
99
+ * @param {{ deep?: boolean, mode?: string }} [options]
100
+ * @returns {Promise<{ status: string, deleteCalls: number, writes: Array }>}
101
+ */
102
+ async deleteForQuery(query, options = {}) {
103
+ if (!query || !query.trim()) {
104
+ return { status: 'skipped', deleteCalls: 0, writes: [] };
105
+ }
106
+
107
+ const isDocument = options.mode === 'document';
108
+
109
+ return options.deep
110
+ ? this._deepDelete(query, isDocument)
111
+ : this._standardDelete(query, isDocument);
112
+ }
113
+
114
+ async _standardDelete(query, isDocument) {
115
+ await this._backend.init();
116
+ const index = await this._backend.getTree() || '';
117
+
118
+ const promptKey = isDocument ? 'document_delete' : 'delete';
119
+ const { ingestionPrompt } = resolvePromptSet(promptKey);
120
+ const systemPrompt = ingestionPrompt
121
+ .replace('{QUERY}', query)
122
+ .replace('{INDEX}', index);
123
+
124
+ return this._runDeletionLoop(query, systemPrompt, DELETION_TOOLS, 8);
125
+ }
126
+
127
+ async _deepDelete(query, isDocument) {
128
+ await this._backend.init();
129
+
130
+ const allFiles = await this._backend.exportAll();
131
+ const paths = allFiles
132
+ .map(f => f.path)
133
+ .filter(p => !p.endsWith('_tree.md'))
134
+ .sort();
135
+
136
+ if (paths.length === 0) {
137
+ return { status: 'skipped', deleteCalls: 0, writes: [] };
138
+ }
139
+
140
+ const fileList = paths.map(p => `- ${p}`).join('\n');
141
+
142
+ const promptKey = isDocument ? 'document_deep_delete' : 'deep_delete';
143
+ const { ingestionPrompt } = resolvePromptSet(promptKey);
144
+ const systemPrompt = ingestionPrompt
145
+ .replace('{QUERY}', query)
146
+ .replace('{FILE_LIST}', fileList);
147
+
148
+ // Each file needs a read + potentially multiple deletes; allow enough iterations.
149
+ const maxIterations = Math.max(30, paths.length * 3);
150
+
151
+ return this._runDeletionLoop(query, systemPrompt, DEEP_DELETION_TOOLS, maxIterations);
152
+ }
153
+
154
+ async _runDeletionLoop(query, systemPrompt, tools, maxIterations) {
155
+ const writes = [];
156
+ const onToolCall = this._onToolCall;
157
+
158
+ const toolExecutors = createDeletionExecutors(this._backend, {
159
+ refreshIndex: (path) => this._bulletIndex.refreshPath(path),
160
+ onWrite: (path, before, after) => writes.push({ path, before, after }),
161
+ });
162
+
163
+ try {
164
+ await runAgenticToolLoop({
165
+ llmClient: this._llmClient,
166
+ model: this._model,
167
+ tools,
168
+ toolExecutors,
169
+ messages: [
170
+ { role: 'system', content: systemPrompt },
171
+ { role: 'user', content: query }
172
+ ],
173
+ maxIterations,
174
+ maxOutputTokens: 2000,
175
+ temperature: 0,
176
+ onToolCall: (name, args, result) => {
177
+ onToolCall?.(name, args, result);
178
+ }
179
+ });
180
+ } catch (error) {
181
+ const message = error instanceof Error ? error.message : String(error);
182
+ return { status: 'error', deleteCalls: 0, writes: [], error: message };
183
+ }
184
+
185
+ return { status: 'processed', deleteCalls: writes.length, writes };
186
+ }
187
+ }