codemini-cli 0.4.1 → 0.4.2

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.
@@ -58,6 +58,12 @@ function isSafeEntry(entry) {
58
58
  return entry !== '.' && entry !== '..' && !entry.includes('/') && !entry.includes('\\');
59
59
  }
60
60
 
61
+ function setCommand(out, name, command) {
62
+ const existing = out.get(name);
63
+ if (existing?.source === 'bundled-skill') return;
64
+ out.set(name, command);
65
+ }
66
+
61
67
  function loadMarkdownCommandsFromDir(baseDir, source, out) {
62
68
  if (!fs.existsSync(baseDir)) return;
63
69
  for (const entry of safeEntries(baseDir)) {
@@ -70,7 +76,7 @@ function loadMarkdownCommandsFromDir(baseDir, source, out) {
70
76
  if (fs.existsSync(commandFile)) {
71
77
  const raw = fs.readFileSync(commandFile, 'utf8');
72
78
  const parsed = parseFrontmatter(raw);
73
- out.set(entry, {
79
+ setCommand(out, entry, {
74
80
  name: entry,
75
81
  source,
76
82
  path: commandFile,
@@ -85,7 +91,7 @@ function loadMarkdownCommandsFromDir(baseDir, source, out) {
85
91
  const name = entry.replace(/\.md$/, '');
86
92
  const raw = fs.readFileSync(full, 'utf8');
87
93
  const parsed = parseFrontmatter(raw);
88
- out.set(name, {
94
+ setCommand(out, name, {
89
95
  name,
90
96
  source,
91
97
  path: full,
@@ -107,7 +113,7 @@ function loadLegacySkillsFromDir(baseDir, source, out) {
107
113
  if (!fs.existsSync(skillFile)) continue;
108
114
  const raw = fs.readFileSync(skillFile, 'utf8');
109
115
  const parsed = parseFrontmatter(raw);
110
- out.set(entry, {
116
+ setCommand(out, entry, {
111
117
  name: entry,
112
118
  source: `${source}-skill`,
113
119
  path: skillFile,
@@ -131,7 +137,7 @@ function loadBundledSkillsFromDir(baseDir, out) {
131
137
  if (!fs.existsSync(skillFile)) continue;
132
138
  const raw = fs.readFileSync(skillFile, 'utf8');
133
139
  const parsed = parseFrontmatter(raw);
134
- out.set(entry, {
140
+ setCommand(out, entry, {
135
141
  name: entry,
136
142
  source: 'bundled-skill',
137
143
  path: skillFile,
@@ -151,12 +157,13 @@ function loadInstalledSkillsFromRegistry(baseDir, registry, out) {
151
157
  for (const skill of registry.skills) {
152
158
  if (skill.enabled === false) continue;
153
159
  const name = skill.name;
160
+ if (out.has(name)) continue;
154
161
  const entry = skill.entryFile || 'SKILL.md';
155
162
  const full = path.join(baseDir, name, entry);
156
163
  if (!fs.existsSync(full)) continue;
157
164
  const raw = fs.readFileSync(full, 'utf8');
158
165
  const parsed = parseFrontmatter(raw);
159
- out.set(name, {
166
+ setCommand(out, name, {
160
167
  name,
161
168
  source: 'registry-skill',
162
169
  path: full,
@@ -1,4 +1,5 @@
1
- import { summarizeToolResult, trimInline } from './agent-loop.js';
1
+ import { trimInline } from './string-utils.js';
2
+ import { summarizeToolResult } from './tool-result-store.js';
2
3
 
3
4
  function textFromContent(content) {
4
5
  if (typeof content === 'string') return content;
@@ -56,6 +56,18 @@ function renderReport(report) {
56
56
  lines.push('');
57
57
  }
58
58
 
59
+ if (report.maintenance?.length) {
60
+ lines.push('## Memory Maintenance');
61
+ for (const item of report.maintenance) {
62
+ if (item.skipped) {
63
+ lines.push(`- [${item.scope}] skipped: ${item.reason}`);
64
+ } else {
65
+ lines.push(`- [${item.scope}] ${item.before} -> ${item.after} item(s)${item.changed ? ' changed' : ' marked clean'}`);
66
+ }
67
+ }
68
+ lines.push('');
69
+ }
70
+
59
71
  if (report.disagreements?.length) {
60
72
  lines.push('## Reviewer Disagreements');
61
73
  for (const d of report.disagreements) {
@@ -1,6 +1,13 @@
1
- import { listMemories, listInbox, archiveEntry, promoteMemory } from './memory-store.js';
1
+ import {
2
+ getMemoryBucketMaintenance,
3
+ listMemories,
4
+ listInbox,
5
+ archiveEntry,
6
+ promoteMemory,
7
+ replaceMemoryBucket
8
+ } from './memory-store.js';
2
9
  import { writeDreamAuditReport } from './dream-audit.js';
3
- import { evaluateInboxBatch } from './dream-evaluator.js';
10
+ import { evaluateInboxBatch, evaluateMemoryMaintenance } from './dream-evaluator.js';
4
11
 
5
12
  const LONGTERM_TYPES = new Set(['preference', 'pattern', 'win', 'decision']);
6
13
  const OPERATIONAL_TYPES = new Set(['correction', 'failure', 'gap', 'observation']);
@@ -22,6 +29,76 @@ function memoryContainsSummary(memory, summaryKey) {
22
29
  return content.includes(summaryKey) || summary.includes(summaryKey);
23
30
  }
24
31
 
32
+ function maintenanceScopes(scopeFilter) {
33
+ const scope = normalizeText(scopeFilter);
34
+ if (!scope) return ['user', 'global', 'project'];
35
+ if (scope === 'repo') return ['project'];
36
+ if (['user', 'global', 'project'].includes(scope)) return [scope];
37
+ return ['user', 'global', 'project'];
38
+ }
39
+
40
+ async function runMemoryMaintenance({
41
+ dryRun = false,
42
+ scope = null,
43
+ workspaceRoot = process.cwd(),
44
+ config = {}
45
+ } = {}) {
46
+ const reports = [];
47
+ const filesChanged = [];
48
+
49
+ for (const memoryScope of maintenanceScopes(scope)) {
50
+ const maintenance = await getMemoryBucketMaintenance({ scope: memoryScope, workspaceRoot });
51
+ const items = await listMemories({ scope: memoryScope, workspaceRoot });
52
+ if (items.length === 0) {
53
+ reports.push({ scope: memoryScope, skipped: true, reason: 'empty' });
54
+ continue;
55
+ }
56
+ if (maintenance.fresh) {
57
+ reports.push({ scope: memoryScope, skipped: true, reason: 'already-maintained', itemCount: items.length });
58
+ continue;
59
+ }
60
+
61
+ const evaluated = dryRun
62
+ ? { items, archives: [] }
63
+ : await evaluateMemoryMaintenance({ scope: memoryScope, items, config, workspaceRoot });
64
+ if (evaluated.error) {
65
+ reports.push({ scope: memoryScope, skipped: true, reason: `maintenance-error: ${evaluated.error}`, itemCount: items.length });
66
+ continue;
67
+ }
68
+ const nextItems = Array.isArray(evaluated.items) && evaluated.items.length > 0 ? evaluated.items : items;
69
+ const changed =
70
+ JSON.stringify(nextItems.map((item) => [item.kind, item.content, item.summary, item.lifecycle || ''])) !==
71
+ JSON.stringify(items.map((item) => [item.kind, item.content, item.summary, item.lifecycle || '']));
72
+
73
+ if (!dryRun) {
74
+ await replaceMemoryBucket({
75
+ scope: memoryScope,
76
+ items: nextItems,
77
+ workspaceRoot,
78
+ markMaintained: true
79
+ });
80
+ filesChanged.push({
81
+ file: memoryScope === 'project' ? 'memory/project/*.json' : `memory/${memoryScope}.json`,
82
+ why: changed
83
+ ? `LLM-maintained ${items.length} item(s) into ${nextItems.length} item(s)`
84
+ : `Marked ${items.length} item(s) as maintained`
85
+ });
86
+ }
87
+
88
+ reports.push({
89
+ scope: memoryScope,
90
+ skipped: false,
91
+ before: items.length,
92
+ after: nextItems.length,
93
+ changed,
94
+ archives: evaluated.archives || [],
95
+ dryRun
96
+ });
97
+ }
98
+
99
+ return { reports, filesChanged };
100
+ }
101
+
25
102
  export async function runDreamConsolidation({
26
103
  dryRun = false,
27
104
  scope = null,
@@ -31,9 +108,6 @@ export async function runDreamConsolidation({
31
108
  } = {}) {
32
109
  const scopeFilter = scope || null;
33
110
  const inbox = await listInbox({ scope: scopeFilter });
34
- if (inbox.length === 0) {
35
- return { ok: true, dryRun, message: 'No inbox entries to consolidate.', promotions: [], rejections: [], archives: [] };
36
- }
37
111
 
38
112
  const [globalMemories, userMemories, projectMemories] = await Promise.all([
39
113
  listMemories({ scope: 'global', workspaceRoot }),
@@ -77,67 +151,63 @@ export async function runDreamConsolidation({
77
151
  candidates.push(entry);
78
152
  }
79
153
 
80
- if (candidates.length === 0) {
81
- const report = { timestamp: new Date().toISOString(), filesRead, filesChanged: [], candidatesGenerated: inbox.length, promotions, rejections, archives };
82
- if (!dryRun && writeAudit) {
83
- const reportPath = await writeDreamAuditReport(report);
84
- report.auditReport = reportPath;
85
- }
86
- return { ok: true, dryRun, ...report };
87
- }
88
-
89
- /* ── Phase 2: LLM 批量评估(质量门控 + scope 分类 + 内容提炼) ── */
90
- const llmResults = dryRun
91
- ? candidates.map((e) => ({ id: e.id, action: 'keep', scope: 'global', kind: e.type || 'observation', content: e.details || e.summary, summary: e.summary, confidence: 0.9 }))
92
- : await evaluateInboxBatch({ entries: candidates, config, workspaceRoot });
154
+ if (candidates.length > 0) {
155
+ /* ── Phase 2: LLM 批量评估(质量门控 + scope 分类 + 内容提炼) ── */
156
+ const llmResults = dryRun
157
+ ? candidates.map((e) => ({ id: e.id, action: 'keep', scope: 'global', kind: e.type || 'observation', content: e.details || e.summary, summary: e.summary, confidence: 0.9 }))
158
+ : await evaluateInboxBatch({ entries: candidates, config, workspaceRoot });
93
159
 
94
- const resultMap = new Map(llmResults.map((r) => [r.id, r]));
160
+ const resultMap = new Map(llmResults.map((r) => [r.id, r]));
95
161
 
96
- /* ── Phase 3: 按评估结果 promote 或 archive ─────────────────── */
97
- for (const entry of candidates) {
98
- const evaluation = resultMap.get(entry.id);
162
+ /* ── Phase 3: 按评估结果 promote 或 archive ─────────────────── */
163
+ for (const entry of candidates) {
164
+ const evaluation = resultMap.get(entry.id);
99
165
 
100
- if (!evaluation || evaluation.action === 'discard') {
101
- const reason = evaluation?.reason || 'LLM discarded';
102
- if (!dryRun) await archiveEntry(entry, 'discarded-by-evaluator', reason);
103
- rejections.push({ summary: entry.summary, reason: `evaluator-discard: ${reason}` });
104
- continue;
105
- }
106
-
107
- const promoteScope = evaluation.scope || 'global';
108
- const lifecycle = chooseLifecycle(evaluation.kind);
109
- const enrichedEntry = {
110
- ...entry,
111
- /* 用 LLM 提炼后的内容覆盖原始报错 */
112
- summary: evaluation.summary || entry.summary,
113
- details: evaluation.content || entry.details || entry.summary,
114
- type: evaluation.kind || entry.type || 'observation'
115
- };
166
+ if (!evaluation || evaluation.action === 'discard') {
167
+ const reason = evaluation?.reason || 'LLM discarded';
168
+ if (!dryRun) await archiveEntry(entry, 'discarded-by-evaluator', reason);
169
+ rejections.push({ summary: entry.summary, reason: `evaluator-discard: ${reason}` });
170
+ continue;
171
+ }
116
172
 
117
- if (!dryRun) {
118
- try {
119
- await promoteMemory({
120
- entry: enrichedEntry,
121
- scope: promoteScope,
122
- lifecycle,
123
- workspaceRoot,
124
- config,
125
- confidence: evaluation.confidence || 0.8
126
- });
127
- filesChanged.push({ file: `memory/${promoteScope}.json`, why: `Promoted "${enrichedEntry.summary}" as ${lifecycle} (${promoteScope})` });
128
- promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence });
129
- } catch (error) {
130
- const reason = String(error?.message || error || 'promotion failed').slice(0, 180);
131
- await archiveEntry(entry, 'promotion-failed', reason);
132
- rejections.push({ summary: entry.summary, reason: `promotion-failed: ${reason}` });
133
- archives.push({ summary: entry.summary, reason: 'promotion-failed' });
173
+ const promoteScope = evaluation.scope || 'global';
174
+ const lifecycle = chooseLifecycle(evaluation.kind);
175
+ const enrichedEntry = {
176
+ ...entry,
177
+ /* 用 LLM 提炼后的内容覆盖原始报错 */
178
+ summary: evaluation.summary || entry.summary,
179
+ details: evaluation.content || entry.details || entry.summary,
180
+ type: evaluation.kind || entry.type || 'observation'
181
+ };
182
+
183
+ if (!dryRun) {
184
+ try {
185
+ await promoteMemory({
186
+ entry: enrichedEntry,
187
+ scope: promoteScope,
188
+ lifecycle,
189
+ workspaceRoot,
190
+ config,
191
+ confidence: evaluation.confidence || 0.8
192
+ });
193
+ filesChanged.push({ file: `memory/${promoteScope}.json`, why: `Promoted "${enrichedEntry.summary}" as ${lifecycle} (${promoteScope})` });
194
+ promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence });
195
+ } catch (error) {
196
+ const reason = String(error?.message || error || 'promotion failed').slice(0, 180);
197
+ await archiveEntry(entry, 'promotion-failed', reason);
198
+ rejections.push({ summary: entry.summary, reason: `promotion-failed: ${reason}` });
199
+ archives.push({ summary: entry.summary, reason: 'promotion-failed' });
200
+ }
201
+ continue;
134
202
  }
135
- continue;
136
- }
137
203
 
138
- promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence, dryRun: true });
204
+ promotions.push({ summary: enrichedEntry.summary, scope: promoteScope, lifecycle, rationale: evaluation.kind, confidence: evaluation.confidence, dryRun: true });
205
+ }
139
206
  }
140
207
 
208
+ const maintenance = await runMemoryMaintenance({ dryRun, scope: scopeFilter, workspaceRoot, config });
209
+ filesChanged.push(...maintenance.filesChanged);
210
+
141
211
  const report = {
142
212
  timestamp: new Date().toISOString(),
143
213
  filesRead,
@@ -145,7 +215,9 @@ export async function runDreamConsolidation({
145
215
  candidatesGenerated: inbox.length,
146
216
  promotions,
147
217
  rejections,
148
- archives
218
+ archives,
219
+ maintenance: maintenance.reports,
220
+ ...(inbox.length === 0 ? { message: 'No inbox entries to consolidate; maintained existing memory buckets.' } : {})
149
221
  };
150
222
 
151
223
  if (!dryRun && writeAudit) {
@@ -22,6 +22,26 @@ Rules:
22
22
  - General coding/environment knowledge → scope "global"
23
23
  - If in doubt, discard. Memory is expensive; only promote what future sessions will genuinely benefit from.`;
24
24
 
25
+ const MAINTENANCE_SYSTEM_PROMPT = `You are maintaining an existing persistent memory bucket for a coding assistant.
26
+
27
+ Your job:
28
+ 1. Merge duplicates and near-duplicates.
29
+ 2. Summarize clusters into fewer, higher-signal memories.
30
+ 3. Remove stale, contradictory, trivial, or overly specific noise.
31
+ 4. Preserve important exact commands, file paths, preferences, and constraints.
32
+ 5. Keep memories scoped exactly to the bucket you receive.
33
+
34
+ Respond with valid JSON only, no markdown fences:
35
+ {"items":[{"kind":"preference|workflow|pattern|observation|correction|decision|failure|architecture|module|note","content":"durable memory text","summary":"under 80 chars","confidence":0.5,"pinned":false,"lifecycle":"longterm|operational"}],"archives":[{"source_ids":["mem_..."],"reason":"merged|stale|duplicate|noise|contradiction"}]}
36
+
37
+ Rules:
38
+ - Prefer fewer, clearer items, but do not collapse unrelated facts.
39
+ - User preferences belong in user memory and should not become project rules.
40
+ - Project conventions belong in project memory and should not become user preferences.
41
+ - Global memory is only for reusable cross-project/tool/environment knowledge.
42
+ - If a pinned item is still valid, keep it.
43
+ - Return at least one item if the input has useful durable content.`;
44
+
25
45
  function parseResults(text) {
26
46
  try {
27
47
  const json = JSON.parse(text);
@@ -97,3 +117,69 @@ export async function evaluateInboxBatch({ entries, config, workspaceRoot }) {
97
117
  }));
98
118
  }
99
119
  }
120
+
121
+ function parseMaintenanceResult(text) {
122
+ try {
123
+ const json = JSON.parse(text);
124
+ const items = Array.isArray(json?.items) ? json.items : [];
125
+ const archives = Array.isArray(json?.archives) ? json.archives : [];
126
+ return {
127
+ items: items
128
+ .map((item) => ({
129
+ kind: String(item.kind || 'note').slice(0, 40),
130
+ content: String(item.content || '').slice(0, 600),
131
+ summary: String(item.summary || item.content || '').slice(0, 120),
132
+ confidence: Math.min(1, Math.max(0.5, Number(item.confidence) || 0.8)),
133
+ pinned: item.pinned === true,
134
+ lifecycle: ['longterm', 'operational'].includes(String(item.lifecycle || '')) ? String(item.lifecycle) : undefined
135
+ }))
136
+ .filter((item) => item.content.trim()),
137
+ archives: archives.map((archive) => ({
138
+ source_ids: Array.isArray(archive.source_ids) ? archive.source_ids.map((id) => String(id)).filter(Boolean) : [],
139
+ reason: String(archive.reason || '').slice(0, 160)
140
+ }))
141
+ };
142
+ } catch {
143
+ return { items: [], archives: [] };
144
+ }
145
+ }
146
+
147
+ export async function evaluateMemoryMaintenance({ scope, items, config, workspaceRoot }) {
148
+ const sourceItems = Array.isArray(items) ? items : [];
149
+ if (sourceItems.length === 0) return { items: [], archives: [] };
150
+
151
+ const compactItems = sourceItems.map((item) => ({
152
+ id: item.id,
153
+ kind: item.kind,
154
+ content: String(item.content || '').slice(0, 600),
155
+ summary: String(item.summary || '').slice(0, 160),
156
+ confidence: item.confidence,
157
+ pinned: item.pinned === true,
158
+ lifecycle: item.lifecycle || ''
159
+ }));
160
+
161
+ try {
162
+ const result = await createChatCompletion({
163
+ sdkProvider: config?.sdk?.provider,
164
+ baseUrl: config?.gateway?.base_url,
165
+ apiKey: config?.gateway?.api_key,
166
+ model: config?.model?.name,
167
+ messages: [
168
+ { role: 'system', content: MAINTENANCE_SYSTEM_PROMPT },
169
+ {
170
+ role: 'user',
171
+ content: `Maintain this ${scope} memory bucket. Workspace: ${workspaceRoot || process.cwd()}\n\n${JSON.stringify(compactItems, null, 2)}`
172
+ }
173
+ ],
174
+ temperature: 0,
175
+ timeoutMs: EVAL_TIMEOUT_MS
176
+ });
177
+ return parseMaintenanceResult(result?.text || '');
178
+ } catch (error) {
179
+ return {
180
+ items: sourceItems,
181
+ archives: [],
182
+ error: String(error?.message || error || 'memory maintenance failed')
183
+ };
184
+ }
185
+ }
@@ -111,7 +111,7 @@ class FffMcpClient {
111
111
  capabilities: {},
112
112
  clientInfo: {
113
113
  name: 'codemini-cli',
114
- version: '0.4.1'
114
+ version: '0.4.2'
115
115
  }
116
116
  });
117
117
  this.sendNotification('notifications/initialized', {});
@@ -41,22 +41,92 @@ async function ensureParent(filePath) {
41
41
  function buildFilePath(scope, workspaceRoot = process.cwd(), projectAlias = '') {
42
42
  if (scope === 'user') return path.join(getMemoryDir(), 'user.json');
43
43
  if (scope === 'global') return path.join(getMemoryDir(), 'global.json');
44
- return path.join(getProjectMemoryDir(workspaceRoot), `${getProjectMemoryKey(workspaceRoot, projectAlias)}.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
+ }
45
58
  }
46
59
 
47
60
  async function readMemoryBucket(filePath) {
61
+ const doc = await readMemoryBucketDocument(filePath);
62
+ return doc.items;
63
+ }
64
+
65
+ async function readMemoryBucketDocument(filePath) {
48
66
  try {
49
67
  const raw = await fs.readFile(filePath, 'utf8');
50
68
  const parsed = JSON.parse(raw);
51
- return Array.isArray(parsed?.items) ? parsed.items : [];
69
+ return {
70
+ items: Array.isArray(parsed?.items) ? parsed.items : [],
71
+ maintenance: parsed?.maintenance && typeof parsed.maintenance === 'object' ? parsed.maintenance : null
72
+ };
52
73
  } catch {
53
- return [];
74
+ return { items: [], maintenance: null };
54
75
  }
55
76
  }
56
77
 
57
- async function writeMemoryBucket(filePath, items) {
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 } = {}) {
58
93
  await ensureParent(filePath);
59
- await fs.writeFile(filePath, `${JSON.stringify({ items }, null, 2)}\n`, 'utf8');
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)));
60
130
  }
61
131
 
62
132
  function normalizeMemoryItem(item, scope, projectKey = '') {
@@ -74,7 +144,8 @@ function normalizeMemoryItem(item, scope, projectKey = '') {
74
144
  createdAt: String(item?.createdAt || now),
75
145
  updatedAt: String(item?.updatedAt || now),
76
146
  hits: Number.isFinite(Number(item?.hits)) ? Number(item.hits) : 0,
77
- pinned: item?.pinned === true
147
+ pinned: item?.pinned === true,
148
+ ...(item?.lifecycle ? { lifecycle: String(item.lifecycle) } : {})
78
149
  };
79
150
  }
80
151
 
@@ -96,13 +167,69 @@ function budgetForScope(scope, config = {}) {
96
167
  }
97
168
 
98
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
+ } = {}) {
99
214
  const normalizedScope = ensureScope(scope);
100
215
  const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
101
216
  const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
102
- const items = await readMemoryBucket(filePath);
103
- return items
217
+ const normalizedItems = (Array.isArray(items) ? items : [])
104
218
  .map((item) => normalizeMemoryItem(item, normalizedScope, projectKey))
105
- .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
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
+ };
106
233
  }
107
234
 
108
235
  export async function rememberMemory({
@@ -125,7 +252,7 @@ export async function rememberMemory({
125
252
 
126
253
  const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
127
254
  const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
128
- const existing = (await readMemoryBucket(filePath)).map((item) => normalizeMemoryItem(item, normalizedScope, projectKey));
255
+ const existing = await readScopeMemoryItems(normalizedScope, workspaceRoot, projectAlias);
129
256
  const probe = normalizeMemoryItem({ content: normalizedContent, kind, summary, source, confidence, pinned }, normalizedScope, projectKey);
130
257
 
131
258
  const replaceIndex = replaceSimilar ? existing.findIndex((item) => sameMemory(item, probe)) : -1;
@@ -170,6 +297,14 @@ export async function forgetMemory({ scope, id, workspaceRoot = process.cwd(), p
170
297
  const existing = await listMemories({ scope: normalizedScope, workspaceRoot, projectAlias });
171
298
  const kept = existing.filter((item) => item.id !== id);
172
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
+ }
173
308
  return { removed: existing.length - kept.length };
174
309
  }
175
310