@openanonymity/nanomem 0.1.0
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/README.md +194 -0
- package/package.json +85 -0
- package/src/backends/BaseStorage.js +177 -0
- package/src/backends/filesystem.js +177 -0
- package/src/backends/indexeddb.js +208 -0
- package/src/backends/ram.js +113 -0
- package/src/backends/schema.js +42 -0
- package/src/bullets/bulletIndex.js +125 -0
- package/src/bullets/compaction.js +109 -0
- package/src/bullets/index.js +16 -0
- package/src/bullets/normalize.js +241 -0
- package/src/bullets/parser.js +199 -0
- package/src/bullets/scoring.js +53 -0
- package/src/cli/auth.js +323 -0
- package/src/cli/commands.js +411 -0
- package/src/cli/config.js +120 -0
- package/src/cli/diff.js +68 -0
- package/src/cli/help.js +84 -0
- package/src/cli/output.js +269 -0
- package/src/cli/spinner.js +54 -0
- package/src/cli.js +178 -0
- package/src/engine/compactor.js +247 -0
- package/src/engine/executors.js +152 -0
- package/src/engine/ingester.js +229 -0
- package/src/engine/retriever.js +414 -0
- package/src/engine/toolLoop.js +176 -0
- package/src/imports/chatgpt.js +160 -0
- package/src/imports/index.js +14 -0
- package/src/imports/markdown.js +104 -0
- package/src/imports/oaFastchat.js +124 -0
- package/src/index.js +199 -0
- package/src/llm/anthropic.js +264 -0
- package/src/llm/openai.js +179 -0
- package/src/prompt_sets/conversation/ingestion.js +51 -0
- package/src/prompt_sets/document/ingestion.js +43 -0
- package/src/prompt_sets/index.js +31 -0
- package/src/types.js +382 -0
- package/src/utils/portability.js +174 -0
- package/types/backends/BaseStorage.d.ts +42 -0
- package/types/backends/filesystem.d.ts +11 -0
- package/types/backends/indexeddb.d.ts +12 -0
- package/types/backends/ram.d.ts +8 -0
- package/types/backends/schema.d.ts +14 -0
- package/types/bullets/bulletIndex.d.ts +47 -0
- package/types/bullets/compaction.d.ts +10 -0
- package/types/bullets/index.d.ts +36 -0
- package/types/bullets/normalize.d.ts +95 -0
- package/types/bullets/parser.d.ts +31 -0
- package/types/bullets/scoring.d.ts +12 -0
- package/types/engine/compactor.d.ts +27 -0
- package/types/engine/executors.d.ts +46 -0
- package/types/engine/ingester.d.ts +29 -0
- package/types/engine/retriever.d.ts +50 -0
- package/types/engine/toolLoop.d.ts +9 -0
- package/types/imports/chatgpt.d.ts +14 -0
- package/types/imports/index.d.ts +3 -0
- package/types/imports/markdown.d.ts +31 -0
- package/types/imports/oaFastchat.d.ts +30 -0
- package/types/index.d.ts +21 -0
- package/types/llm/anthropic.d.ts +16 -0
- package/types/llm/openai.d.ts +16 -0
- package/types/prompt_sets/conversation/ingestion.d.ts +7 -0
- package/types/prompt_sets/document/ingestion.d.ts +7 -0
- package/types/prompt_sets/index.d.ts +11 -0
- package/types/types.d.ts +293 -0
- package/types/utils/portability.d.ts +33 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryCompactor — Periodic dedup + archive of stale facts.
|
|
3
|
+
*
|
|
4
|
+
* Two-phase compaction:
|
|
5
|
+
* 1. Deterministic — dedup, tier assignment, expiry (no LLM, cheap)
|
|
6
|
+
* 2. Semantic review — LLM pass on Working bullets only, to catch stale
|
|
7
|
+
* plans/goals that deterministic logic can't detect. Uses cross-file
|
|
8
|
+
* oneLiner summaries as context so inter-file resolutions are visible.
|
|
9
|
+
*
|
|
10
|
+
* Unstructured/legacy files use a full LLM rewrite (existing behaviour).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* - compactAll(): Force-compact all memory files immediately.
|
|
14
|
+
* - maybeCompact(): Only runs if >=6 hours have passed since last run.
|
|
15
|
+
*/
|
|
16
|
+
/** @import { LLMClient, StorageBackend } from '../types.js' */
|
|
17
|
+
import {
|
|
18
|
+
compactBullets,
|
|
19
|
+
inferTopicFromPath,
|
|
20
|
+
parseBullets,
|
|
21
|
+
todayIsoDate,
|
|
22
|
+
renderCompactedDocument
|
|
23
|
+
} from '../bullets/index.js';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
const MAX_FILE_CHARS = 8000;
|
|
27
|
+
|
|
28
|
+
// ─── Prompts ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const COMPACTION_PROMPT = `You are compacting a markdown memory file into a stable memory format.
|
|
31
|
+
|
|
32
|
+
Input is one memory file. Rewrite it into:
|
|
33
|
+
|
|
34
|
+
# Memory: <Topic>
|
|
35
|
+
|
|
36
|
+
## Working
|
|
37
|
+
### <Topic>
|
|
38
|
+
- fact | topic=<topic> | tier=working | status=active | source=user_statement|assistant_summary|inference|system | confidence=high|medium|low | updated_at=YYYY-MM-DD | review_at=YYYY-MM-DD(optional) | expires_at=YYYY-MM-DD(optional)
|
|
39
|
+
|
|
40
|
+
## Long-Term
|
|
41
|
+
### <Topic>
|
|
42
|
+
- fact | topic=<topic> | tier=long_term | status=active | source=user_statement|assistant_summary|inference|system | confidence=high|medium|low | updated_at=YYYY-MM-DD | expires_at=YYYY-MM-DD(optional)
|
|
43
|
+
|
|
44
|
+
## History
|
|
45
|
+
### <Topic>
|
|
46
|
+
- fact | topic=<topic> | tier=history | status=superseded|expired|uncertain | source=user_statement|assistant_summary|inference|system | confidence=high|medium|low | updated_at=YYYY-MM-DD | expires_at=YYYY-MM-DD(optional)
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
- Write facts in a timeless, archival format: use absolute dates (YYYY-MM-DD) rather than relative terms like "recently", "currently", "just", or "last week". A fact must be interpretable correctly even years after it was written.
|
|
50
|
+
- Keep only concrete reusable facts.
|
|
51
|
+
- Merge semantic duplicates and keep the most recent/best phrasing.
|
|
52
|
+
- Resolve contradictions: newer user statements beat older ones; user statements beat inferences; higher confidence beats lower.
|
|
53
|
+
- Put stable facts in Long-Term: identity/background, durable preferences, recurring constraints, persistent health facts, long-running roles, durable relationships.
|
|
54
|
+
- Put temporary or in-progress context in Working: active plans, current tasks, temporary situations, near-term goals.
|
|
55
|
+
- Expired facts (expires_at in the past) go to History with status=expired.
|
|
56
|
+
- Working facts should include review_at or expires_at when possible.
|
|
57
|
+
- Keep Working concise. Move stale/low-priority facts to History.
|
|
58
|
+
- Preserve meaning; do not invent facts.
|
|
59
|
+
- Output markdown only (no fences, no explanations).
|
|
60
|
+
|
|
61
|
+
Today: {TODAY}
|
|
62
|
+
Path: {PATH}
|
|
63
|
+
|
|
64
|
+
File content:
|
|
65
|
+
\`\`\`
|
|
66
|
+
{CONTENT}
|
|
67
|
+
\`\`\``;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Targeted semantic review of Working bullets only.
|
|
71
|
+
* Output is minimal (KEEP/SUPERSEDED per bullet) to keep cost low.
|
|
72
|
+
*/
|
|
73
|
+
const SEMANTIC_REVIEW_PROMPT = `Today is {TODAY}. Review these short-term (Working) memory bullets and identify which are stale, completed, or superseded.
|
|
74
|
+
|
|
75
|
+
{FILE_SUMMARIES_SECTION}{LONG_TERM_SECTION}Working bullets to review:
|
|
76
|
+
{NUMBERED_BULLETS}
|
|
77
|
+
|
|
78
|
+
For each numbered bullet, output exactly one line in the format:
|
|
79
|
+
N: KEEP
|
|
80
|
+
or
|
|
81
|
+
N: SUPERSEDED — brief reason
|
|
82
|
+
|
|
83
|
+
Output only these lines, one per bullet, nothing else.`;
|
|
84
|
+
|
|
85
|
+
// ─── Compactor ───────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
class MemoryCompactor {
|
|
88
|
+
constructor({ backend, bulletIndex, llmClient, model, onProgress }) {
|
|
89
|
+
this._backend = backend;
|
|
90
|
+
this._bulletIndex = bulletIndex;
|
|
91
|
+
this._llmClient = llmClient;
|
|
92
|
+
this._model = model;
|
|
93
|
+
this._onProgress = onProgress || null;
|
|
94
|
+
this._running = false;
|
|
95
|
+
this._fileSummaries = [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async compactAll() {
|
|
99
|
+
if (this._running) return;
|
|
100
|
+
this._running = true;
|
|
101
|
+
try {
|
|
102
|
+
await this._backend.init();
|
|
103
|
+
const allFiles = await this._backend.exportAll();
|
|
104
|
+
const realFiles = allFiles.filter((file) => !file.path.endsWith('_tree.md'));
|
|
105
|
+
|
|
106
|
+
// Collect one-liner summaries for cross-file context in semantic review.
|
|
107
|
+
this._fileSummaries = realFiles
|
|
108
|
+
.filter(f => f.oneLiner)
|
|
109
|
+
.map(f => ({ path: f.path, oneLiner: f.oneLiner }));
|
|
110
|
+
|
|
111
|
+
let filesChanged = 0;
|
|
112
|
+
const total = realFiles.length;
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < total; i++) {
|
|
115
|
+
const file = realFiles[i];
|
|
116
|
+
this._onProgress?.({ stage: 'file', file: file.path, current: i + 1, total });
|
|
117
|
+
|
|
118
|
+
const compacted = await this._compactFile(file.path, file.content || '');
|
|
119
|
+
if (!compacted) continue;
|
|
120
|
+
if (compacted.trim() === String(file.content || '').trim()) continue;
|
|
121
|
+
await this._backend.write(file.path, compacted);
|
|
122
|
+
await this._bulletIndex.refreshPath(file.path);
|
|
123
|
+
filesChanged++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { filesChanged, filesTotal: total };
|
|
127
|
+
|
|
128
|
+
} finally {
|
|
129
|
+
this._running = false;
|
|
130
|
+
this._fileSummaries = [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _compactFile(path, content) {
|
|
135
|
+
const raw = String(content || '').trim();
|
|
136
|
+
if (!raw) return null;
|
|
137
|
+
|
|
138
|
+
const parsed = parseBullets(raw);
|
|
139
|
+
|
|
140
|
+
// Unstructured/legacy files: full LLM rewrite (unchanged behaviour).
|
|
141
|
+
if (parsed.length === 0) {
|
|
142
|
+
return this._llmRewrite(path, raw);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Phase 1: deterministic dedup, tier assignment, expiry.
|
|
146
|
+
const defaultTopic = inferTopicFromPath(path);
|
|
147
|
+
const det = compactBullets(parsed, { defaultTopic });
|
|
148
|
+
const deduplicated = parsed.length - (det.working.length + det.longTerm.length + det.history.length);
|
|
149
|
+
const expired = det.history.filter(b => b.status === 'expired').length;
|
|
150
|
+
|
|
151
|
+
// Phase 2: semantic review of Working bullets.
|
|
152
|
+
// Only fires when Working bullets exist — skipped for stable long-term-only files.
|
|
153
|
+
let working = det.working;
|
|
154
|
+
let superseded = 0;
|
|
155
|
+
if (working.length > 0) {
|
|
156
|
+
this._onProgress?.({ stage: 'semantic', file: path });
|
|
157
|
+
working = await this._semanticReviewWorking(working, det.longTerm, path);
|
|
158
|
+
superseded = working.filter(b => b.status === 'superseded').length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Re-run deterministic compaction so newly-superseded bullets flow to History.
|
|
162
|
+
const allBullets = [...working, ...det.longTerm, ...det.history];
|
|
163
|
+
const final = compactBullets(allBullets, { defaultTopic });
|
|
164
|
+
|
|
165
|
+
this._onProgress?.({ stage: 'file_done', file: path, deduplicated, superseded, expired });
|
|
166
|
+
|
|
167
|
+
return renderCompactedDocument(
|
|
168
|
+
final.working, final.longTerm, final.history,
|
|
169
|
+
{ titleTopic: defaultTopic }
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Ask the LLM which Working bullets are now stale/completed/superseded.
|
|
175
|
+
* Returns the same array with superseded bullets marked status=superseded.
|
|
176
|
+
*/
|
|
177
|
+
async _semanticReviewWorking(working, longTerm, path) {
|
|
178
|
+
const numberedBullets = working
|
|
179
|
+
.map((b, i) => `${i + 1}: ${b.text}`)
|
|
180
|
+
.join('\n');
|
|
181
|
+
|
|
182
|
+
const longTermSection = longTerm.length > 0
|
|
183
|
+
? `Long-term facts in this file (for resolution context):\n${longTerm.map(b => `- ${b.text}`).join('\n')}\n\n`
|
|
184
|
+
: '';
|
|
185
|
+
|
|
186
|
+
// Cross-file context: exclude this file's own summary.
|
|
187
|
+
const otherFiles = this._fileSummaries.filter(f => f.path !== path);
|
|
188
|
+
const fileSummariesSection = otherFiles.length > 0
|
|
189
|
+
? `Other memory files:\n${otherFiles.map(f => `- ${f.path}: ${f.oneLiner}`).join('\n')}\n\n`
|
|
190
|
+
: '';
|
|
191
|
+
|
|
192
|
+
const prompt = SEMANTIC_REVIEW_PROMPT
|
|
193
|
+
.replace('{TODAY}', todayIsoDate())
|
|
194
|
+
.replace('{FILE_SUMMARIES_SECTION}', fileSummariesSection)
|
|
195
|
+
.replace('{LONG_TERM_SECTION}', longTermSection)
|
|
196
|
+
.replace('{NUMBERED_BULLETS}', numberedBullets);
|
|
197
|
+
|
|
198
|
+
let responseText = '';
|
|
199
|
+
try {
|
|
200
|
+
const response = await this._llmClient.createChatCompletion({
|
|
201
|
+
model: this._model,
|
|
202
|
+
messages: [{ role: 'user', content: prompt }],
|
|
203
|
+
max_tokens: 6000,
|
|
204
|
+
temperature: 0,
|
|
205
|
+
});
|
|
206
|
+
responseText = response.content || '';
|
|
207
|
+
} catch {
|
|
208
|
+
// On failure, leave Working bullets unchanged rather than silently corrupting data.
|
|
209
|
+
return working;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Parse "N: KEEP" / "N: SUPERSEDED — reason" lines.
|
|
213
|
+
const decisions = new Map();
|
|
214
|
+
for (const line of responseText.split('\n')) {
|
|
215
|
+
const match = line.match(/^(\d+)\s*:\s*(KEEP|SUPERSEDED)/i);
|
|
216
|
+
if (match) {
|
|
217
|
+
decisions.set(parseInt(match[1], 10), match[2].toUpperCase());
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return working.map((b, i) => {
|
|
222
|
+
if (decisions.get(i + 1) === 'SUPERSEDED') {
|
|
223
|
+
return { ...b, status: 'superseded', tier: 'history', section: 'history' };
|
|
224
|
+
}
|
|
225
|
+
return b;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async _llmRewrite(path, raw) {
|
|
230
|
+
const prompt = COMPACTION_PROMPT
|
|
231
|
+
.replace('{TODAY}', todayIsoDate())
|
|
232
|
+
.replace('{PATH}', path)
|
|
233
|
+
.replace('{CONTENT}', raw.length > MAX_FILE_CHARS ? raw.slice(0, MAX_FILE_CHARS) + '\n...(truncated)' : raw);
|
|
234
|
+
|
|
235
|
+
const response = await this._llmClient.createChatCompletion({
|
|
236
|
+
model: this._model,
|
|
237
|
+
messages: [{ role: 'user', content: prompt }],
|
|
238
|
+
max_tokens: 1800,
|
|
239
|
+
temperature: 0,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const text = (response.content || '').trim();
|
|
243
|
+
return text || null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export { MemoryCompactor };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool executor factories — the bridge between the agentic tool loop and storage.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* retrieval.js / extractor.js — define tool schemas (what the LLM sees)
|
|
6
|
+
* executors.js (this file) — implement those tools (what runs when called)
|
|
7
|
+
* toolLoop.js — generic engine that connects the two
|
|
8
|
+
*
|
|
9
|
+
* Each factory takes a storage backend and returns an object mapping
|
|
10
|
+
* tool names to async functions: { tool_name: async (args) => resultString }
|
|
11
|
+
*/
|
|
12
|
+
/** @import { ExtractionExecutorHooks, StorageBackend } from '../types.js' */
|
|
13
|
+
import {
|
|
14
|
+
compactBullets,
|
|
15
|
+
inferTopicFromPath,
|
|
16
|
+
normalizeFactText,
|
|
17
|
+
parseBullets,
|
|
18
|
+
renderCompactedDocument,
|
|
19
|
+
} from '../bullets/index.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build tool executors for the retrieval (read) flow.
|
|
23
|
+
* @param {StorageBackend} backend
|
|
24
|
+
*/
|
|
25
|
+
export function createRetrievalExecutors(backend) {
|
|
26
|
+
return {
|
|
27
|
+
list_directory: async ({ dir_path }) => {
|
|
28
|
+
const { files, dirs } = await backend.ls(dir_path || '');
|
|
29
|
+
return JSON.stringify({ files, dirs });
|
|
30
|
+
},
|
|
31
|
+
retrieve_file: async ({ query }) => {
|
|
32
|
+
const results = await backend.search(query);
|
|
33
|
+
const contentPaths = results.map(r => r.path);
|
|
34
|
+
|
|
35
|
+
const allFiles = await backend.exportAll();
|
|
36
|
+
const queryLower = query.toLowerCase();
|
|
37
|
+
const pathMatches = allFiles
|
|
38
|
+
.filter(f => !f.path.endsWith('_tree.md') && f.path.toLowerCase().includes(queryLower))
|
|
39
|
+
.map(f => f.path);
|
|
40
|
+
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const paths = [];
|
|
43
|
+
for (const p of [...pathMatches, ...contentPaths]) {
|
|
44
|
+
if (!seen.has(p)) { seen.add(p); paths.push(p); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return JSON.stringify({ paths: paths.slice(0, 5), count: Math.min(paths.length, 5) });
|
|
48
|
+
},
|
|
49
|
+
read_file: async ({ path }) => {
|
|
50
|
+
const content = await backend.read(path);
|
|
51
|
+
if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
|
|
52
|
+
return content.length > 1500 ? content.slice(0, 1500) + '...(truncated)' : content;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build tool executors for the extraction (write) flow.
|
|
59
|
+
* @param {StorageBackend} backend
|
|
60
|
+
* @param {ExtractionExecutorHooks} [hooks]
|
|
61
|
+
*/
|
|
62
|
+
export function createExtractionExecutors(backend, hooks = {}) {
|
|
63
|
+
const { normalizeContent, mergeWithExisting, refreshIndex, onWrite } = hooks;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
read_file: async ({ path }) => {
|
|
67
|
+
const content = await backend.read(path);
|
|
68
|
+
if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
|
|
69
|
+
return content.length > 2000 ? content.slice(0, 2000) + '...(truncated)' : content;
|
|
70
|
+
},
|
|
71
|
+
create_new_file: async ({ path, content }) => {
|
|
72
|
+
const exists = await backend.exists(path);
|
|
73
|
+
if (exists) return JSON.stringify({ error: `File already exists: ${path}. Use append_memory or update_memory instead.` });
|
|
74
|
+
const normalized = normalizeContent ? normalizeContent(content, path) : content;
|
|
75
|
+
await backend.write(path, normalized);
|
|
76
|
+
if (refreshIndex) await refreshIndex(path);
|
|
77
|
+
onWrite?.(path, '', normalized);
|
|
78
|
+
return JSON.stringify({ success: true, path });
|
|
79
|
+
},
|
|
80
|
+
append_memory: async ({ path, content }) => {
|
|
81
|
+
const existing = await backend.read(path);
|
|
82
|
+
const newContent = mergeWithExisting
|
|
83
|
+
? mergeWithExisting(existing, content, path)
|
|
84
|
+
: (existing ? existing + '\n\n' + content : content);
|
|
85
|
+
await backend.write(path, newContent);
|
|
86
|
+
if (refreshIndex) await refreshIndex(path);
|
|
87
|
+
onWrite?.(path, existing ?? '', newContent);
|
|
88
|
+
return JSON.stringify({ success: true, path, action: 'appended' });
|
|
89
|
+
},
|
|
90
|
+
update_memory: async ({ path, content }) => {
|
|
91
|
+
const before = await backend.read(path);
|
|
92
|
+
const normalized = normalizeContent ? normalizeContent(content, path) : content;
|
|
93
|
+
await backend.write(path, normalized);
|
|
94
|
+
if (refreshIndex) await refreshIndex(path);
|
|
95
|
+
onWrite?.(path, before ?? '', normalized);
|
|
96
|
+
return JSON.stringify({ success: true, path, action: 'updated' });
|
|
97
|
+
},
|
|
98
|
+
archive_memory: async ({ path, item_text }) => {
|
|
99
|
+
const existing = await backend.read(path);
|
|
100
|
+
if (!existing) return JSON.stringify({ error: `File not found: ${path}` });
|
|
101
|
+
const newContent = removeArchivedItem(existing, item_text, path);
|
|
102
|
+
if (newContent === null) {
|
|
103
|
+
return JSON.stringify({ error: `Could not find an exact memory item match in: ${path}` });
|
|
104
|
+
}
|
|
105
|
+
await backend.write(path, newContent);
|
|
106
|
+
if (refreshIndex) await refreshIndex(path);
|
|
107
|
+
return JSON.stringify({ success: true, path, action: 'archived', removed: item_text });
|
|
108
|
+
},
|
|
109
|
+
delete_memory: async ({ path }) => {
|
|
110
|
+
if (path.endsWith('_tree.md')) {
|
|
111
|
+
return JSON.stringify({ error: 'Cannot delete index files' });
|
|
112
|
+
}
|
|
113
|
+
await backend.delete(path);
|
|
114
|
+
if (refreshIndex) await refreshIndex(path);
|
|
115
|
+
return JSON.stringify({ success: true, path, action: 'deleted' });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function removeArchivedItem(content, itemText, path) {
|
|
121
|
+
const raw = String(content || '');
|
|
122
|
+
const target = normalizeFactText(itemText);
|
|
123
|
+
if (!target) return null;
|
|
124
|
+
|
|
125
|
+
const parsed = parseBullets(raw);
|
|
126
|
+
if (parsed.length > 0) {
|
|
127
|
+
const remaining = parsed.filter((bullet) => normalizeFactText(bullet.text) !== target);
|
|
128
|
+
if (remaining.length === parsed.length) return null;
|
|
129
|
+
const compacted = compactBullets(remaining, { defaultTopic: inferTopicFromPath(path), maxActivePerTopic: 1000 });
|
|
130
|
+
return renderCompactedDocument(
|
|
131
|
+
compacted.working, compacted.longTerm, compacted.history,
|
|
132
|
+
{ titleTopic: inferTopicFromPath(path) }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lines = raw.split('\n');
|
|
137
|
+
let removed = false;
|
|
138
|
+
const filtered = lines.filter((line) => {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
const normalized = trimmed.startsWith('- ')
|
|
141
|
+
? normalizeFactText(trimmed.slice(2))
|
|
142
|
+
: normalizeFactText(trimmed);
|
|
143
|
+
if (!removed && normalized === target) {
|
|
144
|
+
removed = true;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!removed) return null;
|
|
151
|
+
return filtered.join('\n').trim();
|
|
152
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryIngester — Write path for agentic memory.
|
|
3
|
+
*
|
|
4
|
+
* Takes a conversation (array of messages) and uses tool-calling via the
|
|
5
|
+
* agentic loop to decide whether to create/append/update memory files.
|
|
6
|
+
*/
|
|
7
|
+
/** @import { IngestOptions, IngestResult, LLMClient, Message, StorageBackend, ToolDefinition } from '../types.js' */
|
|
8
|
+
import { runAgenticToolLoop } from './toolLoop.js';
|
|
9
|
+
import { createExtractionExecutors } from './executors.js';
|
|
10
|
+
import { resolvePromptSet } from '../prompt_sets/index.js';
|
|
11
|
+
import {
|
|
12
|
+
compactBullets,
|
|
13
|
+
ensureBulletMetadata,
|
|
14
|
+
inferTopicFromPath,
|
|
15
|
+
parseBullets,
|
|
16
|
+
renderCompactedDocument,
|
|
17
|
+
todayIsoDate
|
|
18
|
+
} from '../bullets/index.js';
|
|
19
|
+
|
|
20
|
+
const MAX_CONVERSATION_CHARS = 128000;
|
|
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
|
+
}
|
|
36
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
66
|
+
}
|
|
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
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
class MemoryIngester {
|
|
86
|
+
constructor({ backend, bulletIndex, llmClient, model, onToolCall }) {
|
|
87
|
+
this._backend = backend;
|
|
88
|
+
this._bulletIndex = bulletIndex;
|
|
89
|
+
this._llmClient = llmClient;
|
|
90
|
+
this._model = model;
|
|
91
|
+
this._onToolCall = onToolCall || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Ingest memory from a conversation.
|
|
96
|
+
*
|
|
97
|
+
* @param {Message[]} messages
|
|
98
|
+
* @param {IngestOptions} [options]
|
|
99
|
+
* @returns {Promise<IngestResult>}
|
|
100
|
+
*/
|
|
101
|
+
async ingest(messages, options = {}) {
|
|
102
|
+
const updatedAt = options.updatedAt || todayIsoDate();
|
|
103
|
+
const onToolCall = this._onToolCall;
|
|
104
|
+
if (!messages || messages.length === 0) return { status: 'skipped', writeCalls: 0 };
|
|
105
|
+
|
|
106
|
+
// Support both `mode` and legacy `extractionMode`
|
|
107
|
+
const mode = options.mode || options.extractionMode || 'conversation';
|
|
108
|
+
const isDocument = mode === 'document';
|
|
109
|
+
const conversationText = isDocument
|
|
110
|
+
? this._buildDocumentText(messages)
|
|
111
|
+
: this._buildConversationText(messages);
|
|
112
|
+
if (!conversationText) return { status: 'skipped', writeCalls: 0 };
|
|
113
|
+
|
|
114
|
+
await this._backend.init();
|
|
115
|
+
const index = await this._backend.getTree() || '';
|
|
116
|
+
|
|
117
|
+
const { ingestionPrompt } = resolvePromptSet(mode);
|
|
118
|
+
const systemPrompt = ingestionPrompt.replace('{INDEX}', index);
|
|
119
|
+
const writes = [];
|
|
120
|
+
const toolExecutors = createExtractionExecutors(this._backend, {
|
|
121
|
+
normalizeContent: (content, path) => this._normalizeGeneratedContent(content, path, updatedAt, isDocument),
|
|
122
|
+
mergeWithExisting: (existing, incoming, path) => this._mergeWithExisting(existing, incoming, path, updatedAt, isDocument),
|
|
123
|
+
refreshIndex: (path) => this._bulletIndex.refreshPath(path),
|
|
124
|
+
onWrite: (path, before, after) => writes.push({ path, before, after }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const userMessage = isDocument
|
|
128
|
+
? `Document content:\n\`\`\`\n${conversationText}\n\`\`\``
|
|
129
|
+
: `Conversation:\n\`\`\`\n${conversationText}\n\`\`\``;
|
|
130
|
+
|
|
131
|
+
let toolCallLog;
|
|
132
|
+
try {
|
|
133
|
+
const result = await runAgenticToolLoop({
|
|
134
|
+
llmClient: this._llmClient,
|
|
135
|
+
model: this._model,
|
|
136
|
+
tools: EXTRACTION_TOOLS,
|
|
137
|
+
toolExecutors,
|
|
138
|
+
messages: [
|
|
139
|
+
{ role: 'system', content: systemPrompt },
|
|
140
|
+
{ role: 'user', content: userMessage }
|
|
141
|
+
],
|
|
142
|
+
maxIterations: 12,
|
|
143
|
+
maxOutputTokens: 4000,
|
|
144
|
+
temperature: 0,
|
|
145
|
+
onToolCall: (name, args, result) => {
|
|
146
|
+
onToolCall?.(name, args, result);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
toolCallLog = result.toolCallLog;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
return { status: 'error', writeCalls: 0, error: message };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const writeTools = ['create_new_file', 'append_memory', 'update_memory', 'archive_memory', 'delete_memory'];
|
|
156
|
+
const writeCalls = toolCallLog.filter(e => writeTools.includes(e.name));
|
|
157
|
+
|
|
158
|
+
return { status: 'processed', writeCalls: writeCalls.length, writes };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_buildConversationText(messages) {
|
|
162
|
+
let text = '';
|
|
163
|
+
for (const msg of messages) {
|
|
164
|
+
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
|
165
|
+
const content = msg.content || '';
|
|
166
|
+
text += `${role}: ${content}\n\n`;
|
|
167
|
+
if (text.length > MAX_CONVERSATION_CHARS) break;
|
|
168
|
+
}
|
|
169
|
+
return text.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_buildDocumentText(messages) {
|
|
173
|
+
// For documents, concatenate content blocks without role labels.
|
|
174
|
+
// Multiple messages are treated as sections of the same document.
|
|
175
|
+
return messages
|
|
176
|
+
.map(m => (m.content || '').trim())
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join('\n\n')
|
|
179
|
+
.slice(0, MAX_CONVERSATION_CHARS);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_normalizeGeneratedContent(content, path, updatedAt, isDocument = false) {
|
|
183
|
+
const incomingBullets = parseBullets(content);
|
|
184
|
+
if (incomingBullets.length === 0) {
|
|
185
|
+
return content;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const defaultTopic = inferTopicFromPath(path);
|
|
189
|
+
const normalized = incomingBullets.map((bullet) => {
|
|
190
|
+
const b = ensureBulletMetadata({ ...bullet, updatedAt: null }, { defaultTopic, updatedAt });
|
|
191
|
+
if (isDocument && b.source === 'user_statement') b.source = 'document';
|
|
192
|
+
return b;
|
|
193
|
+
});
|
|
194
|
+
const compacted = compactBullets(normalized, { defaultTopic, maxActivePerTopic: 1000 });
|
|
195
|
+
return renderCompactedDocument(compacted.working, compacted.longTerm, compacted.history, { titleTopic: defaultTopic });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_mergeWithExisting(existing, incoming, path, updatedAt, isDocument = false) {
|
|
199
|
+
const existingText = String(existing || '');
|
|
200
|
+
const incomingText = String(incoming || '');
|
|
201
|
+
const defaultTopic = inferTopicFromPath(path);
|
|
202
|
+
|
|
203
|
+
const existingBullets = parseBullets(existingText)
|
|
204
|
+
.map((bullet) => ensureBulletMetadata(bullet, { defaultTopic }));
|
|
205
|
+
const incomingBullets = parseBullets(incomingText)
|
|
206
|
+
.map((bullet) => {
|
|
207
|
+
const b = ensureBulletMetadata({ ...bullet, updatedAt: null }, { defaultTopic, updatedAt });
|
|
208
|
+
if (isDocument && b.source === 'user_statement') b.source = 'document';
|
|
209
|
+
return b;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (incomingBullets.length === 0) {
|
|
213
|
+
return existingText
|
|
214
|
+
? `${existingText}\n\n${incomingText}`
|
|
215
|
+
: incomingText;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (existingBullets.length === 0) {
|
|
219
|
+
const compacted = compactBullets(incomingBullets, { defaultTopic, maxActivePerTopic: 1000 });
|
|
220
|
+
return renderCompactedDocument(compacted.working, compacted.longTerm, compacted.history, { titleTopic: defaultTopic });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const merged = [...existingBullets, ...incomingBullets];
|
|
224
|
+
const compacted = compactBullets(merged, { defaultTopic, maxActivePerTopic: 1000 });
|
|
225
|
+
return renderCompactedDocument(compacted.working, compacted.longTerm, compacted.history, { titleTopic: defaultTopic });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { MemoryIngester };
|