@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.
- package/LICENSE +21 -0
- package/README.md +46 -8
- package/package.json +7 -3
- package/src/backends/BaseStorage.js +147 -3
- package/src/backends/indexeddb.js +21 -8
- package/src/browser.js +227 -0
- package/src/cli/auth.js +1 -1
- package/src/cli/commands.js +51 -8
- package/src/cli/config.js +1 -1
- package/src/cli/help.js +5 -2
- package/src/cli/output.js +4 -0
- package/src/cli.js +5 -2
- package/src/engine/deleter.js +187 -0
- package/src/engine/executors.js +416 -4
- package/src/engine/ingester.js +83 -61
- package/src/engine/recentConversation.js +110 -0
- package/src/engine/retriever.js +238 -36
- package/src/engine/toolLoop.js +51 -9
- package/src/imports/importData.js +454 -0
- package/src/imports/index.js +5 -0
- package/src/index.js +95 -2
- package/src/llm/openai.js +204 -58
- package/src/llm/tinfoil.js +508 -0
- package/src/omf.js +343 -0
- package/src/prompt_sets/conversation/ingestion.js +101 -11
- package/src/prompt_sets/document/ingestion.js +92 -4
- package/src/prompt_sets/index.js +12 -4
- package/src/types.js +133 -3
- package/src/vendor/tinfoil.browser.d.ts +2 -0
- package/src/vendor/tinfoil.browser.js +41596 -0
- package/types/backends/BaseStorage.d.ts +19 -0
- package/types/backends/indexeddb.d.ts +1 -0
- package/types/browser.d.ts +17 -0
- package/types/engine/deleter.d.ts +67 -0
- package/types/engine/executors.d.ts +54 -0
- package/types/engine/recentConversation.d.ts +18 -0
- package/types/engine/retriever.d.ts +22 -9
- package/types/imports/importData.d.ts +29 -0
- package/types/imports/index.d.ts +1 -0
- package/types/index.d.ts +9 -0
- package/types/llm/openai.d.ts +6 -9
- package/types/llm/tinfoil.d.ts +13 -0
- package/types/omf.d.ts +40 -0
- package/types/prompt_sets/conversation/ingestion.d.ts +8 -3
- package/types/prompt_sets/document/ingestion.d.ts +8 -3
- package/types/types.d.ts +125 -2
- package/types/vendor/tinfoil.browser.d.ts +6348 -0
package/src/cli/commands.js
CHANGED
|
@@ -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:
|
|
164
|
+
if (!input) throw new Error('Usage: nanomem add <text>');
|
|
165
165
|
|
|
166
166
|
const conversations = parseConversations(input, flags);
|
|
167
|
-
return ingestConversations(conversations, '
|
|
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
|
|
264
|
-
if (!
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
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
|
|
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':
|
|
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
|
+
}
|