codemini-cli 0.5.10 → 0.5.12
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-B-G99D0A.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-DIGUEzan.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-va2Kl89u.js +1 -0
- package/codemini-web/dist/index.html +35 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +2 -2
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +286 -285
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5173 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
package/src/core/memory-store.js
CHANGED
|
@@ -1,543 +1,543 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { sha256 } from './crypto-utils.js';
|
|
4
|
-
import { getMemoryDir, getProjectMemoryDir, getInboxDir, getArchiveDir } from './paths.js';
|
|
5
|
-
import { assertSafeMemoryContent, normalizeMemoryText, summarizeMemoryContent } from './memory-policy.js';
|
|
6
|
-
|
|
7
|
-
const ALLOWED_SCOPES = new Set(['user', 'global', 'project']);
|
|
8
|
-
|
|
9
|
-
function nowIso() {
|
|
10
|
-
return new Date().toISOString();
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function slugify(value) {
|
|
14
|
-
const text = String(value || '')
|
|
15
|
-
.toLowerCase()
|
|
16
|
-
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
|
17
|
-
.replace(/^-+|-+$/g, '');
|
|
18
|
-
return text || 'project';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function getProjectMemoryKey(workspaceRoot = process.cwd(), projectAlias = '') {
|
|
22
|
-
const alias = normalizeMemoryText(projectAlias);
|
|
23
|
-
if (alias) return slugify(alias);
|
|
24
|
-
const root = path.resolve(workspaceRoot || process.cwd());
|
|
25
|
-
const base = path.basename(root);
|
|
26
|
-
return `${slugify(base)}-${sha256(root).slice(0, 10)}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function ensureScope(scope) {
|
|
30
|
-
const value = String(scope || '').trim().toLowerCase();
|
|
31
|
-
if (!ALLOWED_SCOPES.has(value)) {
|
|
32
|
-
throw new Error(`Unsupported memory scope: ${scope}`);
|
|
33
|
-
}
|
|
34
|
-
return value;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function ensureParent(filePath) {
|
|
38
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function buildFilePath(scope, workspaceRoot = process.cwd(), projectAlias = '') {
|
|
42
|
-
if (scope === 'user') return path.join(getMemoryDir(), 'user.json');
|
|
43
|
-
if (scope === 'global') return path.join(getMemoryDir(), 'global.json');
|
|
44
|
-
return path.join(getProjectMemoryDir(workspaceRoot), 'project.json');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function listProjectMemoryFiles(workspaceRoot = process.cwd()) {
|
|
48
|
-
const dir = getProjectMemoryDir(workspaceRoot);
|
|
49
|
-
try {
|
|
50
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
51
|
-
return entries
|
|
52
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
53
|
-
.map((entry) => path.join(dir, entry.name))
|
|
54
|
-
.sort();
|
|
55
|
-
} catch {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function readMemoryBucket(filePath) {
|
|
61
|
-
const doc = await readMemoryBucketDocument(filePath);
|
|
62
|
-
return doc.items;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function readMemoryBucketDocument(filePath) {
|
|
66
|
-
try {
|
|
67
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
68
|
-
const parsed = JSON.parse(raw);
|
|
69
|
-
return {
|
|
70
|
-
items: Array.isArray(parsed?.items) ? parsed.items : [],
|
|
71
|
-
maintenance: parsed?.maintenance && typeof parsed.maintenance === 'object' ? parsed.maintenance : null
|
|
72
|
-
};
|
|
73
|
-
} catch {
|
|
74
|
-
return { items: [], maintenance: null };
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function memoryBucketHash(items = []) {
|
|
79
|
-
const stable = (Array.isArray(items) ? items : [])
|
|
80
|
-
.map((item) => ({
|
|
81
|
-
id: String(item?.id || ''),
|
|
82
|
-
kind: String(item?.kind || ''),
|
|
83
|
-
content: normalizeMemoryText(item?.content || ''),
|
|
84
|
-
summary: normalizeMemoryText(item?.summary || ''),
|
|
85
|
-
lifecycle: String(item?.lifecycle || ''),
|
|
86
|
-
pinned: item?.pinned === true
|
|
87
|
-
}))
|
|
88
|
-
.sort((left, right) => left.id.localeCompare(right.id));
|
|
89
|
-
return sha256(JSON.stringify(stable));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function writeMemoryBucket(filePath, items, { maintenance = null } = {}) {
|
|
93
|
-
await ensureParent(filePath);
|
|
94
|
-
const doc = { items };
|
|
95
|
-
if (maintenance) doc.maintenance = maintenance;
|
|
96
|
-
await fs.writeFile(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function dedupeMemoryItems(items = []) {
|
|
100
|
-
const deduped = [];
|
|
101
|
-
const seen = new Set();
|
|
102
|
-
for (const item of items) {
|
|
103
|
-
const key = item.id ? `id:${item.id}` : `${item.kind}:${normalizeMemoryText(item.content)}`;
|
|
104
|
-
if (seen.has(key)) continue;
|
|
105
|
-
seen.add(key);
|
|
106
|
-
deduped.push(item);
|
|
107
|
-
}
|
|
108
|
-
return deduped;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function readProjectMemoryItems(workspaceRoot = process.cwd(), projectAlias = '') {
|
|
112
|
-
const projectKey = getProjectMemoryKey(workspaceRoot, projectAlias);
|
|
113
|
-
const files = await listProjectMemoryFiles(workspaceRoot);
|
|
114
|
-
const items = [];
|
|
115
|
-
for (const file of files) {
|
|
116
|
-
const bucket = await readMemoryBucket(file);
|
|
117
|
-
items.push(...bucket.map((item) => normalizeMemoryItem(item, 'project', projectKey)));
|
|
118
|
-
}
|
|
119
|
-
return dedupeMemoryItems(items)
|
|
120
|
-
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function readScopeMemoryItems(scope, workspaceRoot = process.cwd(), projectAlias = '') {
|
|
124
|
-
const normalizedScope = ensureScope(scope);
|
|
125
|
-
if (normalizedScope === 'project') return readProjectMemoryItems(workspaceRoot, projectAlias);
|
|
126
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
127
|
-
return (await readMemoryBucket(filePath))
|
|
128
|
-
.map((item) => normalizeMemoryItem(item, normalizedScope, ''))
|
|
129
|
-
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function normalizeMemoryItem(item, scope, projectKey = '') {
|
|
133
|
-
const now = nowIso();
|
|
134
|
-
const content = normalizeMemoryText(item?.content || '');
|
|
135
|
-
return {
|
|
136
|
-
id: String(item?.id || `mem_${sha256(`${scope}:${projectKey}:${content}:${now}:${Math.random()}`).slice(0, 12)}`),
|
|
137
|
-
scope,
|
|
138
|
-
projectKey: projectKey || undefined,
|
|
139
|
-
kind: String(item?.kind || 'note').trim() || 'note',
|
|
140
|
-
content,
|
|
141
|
-
summary: normalizeMemoryText(item?.summary || summarizeMemoryContent(content)),
|
|
142
|
-
source: String(item?.source || 'tool').trim() || 'tool',
|
|
143
|
-
confidence: Number.isFinite(Number(item?.confidence)) ? Number(item.confidence) : 0.9,
|
|
144
|
-
createdAt: String(item?.createdAt || now),
|
|
145
|
-
updatedAt: String(item?.updatedAt || now),
|
|
146
|
-
hits: Number.isFinite(Number(item?.hits)) ? Number(item.hits) : 0,
|
|
147
|
-
pinned: item?.pinned === true,
|
|
148
|
-
...(item?.lifecycle ? { lifecycle: String(item.lifecycle) } : {})
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function sameMemory(left, right) {
|
|
153
|
-
const a = normalizeMemoryText(left?.content);
|
|
154
|
-
const b = normalizeMemoryText(right?.content);
|
|
155
|
-
if (!a || !b) return false;
|
|
156
|
-
return a === b;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function measureMemoryChars(item) {
|
|
160
|
-
return normalizeMemoryText(item?.content).length + normalizeMemoryText(item?.summary).length;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function budgetForScope(scope, config = {}) {
|
|
164
|
-
if (scope === 'user') return Math.max(80, Number(config?.memory?.max_user_chars || 1375));
|
|
165
|
-
if (scope === 'global') return Math.max(80, Number(config?.memory?.max_global_chars || 2200));
|
|
166
|
-
return Math.max(80, Number(config?.memory?.max_project_chars || 2200));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export async function listMemories({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
170
|
-
const normalizedScope = ensureScope(scope);
|
|
171
|
-
return readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export async function getMemoryBucketMaintenance({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
175
|
-
const normalizedScope = ensureScope(scope);
|
|
176
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
177
|
-
const doc = await readMemoryBucketDocument(filePath);
|
|
178
|
-
const items = normalizedScope === 'project'
|
|
179
|
-
? await readProjectMemoryItems(workspaceRoot, projectAlias)
|
|
180
|
-
: doc.items.map((item) => normalizeMemoryItem(item, normalizedScope, ''));
|
|
181
|
-
const currentHash = memoryBucketHash(items);
|
|
182
|
-
const storedHash = String(doc.maintenance?.contentHash || '');
|
|
183
|
-
const maintainedAt = String(doc.maintenance?.maintainedAt || '');
|
|
184
|
-
return {
|
|
185
|
-
scope: normalizedScope,
|
|
186
|
-
itemCount: items.length,
|
|
187
|
-
contentHash: currentHash,
|
|
188
|
-
storedHash,
|
|
189
|
-
maintainedAt,
|
|
190
|
-
fresh: Boolean(maintainedAt && storedHash && storedHash === currentHash)
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export async function markMemoryBucketMaintained({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
195
|
-
const normalizedScope = ensureScope(scope);
|
|
196
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
197
|
-
const items = await readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
198
|
-
const maintenance = {
|
|
199
|
-
maintainedAt: nowIso(),
|
|
200
|
-
contentHash: memoryBucketHash(items),
|
|
201
|
-
itemCount: items.length
|
|
202
|
-
};
|
|
203
|
-
await writeMemoryBucket(filePath, items, { maintenance });
|
|
204
|
-
return { scope: normalizedScope, ...maintenance };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export async function replaceMemoryBucket({
|
|
208
|
-
scope,
|
|
209
|
-
items = [],
|
|
210
|
-
workspaceRoot = process.cwd(),
|
|
211
|
-
projectAlias = '',
|
|
212
|
-
markMaintained = false
|
|
213
|
-
} = {}) {
|
|
214
|
-
const normalizedScope = ensureScope(scope);
|
|
215
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
216
|
-
const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
217
|
-
const normalizedItems = (Array.isArray(items) ? items : [])
|
|
218
|
-
.map((item) => normalizeMemoryItem(item, normalizedScope, projectKey))
|
|
219
|
-
.filter((item) => item.content);
|
|
220
|
-
const maintenance = markMaintained
|
|
221
|
-
? {
|
|
222
|
-
maintainedAt: nowIso(),
|
|
223
|
-
contentHash: memoryBucketHash(normalizedItems),
|
|
224
|
-
itemCount: normalizedItems.length
|
|
225
|
-
}
|
|
226
|
-
: null;
|
|
227
|
-
await writeMemoryBucket(filePath, normalizedItems, { maintenance });
|
|
228
|
-
return {
|
|
229
|
-
scope: normalizedScope,
|
|
230
|
-
items: normalizedItems,
|
|
231
|
-
maintenance
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function rememberMemory({
|
|
236
|
-
scope,
|
|
237
|
-
content,
|
|
238
|
-
kind = 'note',
|
|
239
|
-
summary = '',
|
|
240
|
-
source = 'tool',
|
|
241
|
-
confidence = 0.9,
|
|
242
|
-
replaceSimilar = true,
|
|
243
|
-
pinned = false,
|
|
244
|
-
workspaceRoot = process.cwd(),
|
|
245
|
-
projectAlias = '',
|
|
246
|
-
config = {}
|
|
247
|
-
}) {
|
|
248
|
-
const normalizedScope = ensureScope(scope);
|
|
249
|
-
const normalizedContent = normalizeMemoryText(content);
|
|
250
|
-
if (!normalizedContent) throw new Error('Memory content is required');
|
|
251
|
-
assertSafeMemoryContent(normalizedContent);
|
|
252
|
-
|
|
253
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
254
|
-
const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
255
|
-
const existing = await readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
256
|
-
const probe = normalizeMemoryItem({ content: normalizedContent, kind, summary, source, confidence, pinned }, normalizedScope, projectKey);
|
|
257
|
-
|
|
258
|
-
const replaceIndex = replaceSimilar ? existing.findIndex((item) => sameMemory(item, probe)) : -1;
|
|
259
|
-
let saved;
|
|
260
|
-
if (replaceIndex >= 0) {
|
|
261
|
-
saved = {
|
|
262
|
-
...existing[replaceIndex],
|
|
263
|
-
...probe,
|
|
264
|
-
id: existing[replaceIndex].id,
|
|
265
|
-
createdAt: existing[replaceIndex].createdAt,
|
|
266
|
-
updatedAt: nowIso()
|
|
267
|
-
};
|
|
268
|
-
existing.splice(replaceIndex, 1, saved);
|
|
269
|
-
} else {
|
|
270
|
-
saved = probe;
|
|
271
|
-
existing.unshift(saved);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const maxItems = Math.max(1, Number(config?.memory?.max_items_per_scope || 12));
|
|
275
|
-
const maxChars = budgetForScope(normalizedScope, config);
|
|
276
|
-
const deduped = [];
|
|
277
|
-
const seen = new Set();
|
|
278
|
-
for (const item of existing) {
|
|
279
|
-
const key = `${item.kind}:${normalizeMemoryText(item.content)}`;
|
|
280
|
-
if (seen.has(key)) continue;
|
|
281
|
-
seen.add(key);
|
|
282
|
-
deduped.push(item);
|
|
283
|
-
if (deduped.length >= maxItems) break;
|
|
284
|
-
}
|
|
285
|
-
let totalChars = deduped.reduce((sum, item) => sum + measureMemoryChars(item), 0);
|
|
286
|
-
while (deduped.length > 1 && totalChars > maxChars) {
|
|
287
|
-
const removed = deduped.pop();
|
|
288
|
-
totalChars -= measureMemoryChars(removed);
|
|
289
|
-
}
|
|
290
|
-
await writeMemoryBucket(filePath, deduped);
|
|
291
|
-
return saved;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export async function forgetMemory({ scope, id, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
295
|
-
const normalizedScope = ensureScope(scope);
|
|
296
|
-
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
297
|
-
const existing = await listMemories({ scope: normalizedScope, workspaceRoot, projectAlias });
|
|
298
|
-
const kept = existing.filter((item) => item.id !== id);
|
|
299
|
-
await writeMemoryBucket(filePath, kept);
|
|
300
|
-
if (normalizedScope === 'project') {
|
|
301
|
-
const files = (await listProjectMemoryFiles(workspaceRoot)).filter((file) => file !== filePath);
|
|
302
|
-
await Promise.all(files.map(async (file) => {
|
|
303
|
-
const bucket = await readMemoryBucket(file);
|
|
304
|
-
const next = bucket.filter((item) => String(item?.id || '') !== id);
|
|
305
|
-
if (next.length !== bucket.length) await writeMemoryBucket(file, next);
|
|
306
|
-
}));
|
|
307
|
-
}
|
|
308
|
-
return { removed: existing.length - kept.length };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export async function searchMemories({ scope, query, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
312
|
-
const items = await listMemories({ scope, workspaceRoot, projectAlias });
|
|
313
|
-
const needle = normalizeMemoryText(query).toLowerCase();
|
|
314
|
-
if (!needle) return items;
|
|
315
|
-
return items.filter((item) => item.content.toLowerCase().includes(needle) || item.summary.toLowerCase().includes(needle));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
// Dream Loop: inbox capture, lifecycle, archive, promotion
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
|
|
322
|
-
const VALID_LIFECYCLE = new Set(['observed', 'candidate', 'operational', 'longterm', 'archived']);
|
|
323
|
-
const VALID_INBOX_SCOPES = new Set(['global', 'repo', 'thread', 'project', 'user']);
|
|
324
|
-
|
|
325
|
-
function validateLifecycle(value) {
|
|
326
|
-
const lc = String(value || '').trim().toLowerCase();
|
|
327
|
-
if (!VALID_LIFECYCLE.has(lc)) throw new Error(`Invalid lifecycle state: ${value}`);
|
|
328
|
-
return lc;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function normalizeInboxScope(value) {
|
|
332
|
-
const scope = String(value || 'global').trim().toLowerCase();
|
|
333
|
-
if (!VALID_INBOX_SCOPES.has(scope)) throw new Error(`Unsupported inbox scope: ${value}`);
|
|
334
|
-
return scope;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function todayDir(baseDir) {
|
|
338
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
339
|
-
return path.join(baseDir, date);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async function readJsonArray(filePath) {
|
|
343
|
-
try {
|
|
344
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
345
|
-
const parsed = JSON.parse(raw);
|
|
346
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
347
|
-
} catch {
|
|
348
|
-
return [];
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function writeJsonArray(filePath, items) {
|
|
353
|
-
await ensureParent(filePath);
|
|
354
|
-
await fs.writeFile(filePath, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
export async function captureToInbox({
|
|
358
|
-
scope = 'global',
|
|
359
|
-
type = 'observation',
|
|
360
|
-
summary,
|
|
361
|
-
details = '',
|
|
362
|
-
suggestedAction = '',
|
|
363
|
-
tags = [],
|
|
364
|
-
source = 'tool'
|
|
365
|
-
} = {}) {
|
|
366
|
-
const normalizedSummary = normalizeMemoryText(summary);
|
|
367
|
-
if (!normalizedSummary) throw new Error('Inbox capture summary is required');
|
|
368
|
-
assertSafeMemoryContent(normalizedSummary);
|
|
369
|
-
|
|
370
|
-
const dir = todayDir(getInboxDir());
|
|
371
|
-
await fs.mkdir(dir, { recursive: true });
|
|
372
|
-
const now = nowIso();
|
|
373
|
-
const id = `inbox_${sha256(`${normalizedSummary}:${now}:${Math.random()}`).slice(0, 12)}`;
|
|
374
|
-
const entry = {
|
|
375
|
-
id,
|
|
376
|
-
timestamp: now,
|
|
377
|
-
scope: normalizeInboxScope(scope),
|
|
378
|
-
source,
|
|
379
|
-
type: String(type || 'observation').trim().toLowerCase(),
|
|
380
|
-
summary: normalizedSummary,
|
|
381
|
-
details: normalizeMemoryText(details),
|
|
382
|
-
suggestedAction: normalizeMemoryText(suggestedAction),
|
|
383
|
-
tags: Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [],
|
|
384
|
-
lifecycle: 'observed'
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
const indexPath = path.join(dir, 'index.json');
|
|
388
|
-
const entries = await readJsonArray(indexPath);
|
|
389
|
-
entries.push(entry);
|
|
390
|
-
await writeJsonArray(indexPath, entries);
|
|
391
|
-
return entry;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export async function listInbox({ since, scope } = {}) {
|
|
395
|
-
const inboxBase = getInboxDir();
|
|
396
|
-
let dayDirs;
|
|
397
|
-
try {
|
|
398
|
-
const entries = await fs.readdir(inboxBase);
|
|
399
|
-
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
400
|
-
} catch {
|
|
401
|
-
return [];
|
|
402
|
-
}
|
|
403
|
-
if (since) {
|
|
404
|
-
const sinceStr = String(since).slice(0, 10);
|
|
405
|
-
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
406
|
-
}
|
|
407
|
-
const all = [];
|
|
408
|
-
for (const day of dayDirs) {
|
|
409
|
-
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
410
|
-
const entries = await readJsonArray(indexPath);
|
|
411
|
-
all.push(...entries);
|
|
412
|
-
}
|
|
413
|
-
if (scope) {
|
|
414
|
-
const sc = String(scope).trim().toLowerCase();
|
|
415
|
-
return all.filter((e) => e.scope === sc);
|
|
416
|
-
}
|
|
417
|
-
return all;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
export async function updateInboxEntry(id, updates = {}) {
|
|
421
|
-
const inboxBase = getInboxDir();
|
|
422
|
-
let dayDirs;
|
|
423
|
-
try {
|
|
424
|
-
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
425
|
-
} catch {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
for (const day of dayDirs) {
|
|
429
|
-
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
430
|
-
const entries = await readJsonArray(indexPath);
|
|
431
|
-
const idx = entries.findIndex((e) => e.id === id);
|
|
432
|
-
if (idx === -1) continue;
|
|
433
|
-
if (updates.lifecycle) updates.lifecycle = validateLifecycle(updates.lifecycle);
|
|
434
|
-
entries[idx] = { ...entries[idx], ...updates };
|
|
435
|
-
await writeJsonArray(indexPath, entries);
|
|
436
|
-
return entries[idx];
|
|
437
|
-
}
|
|
438
|
-
return null;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
export async function removeInboxEntry(id) {
|
|
442
|
-
const inboxBase = getInboxDir();
|
|
443
|
-
let dayDirs;
|
|
444
|
-
try {
|
|
445
|
-
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
446
|
-
} catch {
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
for (const day of dayDirs) {
|
|
450
|
-
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
451
|
-
const entries = await readJsonArray(indexPath);
|
|
452
|
-
const idx = entries.findIndex((e) => e.id === id);
|
|
453
|
-
if (idx === -1) continue;
|
|
454
|
-
entries.splice(idx, 1);
|
|
455
|
-
await writeJsonArray(indexPath, entries);
|
|
456
|
-
return true;
|
|
457
|
-
}
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export async function archiveEntry(entry, reason = '', auditNote = '') {
|
|
462
|
-
const archiveDir = getArchiveDir();
|
|
463
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
464
|
-
const dir = path.join(archiveDir, date);
|
|
465
|
-
await fs.mkdir(dir, { recursive: true });
|
|
466
|
-
const archived = {
|
|
467
|
-
...entry,
|
|
468
|
-
lifecycle: 'archived',
|
|
469
|
-
archivedAt: nowIso(),
|
|
470
|
-
archiveReason: normalizeMemoryText(reason),
|
|
471
|
-
auditNote: normalizeMemoryText(auditNote)
|
|
472
|
-
};
|
|
473
|
-
const indexPath = path.join(dir, 'index.json');
|
|
474
|
-
const entries = await readJsonArray(indexPath);
|
|
475
|
-
entries.push(archived);
|
|
476
|
-
await writeJsonArray(indexPath, entries);
|
|
477
|
-
await removeInboxEntry(entry.id);
|
|
478
|
-
return archived;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export async function listArchive({ since, scope } = {}) {
|
|
482
|
-
const archiveBase = getArchiveDir();
|
|
483
|
-
let dayDirs;
|
|
484
|
-
try {
|
|
485
|
-
const entries = await fs.readdir(archiveBase);
|
|
486
|
-
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
487
|
-
} catch {
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
if (since) {
|
|
491
|
-
const sinceStr = String(since).slice(0, 10);
|
|
492
|
-
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
493
|
-
}
|
|
494
|
-
const all = [];
|
|
495
|
-
for (const day of dayDirs) {
|
|
496
|
-
const indexPath = path.join(archiveBase, day, 'index.json');
|
|
497
|
-
const entries = await readJsonArray(indexPath);
|
|
498
|
-
all.push(...entries);
|
|
499
|
-
}
|
|
500
|
-
if (scope) {
|
|
501
|
-
const sc = String(scope).trim().toLowerCase();
|
|
502
|
-
return all.filter((e) => e.scope === sc);
|
|
503
|
-
}
|
|
504
|
-
return all;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
export async function promoteMemory({
|
|
508
|
-
entry,
|
|
509
|
-
scope = 'global',
|
|
510
|
-
lifecycle = 'operational',
|
|
511
|
-
workspaceRoot = process.cwd(),
|
|
512
|
-
projectAlias = '',
|
|
513
|
-
config = {},
|
|
514
|
-
confidence = 0.9
|
|
515
|
-
} = {}) {
|
|
516
|
-
if (!entry?.summary) throw new Error('Entry with summary is required for promotion');
|
|
517
|
-
const lc = validateLifecycle(lifecycle);
|
|
518
|
-
const content = normalizeMemoryText(entry.details || entry.summary);
|
|
519
|
-
const saved = await rememberMemory({
|
|
520
|
-
scope,
|
|
521
|
-
content,
|
|
522
|
-
kind: entry.type || 'note',
|
|
523
|
-
summary: normalizeMemoryText(entry.summary),
|
|
524
|
-
source: `dream-promote:${entry.id}`,
|
|
525
|
-
confidence: Math.min(1, Math.max(0.5, confidence)),
|
|
526
|
-
replaceSimilar: true,
|
|
527
|
-
workspaceRoot,
|
|
528
|
-
projectAlias,
|
|
529
|
-
config
|
|
530
|
-
});
|
|
531
|
-
// Tag the saved item with lifecycle
|
|
532
|
-
const filePath = buildFilePath(scope, workspaceRoot, projectAlias);
|
|
533
|
-
const projectKey = scope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
534
|
-
const items = (await readMemoryBucket(filePath)).map((item) => normalizeMemoryItem(item, scope, projectKey));
|
|
535
|
-
const target = items.find((item) => item.id === saved.id);
|
|
536
|
-
if (target) {
|
|
537
|
-
target.lifecycle = lc;
|
|
538
|
-
await writeMemoryBucket(filePath, items);
|
|
539
|
-
}
|
|
540
|
-
// Remove from inbox
|
|
541
|
-
await removeInboxEntry(entry.id);
|
|
542
|
-
return { promoted: saved, lifecycle: lc };
|
|
543
|
-
}
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256 } from './crypto-utils.js';
|
|
4
|
+
import { getMemoryDir, getProjectMemoryDir, getInboxDir, getArchiveDir } from './paths.js';
|
|
5
|
+
import { assertSafeMemoryContent, normalizeMemoryText, summarizeMemoryContent } from './memory-policy.js';
|
|
6
|
+
|
|
7
|
+
const ALLOWED_SCOPES = new Set(['user', 'global', 'project']);
|
|
8
|
+
|
|
9
|
+
function nowIso() {
|
|
10
|
+
return new Date().toISOString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function slugify(value) {
|
|
14
|
+
const text = String(value || '')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
|
17
|
+
.replace(/^-+|-+$/g, '');
|
|
18
|
+
return text || 'project';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getProjectMemoryKey(workspaceRoot = process.cwd(), projectAlias = '') {
|
|
22
|
+
const alias = normalizeMemoryText(projectAlias);
|
|
23
|
+
if (alias) return slugify(alias);
|
|
24
|
+
const root = path.resolve(workspaceRoot || process.cwd());
|
|
25
|
+
const base = path.basename(root);
|
|
26
|
+
return `${slugify(base)}-${sha256(root).slice(0, 10)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureScope(scope) {
|
|
30
|
+
const value = String(scope || '').trim().toLowerCase();
|
|
31
|
+
if (!ALLOWED_SCOPES.has(value)) {
|
|
32
|
+
throw new Error(`Unsupported memory scope: ${scope}`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ensureParent(filePath) {
|
|
38
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildFilePath(scope, workspaceRoot = process.cwd(), projectAlias = '') {
|
|
42
|
+
if (scope === 'user') return path.join(getMemoryDir(), 'user.json');
|
|
43
|
+
if (scope === 'global') return path.join(getMemoryDir(), 'global.json');
|
|
44
|
+
return path.join(getProjectMemoryDir(workspaceRoot), 'project.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function listProjectMemoryFiles(workspaceRoot = process.cwd()) {
|
|
48
|
+
const dir = getProjectMemoryDir(workspaceRoot);
|
|
49
|
+
try {
|
|
50
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
51
|
+
return entries
|
|
52
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
53
|
+
.map((entry) => path.join(dir, entry.name))
|
|
54
|
+
.sort();
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readMemoryBucket(filePath) {
|
|
61
|
+
const doc = await readMemoryBucketDocument(filePath);
|
|
62
|
+
return doc.items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readMemoryBucketDocument(filePath) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
return {
|
|
70
|
+
items: Array.isArray(parsed?.items) ? parsed.items : [],
|
|
71
|
+
maintenance: parsed?.maintenance && typeof parsed.maintenance === 'object' ? parsed.maintenance : null
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return { items: [], maintenance: null };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function memoryBucketHash(items = []) {
|
|
79
|
+
const stable = (Array.isArray(items) ? items : [])
|
|
80
|
+
.map((item) => ({
|
|
81
|
+
id: String(item?.id || ''),
|
|
82
|
+
kind: String(item?.kind || ''),
|
|
83
|
+
content: normalizeMemoryText(item?.content || ''),
|
|
84
|
+
summary: normalizeMemoryText(item?.summary || ''),
|
|
85
|
+
lifecycle: String(item?.lifecycle || ''),
|
|
86
|
+
pinned: item?.pinned === true
|
|
87
|
+
}))
|
|
88
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
89
|
+
return sha256(JSON.stringify(stable));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeMemoryBucket(filePath, items, { maintenance = null } = {}) {
|
|
93
|
+
await ensureParent(filePath);
|
|
94
|
+
const doc = { items };
|
|
95
|
+
if (maintenance) doc.maintenance = maintenance;
|
|
96
|
+
await fs.writeFile(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dedupeMemoryItems(items = []) {
|
|
100
|
+
const deduped = [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const item of items) {
|
|
103
|
+
const key = item.id ? `id:${item.id}` : `${item.kind}:${normalizeMemoryText(item.content)}`;
|
|
104
|
+
if (seen.has(key)) continue;
|
|
105
|
+
seen.add(key);
|
|
106
|
+
deduped.push(item);
|
|
107
|
+
}
|
|
108
|
+
return deduped;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readProjectMemoryItems(workspaceRoot = process.cwd(), projectAlias = '') {
|
|
112
|
+
const projectKey = getProjectMemoryKey(workspaceRoot, projectAlias);
|
|
113
|
+
const files = await listProjectMemoryFiles(workspaceRoot);
|
|
114
|
+
const items = [];
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const bucket = await readMemoryBucket(file);
|
|
117
|
+
items.push(...bucket.map((item) => normalizeMemoryItem(item, 'project', projectKey)));
|
|
118
|
+
}
|
|
119
|
+
return dedupeMemoryItems(items)
|
|
120
|
+
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readScopeMemoryItems(scope, workspaceRoot = process.cwd(), projectAlias = '') {
|
|
124
|
+
const normalizedScope = ensureScope(scope);
|
|
125
|
+
if (normalizedScope === 'project') return readProjectMemoryItems(workspaceRoot, projectAlias);
|
|
126
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
127
|
+
return (await readMemoryBucket(filePath))
|
|
128
|
+
.map((item) => normalizeMemoryItem(item, normalizedScope, ''))
|
|
129
|
+
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeMemoryItem(item, scope, projectKey = '') {
|
|
133
|
+
const now = nowIso();
|
|
134
|
+
const content = normalizeMemoryText(item?.content || '');
|
|
135
|
+
return {
|
|
136
|
+
id: String(item?.id || `mem_${sha256(`${scope}:${projectKey}:${content}:${now}:${Math.random()}`).slice(0, 12)}`),
|
|
137
|
+
scope,
|
|
138
|
+
projectKey: projectKey || undefined,
|
|
139
|
+
kind: String(item?.kind || 'note').trim() || 'note',
|
|
140
|
+
content,
|
|
141
|
+
summary: normalizeMemoryText(item?.summary || summarizeMemoryContent(content)),
|
|
142
|
+
source: String(item?.source || 'tool').trim() || 'tool',
|
|
143
|
+
confidence: Number.isFinite(Number(item?.confidence)) ? Number(item.confidence) : 0.9,
|
|
144
|
+
createdAt: String(item?.createdAt || now),
|
|
145
|
+
updatedAt: String(item?.updatedAt || now),
|
|
146
|
+
hits: Number.isFinite(Number(item?.hits)) ? Number(item.hits) : 0,
|
|
147
|
+
pinned: item?.pinned === true,
|
|
148
|
+
...(item?.lifecycle ? { lifecycle: String(item.lifecycle) } : {})
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sameMemory(left, right) {
|
|
153
|
+
const a = normalizeMemoryText(left?.content);
|
|
154
|
+
const b = normalizeMemoryText(right?.content);
|
|
155
|
+
if (!a || !b) return false;
|
|
156
|
+
return a === b;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function measureMemoryChars(item) {
|
|
160
|
+
return normalizeMemoryText(item?.content).length + normalizeMemoryText(item?.summary).length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function budgetForScope(scope, config = {}) {
|
|
164
|
+
if (scope === 'user') return Math.max(80, Number(config?.memory?.max_user_chars || 1375));
|
|
165
|
+
if (scope === 'global') return Math.max(80, Number(config?.memory?.max_global_chars || 2200));
|
|
166
|
+
return Math.max(80, Number(config?.memory?.max_project_chars || 2200));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function listMemories({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
170
|
+
const normalizedScope = ensureScope(scope);
|
|
171
|
+
return readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function getMemoryBucketMaintenance({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
175
|
+
const normalizedScope = ensureScope(scope);
|
|
176
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
177
|
+
const doc = await readMemoryBucketDocument(filePath);
|
|
178
|
+
const items = normalizedScope === 'project'
|
|
179
|
+
? await readProjectMemoryItems(workspaceRoot, projectAlias)
|
|
180
|
+
: doc.items.map((item) => normalizeMemoryItem(item, normalizedScope, ''));
|
|
181
|
+
const currentHash = memoryBucketHash(items);
|
|
182
|
+
const storedHash = String(doc.maintenance?.contentHash || '');
|
|
183
|
+
const maintainedAt = String(doc.maintenance?.maintainedAt || '');
|
|
184
|
+
return {
|
|
185
|
+
scope: normalizedScope,
|
|
186
|
+
itemCount: items.length,
|
|
187
|
+
contentHash: currentHash,
|
|
188
|
+
storedHash,
|
|
189
|
+
maintainedAt,
|
|
190
|
+
fresh: Boolean(maintainedAt && storedHash && storedHash === currentHash)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function markMemoryBucketMaintained({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
195
|
+
const normalizedScope = ensureScope(scope);
|
|
196
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
197
|
+
const items = await readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
198
|
+
const maintenance = {
|
|
199
|
+
maintainedAt: nowIso(),
|
|
200
|
+
contentHash: memoryBucketHash(items),
|
|
201
|
+
itemCount: items.length
|
|
202
|
+
};
|
|
203
|
+
await writeMemoryBucket(filePath, items, { maintenance });
|
|
204
|
+
return { scope: normalizedScope, ...maintenance };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function replaceMemoryBucket({
|
|
208
|
+
scope,
|
|
209
|
+
items = [],
|
|
210
|
+
workspaceRoot = process.cwd(),
|
|
211
|
+
projectAlias = '',
|
|
212
|
+
markMaintained = false
|
|
213
|
+
} = {}) {
|
|
214
|
+
const normalizedScope = ensureScope(scope);
|
|
215
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
216
|
+
const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
217
|
+
const normalizedItems = (Array.isArray(items) ? items : [])
|
|
218
|
+
.map((item) => normalizeMemoryItem(item, normalizedScope, projectKey))
|
|
219
|
+
.filter((item) => item.content);
|
|
220
|
+
const maintenance = markMaintained
|
|
221
|
+
? {
|
|
222
|
+
maintainedAt: nowIso(),
|
|
223
|
+
contentHash: memoryBucketHash(normalizedItems),
|
|
224
|
+
itemCount: normalizedItems.length
|
|
225
|
+
}
|
|
226
|
+
: null;
|
|
227
|
+
await writeMemoryBucket(filePath, normalizedItems, { maintenance });
|
|
228
|
+
return {
|
|
229
|
+
scope: normalizedScope,
|
|
230
|
+
items: normalizedItems,
|
|
231
|
+
maintenance
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function rememberMemory({
|
|
236
|
+
scope,
|
|
237
|
+
content,
|
|
238
|
+
kind = 'note',
|
|
239
|
+
summary = '',
|
|
240
|
+
source = 'tool',
|
|
241
|
+
confidence = 0.9,
|
|
242
|
+
replaceSimilar = true,
|
|
243
|
+
pinned = false,
|
|
244
|
+
workspaceRoot = process.cwd(),
|
|
245
|
+
projectAlias = '',
|
|
246
|
+
config = {}
|
|
247
|
+
}) {
|
|
248
|
+
const normalizedScope = ensureScope(scope);
|
|
249
|
+
const normalizedContent = normalizeMemoryText(content);
|
|
250
|
+
if (!normalizedContent) throw new Error('Memory content is required');
|
|
251
|
+
assertSafeMemoryContent(normalizedContent);
|
|
252
|
+
|
|
253
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
254
|
+
const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
255
|
+
const existing = await readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
|
|
256
|
+
const probe = normalizeMemoryItem({ content: normalizedContent, kind, summary, source, confidence, pinned }, normalizedScope, projectKey);
|
|
257
|
+
|
|
258
|
+
const replaceIndex = replaceSimilar ? existing.findIndex((item) => sameMemory(item, probe)) : -1;
|
|
259
|
+
let saved;
|
|
260
|
+
if (replaceIndex >= 0) {
|
|
261
|
+
saved = {
|
|
262
|
+
...existing[replaceIndex],
|
|
263
|
+
...probe,
|
|
264
|
+
id: existing[replaceIndex].id,
|
|
265
|
+
createdAt: existing[replaceIndex].createdAt,
|
|
266
|
+
updatedAt: nowIso()
|
|
267
|
+
};
|
|
268
|
+
existing.splice(replaceIndex, 1, saved);
|
|
269
|
+
} else {
|
|
270
|
+
saved = probe;
|
|
271
|
+
existing.unshift(saved);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const maxItems = Math.max(1, Number(config?.memory?.max_items_per_scope || 12));
|
|
275
|
+
const maxChars = budgetForScope(normalizedScope, config);
|
|
276
|
+
const deduped = [];
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
for (const item of existing) {
|
|
279
|
+
const key = `${item.kind}:${normalizeMemoryText(item.content)}`;
|
|
280
|
+
if (seen.has(key)) continue;
|
|
281
|
+
seen.add(key);
|
|
282
|
+
deduped.push(item);
|
|
283
|
+
if (deduped.length >= maxItems) break;
|
|
284
|
+
}
|
|
285
|
+
let totalChars = deduped.reduce((sum, item) => sum + measureMemoryChars(item), 0);
|
|
286
|
+
while (deduped.length > 1 && totalChars > maxChars) {
|
|
287
|
+
const removed = deduped.pop();
|
|
288
|
+
totalChars -= measureMemoryChars(removed);
|
|
289
|
+
}
|
|
290
|
+
await writeMemoryBucket(filePath, deduped);
|
|
291
|
+
return saved;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function forgetMemory({ scope, id, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
295
|
+
const normalizedScope = ensureScope(scope);
|
|
296
|
+
const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
|
|
297
|
+
const existing = await listMemories({ scope: normalizedScope, workspaceRoot, projectAlias });
|
|
298
|
+
const kept = existing.filter((item) => item.id !== id);
|
|
299
|
+
await writeMemoryBucket(filePath, kept);
|
|
300
|
+
if (normalizedScope === 'project') {
|
|
301
|
+
const files = (await listProjectMemoryFiles(workspaceRoot)).filter((file) => file !== filePath);
|
|
302
|
+
await Promise.all(files.map(async (file) => {
|
|
303
|
+
const bucket = await readMemoryBucket(file);
|
|
304
|
+
const next = bucket.filter((item) => String(item?.id || '') !== id);
|
|
305
|
+
if (next.length !== bucket.length) await writeMemoryBucket(file, next);
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
return { removed: existing.length - kept.length };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function searchMemories({ scope, query, workspaceRoot = process.cwd(), projectAlias = '' }) {
|
|
312
|
+
const items = await listMemories({ scope, workspaceRoot, projectAlias });
|
|
313
|
+
const needle = normalizeMemoryText(query).toLowerCase();
|
|
314
|
+
if (!needle) return items;
|
|
315
|
+
return items.filter((item) => item.content.toLowerCase().includes(needle) || item.summary.toLowerCase().includes(needle));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Dream Loop: inbox capture, lifecycle, archive, promotion
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
const VALID_LIFECYCLE = new Set(['observed', 'candidate', 'operational', 'longterm', 'archived']);
|
|
323
|
+
const VALID_INBOX_SCOPES = new Set(['global', 'repo', 'thread', 'project', 'user']);
|
|
324
|
+
|
|
325
|
+
function validateLifecycle(value) {
|
|
326
|
+
const lc = String(value || '').trim().toLowerCase();
|
|
327
|
+
if (!VALID_LIFECYCLE.has(lc)) throw new Error(`Invalid lifecycle state: ${value}`);
|
|
328
|
+
return lc;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeInboxScope(value) {
|
|
332
|
+
const scope = String(value || 'global').trim().toLowerCase();
|
|
333
|
+
if (!VALID_INBOX_SCOPES.has(scope)) throw new Error(`Unsupported inbox scope: ${value}`);
|
|
334
|
+
return scope;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function todayDir(baseDir) {
|
|
338
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
339
|
+
return path.join(baseDir, date);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readJsonArray(filePath) {
|
|
343
|
+
try {
|
|
344
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
345
|
+
const parsed = JSON.parse(raw);
|
|
346
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
347
|
+
} catch {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function writeJsonArray(filePath, items) {
|
|
353
|
+
await ensureParent(filePath);
|
|
354
|
+
await fs.writeFile(filePath, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function captureToInbox({
|
|
358
|
+
scope = 'global',
|
|
359
|
+
type = 'observation',
|
|
360
|
+
summary,
|
|
361
|
+
details = '',
|
|
362
|
+
suggestedAction = '',
|
|
363
|
+
tags = [],
|
|
364
|
+
source = 'tool'
|
|
365
|
+
} = {}) {
|
|
366
|
+
const normalizedSummary = normalizeMemoryText(summary);
|
|
367
|
+
if (!normalizedSummary) throw new Error('Inbox capture summary is required');
|
|
368
|
+
assertSafeMemoryContent(normalizedSummary);
|
|
369
|
+
|
|
370
|
+
const dir = todayDir(getInboxDir());
|
|
371
|
+
await fs.mkdir(dir, { recursive: true });
|
|
372
|
+
const now = nowIso();
|
|
373
|
+
const id = `inbox_${sha256(`${normalizedSummary}:${now}:${Math.random()}`).slice(0, 12)}`;
|
|
374
|
+
const entry = {
|
|
375
|
+
id,
|
|
376
|
+
timestamp: now,
|
|
377
|
+
scope: normalizeInboxScope(scope),
|
|
378
|
+
source,
|
|
379
|
+
type: String(type || 'observation').trim().toLowerCase(),
|
|
380
|
+
summary: normalizedSummary,
|
|
381
|
+
details: normalizeMemoryText(details),
|
|
382
|
+
suggestedAction: normalizeMemoryText(suggestedAction),
|
|
383
|
+
tags: Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [],
|
|
384
|
+
lifecycle: 'observed'
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const indexPath = path.join(dir, 'index.json');
|
|
388
|
+
const entries = await readJsonArray(indexPath);
|
|
389
|
+
entries.push(entry);
|
|
390
|
+
await writeJsonArray(indexPath, entries);
|
|
391
|
+
return entry;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function listInbox({ since, scope } = {}) {
|
|
395
|
+
const inboxBase = getInboxDir();
|
|
396
|
+
let dayDirs;
|
|
397
|
+
try {
|
|
398
|
+
const entries = await fs.readdir(inboxBase);
|
|
399
|
+
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
400
|
+
} catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
if (since) {
|
|
404
|
+
const sinceStr = String(since).slice(0, 10);
|
|
405
|
+
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
406
|
+
}
|
|
407
|
+
const all = [];
|
|
408
|
+
for (const day of dayDirs) {
|
|
409
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
410
|
+
const entries = await readJsonArray(indexPath);
|
|
411
|
+
all.push(...entries);
|
|
412
|
+
}
|
|
413
|
+
if (scope) {
|
|
414
|
+
const sc = String(scope).trim().toLowerCase();
|
|
415
|
+
return all.filter((e) => e.scope === sc);
|
|
416
|
+
}
|
|
417
|
+
return all;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function updateInboxEntry(id, updates = {}) {
|
|
421
|
+
const inboxBase = getInboxDir();
|
|
422
|
+
let dayDirs;
|
|
423
|
+
try {
|
|
424
|
+
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
for (const day of dayDirs) {
|
|
429
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
430
|
+
const entries = await readJsonArray(indexPath);
|
|
431
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
432
|
+
if (idx === -1) continue;
|
|
433
|
+
if (updates.lifecycle) updates.lifecycle = validateLifecycle(updates.lifecycle);
|
|
434
|
+
entries[idx] = { ...entries[idx], ...updates };
|
|
435
|
+
await writeJsonArray(indexPath, entries);
|
|
436
|
+
return entries[idx];
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function removeInboxEntry(id) {
|
|
442
|
+
const inboxBase = getInboxDir();
|
|
443
|
+
let dayDirs;
|
|
444
|
+
try {
|
|
445
|
+
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
for (const day of dayDirs) {
|
|
450
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
451
|
+
const entries = await readJsonArray(indexPath);
|
|
452
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
453
|
+
if (idx === -1) continue;
|
|
454
|
+
entries.splice(idx, 1);
|
|
455
|
+
await writeJsonArray(indexPath, entries);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function archiveEntry(entry, reason = '', auditNote = '') {
|
|
462
|
+
const archiveDir = getArchiveDir();
|
|
463
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
464
|
+
const dir = path.join(archiveDir, date);
|
|
465
|
+
await fs.mkdir(dir, { recursive: true });
|
|
466
|
+
const archived = {
|
|
467
|
+
...entry,
|
|
468
|
+
lifecycle: 'archived',
|
|
469
|
+
archivedAt: nowIso(),
|
|
470
|
+
archiveReason: normalizeMemoryText(reason),
|
|
471
|
+
auditNote: normalizeMemoryText(auditNote)
|
|
472
|
+
};
|
|
473
|
+
const indexPath = path.join(dir, 'index.json');
|
|
474
|
+
const entries = await readJsonArray(indexPath);
|
|
475
|
+
entries.push(archived);
|
|
476
|
+
await writeJsonArray(indexPath, entries);
|
|
477
|
+
await removeInboxEntry(entry.id);
|
|
478
|
+
return archived;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function listArchive({ since, scope } = {}) {
|
|
482
|
+
const archiveBase = getArchiveDir();
|
|
483
|
+
let dayDirs;
|
|
484
|
+
try {
|
|
485
|
+
const entries = await fs.readdir(archiveBase);
|
|
486
|
+
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
487
|
+
} catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
if (since) {
|
|
491
|
+
const sinceStr = String(since).slice(0, 10);
|
|
492
|
+
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
493
|
+
}
|
|
494
|
+
const all = [];
|
|
495
|
+
for (const day of dayDirs) {
|
|
496
|
+
const indexPath = path.join(archiveBase, day, 'index.json');
|
|
497
|
+
const entries = await readJsonArray(indexPath);
|
|
498
|
+
all.push(...entries);
|
|
499
|
+
}
|
|
500
|
+
if (scope) {
|
|
501
|
+
const sc = String(scope).trim().toLowerCase();
|
|
502
|
+
return all.filter((e) => e.scope === sc);
|
|
503
|
+
}
|
|
504
|
+
return all;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function promoteMemory({
|
|
508
|
+
entry,
|
|
509
|
+
scope = 'global',
|
|
510
|
+
lifecycle = 'operational',
|
|
511
|
+
workspaceRoot = process.cwd(),
|
|
512
|
+
projectAlias = '',
|
|
513
|
+
config = {},
|
|
514
|
+
confidence = 0.9
|
|
515
|
+
} = {}) {
|
|
516
|
+
if (!entry?.summary) throw new Error('Entry with summary is required for promotion');
|
|
517
|
+
const lc = validateLifecycle(lifecycle);
|
|
518
|
+
const content = normalizeMemoryText(entry.details || entry.summary);
|
|
519
|
+
const saved = await rememberMemory({
|
|
520
|
+
scope,
|
|
521
|
+
content,
|
|
522
|
+
kind: entry.type || 'note',
|
|
523
|
+
summary: normalizeMemoryText(entry.summary),
|
|
524
|
+
source: `dream-promote:${entry.id}`,
|
|
525
|
+
confidence: Math.min(1, Math.max(0.5, confidence)),
|
|
526
|
+
replaceSimilar: true,
|
|
527
|
+
workspaceRoot,
|
|
528
|
+
projectAlias,
|
|
529
|
+
config
|
|
530
|
+
});
|
|
531
|
+
// Tag the saved item with lifecycle
|
|
532
|
+
const filePath = buildFilePath(scope, workspaceRoot, projectAlias);
|
|
533
|
+
const projectKey = scope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
534
|
+
const items = (await readMemoryBucket(filePath)).map((item) => normalizeMemoryItem(item, scope, projectKey));
|
|
535
|
+
const target = items.find((item) => item.id === saved.id);
|
|
536
|
+
if (target) {
|
|
537
|
+
target.lifecycle = lc;
|
|
538
|
+
await writeMemoryBucket(filePath, items);
|
|
539
|
+
}
|
|
540
|
+
// Remove from inbox
|
|
541
|
+
await removeInboxEntry(entry.id);
|
|
542
|
+
return { promoted: saved, lifecycle: lc };
|
|
543
|
+
}
|