codemini-cli 0.3.1 → 0.3.3

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.
@@ -16,7 +16,7 @@ function makeId(name = '') {
16
16
  return `${stamp}-${slug || 'checkpoint'}`;
17
17
  }
18
18
 
19
- export async function createCheckpoint({ name, session, config, tasks }, cwd = process.cwd()) {
19
+ export async function createCheckpoint({ name, session, config }, cwd = process.cwd()) {
20
20
  const dir = checkpointsDir(cwd);
21
21
  await fs.mkdir(dir, { recursive: true });
22
22
  const id = makeId(name);
@@ -26,8 +26,7 @@ export async function createCheckpoint({ name, session, config, tasks }, cwd = p
26
26
  name: String(name || ''),
27
27
  createdAt: new Date().toISOString(),
28
28
  session,
29
- config,
30
- tasks: Array.isArray(tasks) ? tasks : []
29
+ config
31
30
  };
32
31
  await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
33
32
  return payload;
@@ -8,6 +8,138 @@ function firstToken(command) {
8
8
  return base.replace(/\.exe$/i, '');
9
9
  }
10
10
 
11
+ function splitCommandSegments(command) {
12
+ const text = String(command || '').trim();
13
+ if (!text) return [];
14
+ const segments = [];
15
+ let current = '';
16
+ let quote = '';
17
+ let escapeNext = false;
18
+
19
+ for (let i = 0; i < text.length; i += 1) {
20
+ const ch = text[i];
21
+ const next = text[i + 1];
22
+
23
+ if (escapeNext) {
24
+ current += ch;
25
+ escapeNext = false;
26
+ continue;
27
+ }
28
+
29
+ if (ch === '\\' && quote !== '\'') {
30
+ current += ch;
31
+ escapeNext = true;
32
+ continue;
33
+ }
34
+
35
+ if (quote) {
36
+ current += ch;
37
+ if (ch === quote) quote = '';
38
+ continue;
39
+ }
40
+
41
+ if (ch === '"' || ch === '\'') {
42
+ quote = ch;
43
+ current += ch;
44
+ continue;
45
+ }
46
+
47
+ if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) {
48
+ if (current.trim()) segments.push(current.trim());
49
+ current = '';
50
+ i += 1;
51
+ continue;
52
+ }
53
+
54
+ if (ch === '|' || ch === ';' || ch === '&') {
55
+ if (current.trim()) segments.push(current.trim());
56
+ current = '';
57
+ continue;
58
+ }
59
+
60
+ current += ch;
61
+ }
62
+
63
+ if (current.trim()) segments.push(current.trim());
64
+ return segments;
65
+ }
66
+
67
+ function tokenizeTopLevel(command) {
68
+ const text = String(command || '').trim();
69
+ if (!text) return [];
70
+ const tokens = [];
71
+ let current = '';
72
+ let quote = '';
73
+ let escapeNext = false;
74
+
75
+ for (let i = 0; i < text.length; i += 1) {
76
+ const ch = text[i];
77
+ if (escapeNext) {
78
+ current += ch;
79
+ escapeNext = false;
80
+ continue;
81
+ }
82
+ if (ch === '\\' && quote !== '\'') {
83
+ escapeNext = true;
84
+ continue;
85
+ }
86
+ if (quote) {
87
+ if (ch === quote) {
88
+ quote = '';
89
+ } else {
90
+ current += ch;
91
+ }
92
+ continue;
93
+ }
94
+ if (ch === '"' || ch === '\'') {
95
+ quote = ch;
96
+ continue;
97
+ }
98
+ if (/\s/.test(ch)) {
99
+ if (current) {
100
+ tokens.push(current);
101
+ current = '';
102
+ }
103
+ continue;
104
+ }
105
+ current += ch;
106
+ }
107
+
108
+ if (current) tokens.push(current);
109
+ return tokens;
110
+ }
111
+
112
+ function unwrapShellPayload(command) {
113
+ const tokens = tokenizeTopLevel(command);
114
+ const token = firstToken(command);
115
+ if (!['bash', 'sh', 'zsh', 'powershell', 'pwsh', 'cmd'].includes(token)) return '';
116
+
117
+ const index = tokens.findIndex((item, itemIndex) => {
118
+ if (token === 'cmd') return itemIndex > 0 && /^\/c$/i.test(item);
119
+ return /^-(?:c|lc|command)$/i.test(item);
120
+ });
121
+ if (index < 0 || index + 1 >= tokens.length) return '';
122
+ return tokens.slice(index + 1).join(' ').trim();
123
+ }
124
+
125
+ function collectCommandTokens(command) {
126
+ const cmd = String(command || '').trim();
127
+ if (!cmd) return [];
128
+
129
+ const chained = splitCommandSegments(cmd);
130
+ if (chained.length > 1) {
131
+ return chained.flatMap((segment) => collectCommandTokens(segment));
132
+ }
133
+
134
+ const token = firstToken(cmd);
135
+ const out = token ? [{ token, raw: cmd }] : [];
136
+ const wrapped = unwrapShellPayload(cmd);
137
+ if (wrapped && wrapped !== cmd) {
138
+ out.push(...collectCommandTokens(wrapped));
139
+ }
140
+ return out;
141
+ }
142
+
11
143
  function includesAny(haystackLower, patterns = []) {
12
144
  return patterns.some((p) => haystackLower.includes(String(p).toLowerCase()));
13
145
  }
@@ -46,17 +178,19 @@ export function evaluateCommandPolicy(command, config, workspaceRoot = process.c
46
178
  }
47
179
 
48
180
  const token = firstToken(cmd);
49
- if (includesAny(token, policy.blocked_commands)) {
50
- return { allowed: false, reason: `blocked command: ${token}`, suggestion: suggestionForToken(token, config) };
51
- }
52
-
181
+ const inspectedTokens = collectCommandTokens(cmd);
53
182
  const allowlist = Array.isArray(policy.command_allowlist) ? policy.command_allowlist : [];
54
- if (allowlist.length > 0 && !allowlist.includes(token)) {
55
- return {
56
- allowed: false,
57
- reason: `command not in allowlist: ${token}`,
58
- suggestion: suggestionForToken(token, config)
59
- };
183
+ for (const item of inspectedTokens) {
184
+ if (includesAny(item.token, policy.blocked_commands)) {
185
+ return { allowed: false, reason: `blocked command: ${item.token}`, suggestion: suggestionForToken(item.token, config) };
186
+ }
187
+ if (allowlist.length > 0 && !allowlist.includes(item.token)) {
188
+ return {
189
+ allowed: false,
190
+ reason: `command not in allowlist: ${item.token}`,
191
+ suggestion: suggestionForToken(item.token, config)
192
+ };
193
+ }
60
194
  }
61
195
 
62
196
  const workspaceLower = String(workspaceRoot).toLowerCase().replace(/\//g, '\\');
@@ -13,6 +13,9 @@ function normalizeUiLanguage(value) {
13
13
  }
14
14
 
15
15
  const DEFAULT_CONFIG = {
16
+ sdk: {
17
+ provider: 'openai-compatible'
18
+ },
16
19
  gateway: {
17
20
  base_url: 'http://127.0.0.1:8000/v1',
18
21
  api_key: '',
@@ -43,11 +46,9 @@ const DEFAULT_CONFIG = {
43
46
  'run',
44
47
  'patch',
45
48
  'generate_diff',
46
- 'start_service',
47
- 'list_services',
48
- 'get_service_status',
49
- 'get_service_logs',
50
- 'stop_service'
49
+ 'list_background_tasks',
50
+ 'get_background_task',
51
+ 'stop_background_task'
51
52
  ],
52
53
  max_steps: 16
53
54
  },
@@ -63,6 +64,17 @@ const DEFAULT_CONFIG = {
63
64
  language: 'zh',
64
65
  reply_language: 'zh'
65
66
  },
67
+ memory: {
68
+ enabled: true,
69
+ auto_write: true,
70
+ inject_on_session_start: true,
71
+ max_items_per_scope: 12,
72
+ max_prompt_chars: 4000,
73
+ max_user_chars: 1375,
74
+ max_global_chars: 2200,
75
+ max_project_chars: 2200,
76
+ project_binding: 'path-or-alias'
77
+ },
66
78
  soul: {
67
79
  preset: 'default',
68
80
  custom_path: ''
@@ -117,6 +129,10 @@ function uniqueStrings(items = []) {
117
129
 
118
130
  function normalizePolicyLists(config) {
119
131
  const next = structuredClone(config);
132
+ next.sdk = next.sdk || {};
133
+ next.sdk.provider = ['openai-compatible', 'anthropic'].includes(String(next.sdk.provider || '').toLowerCase())
134
+ ? String(next.sdk.provider).toLowerCase()
135
+ : 'openai-compatible';
120
136
  next.shell = next.shell || {};
121
137
  next.shell.default = normalizeShellName(next.shell.default);
122
138
  next.execution = next.execution || {};
@@ -136,17 +152,27 @@ function normalizePolicyLists(config) {
136
152
  'write',
137
153
  'run',
138
154
  'generate_diff',
139
- 'start_service',
140
- 'list_services',
141
- 'get_service_status',
142
- 'get_service_logs',
143
- 'stop_service',
155
+ 'list_background_tasks',
156
+ 'get_background_task',
157
+ 'stop_background_task',
144
158
  ...rawTools
145
159
  ].filter((name) => String(name) !== 'list_files')
146
160
  );
147
161
  next.ui = next.ui || {};
148
162
  next.ui.language = normalizeUiLanguage(next.ui.language);
149
163
  next.ui.reply_language = normalizeReplyLanguage(next.ui.reply_language);
164
+ next.memory = next.memory || {};
165
+ next.memory.enabled = next.memory.enabled !== false;
166
+ next.memory.auto_write = next.memory.auto_write !== false;
167
+ next.memory.inject_on_session_start = next.memory.inject_on_session_start !== false;
168
+ next.memory.max_items_per_scope = Math.max(1, Number(next.memory.max_items_per_scope || 12));
169
+ next.memory.max_prompt_chars = Math.max(200, Number(next.memory.max_prompt_chars || 4000));
170
+ next.memory.max_user_chars = Math.max(80, Number(next.memory.max_user_chars || 1375));
171
+ next.memory.max_global_chars = Math.max(80, Number(next.memory.max_global_chars || 2200));
172
+ next.memory.max_project_chars = Math.max(80, Number(next.memory.max_project_chars || 2200));
173
+ next.memory.project_binding = ['path', 'alias', 'path-or-alias'].includes(String(next.memory.project_binding || ''))
174
+ ? String(next.memory.project_binding)
175
+ : 'path-or-alias';
150
176
  next.policy = next.policy || {};
151
177
  next.policy.command_allowlist = uniqueStrings(
152
178
  Array.isArray(next.policy.command_allowlist) ? next.policy.command_allowlist : []
@@ -19,7 +19,13 @@ export function estimateMessagesTokens(messages) {
19
19
  for (const message of messages || []) {
20
20
  const roleOverhead = 6;
21
21
  const text = textFromContent(message.content);
22
- total += roleOverhead + Math.ceil(text.length / 4);
22
+ let asciiChars = 0;
23
+ let nonAsciiChars = 0;
24
+ for (const char of text) {
25
+ if (char.charCodeAt(0) <= 0x7f) asciiChars += 1;
26
+ else nonAsciiChars += 1;
27
+ }
28
+ total += roleOverhead + Math.ceil(asciiChars / 4) + Math.ceil(nonAsciiChars / 2);
23
29
  }
24
30
  return total;
25
31
  }
@@ -18,6 +18,11 @@ Assistant: first narrow the search with the project index
18
18
  Tool: query_project_index({"query":"auth flow","path":"src","max_results":3})
19
19
  Tool: read({"file_path":"${cwd}/src/auth/service.ts"})
20
20
 
21
+ If the visible tool list does not include a needed capability, load it with tool_search instead of assuming it does not exist.
22
+ Example:
23
+ Tool: tool_search({"query":"glob"})
24
+ Tool: glob({"pattern":"src/**/*.ts"})
25
+
21
26
  2. Targeted search then exact text edit
22
27
  User: rename loginUser to signInUser
23
28
  Assistant: first find the exact occurrences
@@ -29,7 +34,13 @@ User: inspect the reducer around line 120
29
34
  Assistant: read only the needed range
30
35
  Tool: read({"path":"${cwd}/src/store/reducer.ts:110-150"})
31
36
 
32
- 4. Create a new file
37
+ 4. Track a complex task with todos
38
+ User: update the login flow and verify it
39
+ Assistant: create a focused todo checklist before starting
40
+ Tool: update_todos({"todos":[{"content":"Inspect the current login flow","activeForm":"Inspecting the current login flow","status":"in_progress"},{"content":"Implement the requested login changes","activeForm":"Implementing the requested login changes","status":"pending"},{"content":"Run focused verification for the login flow","activeForm":"Running focused verification for the login flow","status":"pending"}]})
41
+ Assistant: keep the checklist updated as each phase finishes, and do not give a completion-style wrap-up until the checklist is complete or a blocker is recorded
42
+
43
+ 5. Create a new file
33
44
  User: add a notes file
34
45
  Assistant: create the file directly
35
46
  Tool: write({"file":"${cwd}/notes.txt","text":"todo\\n"})
@@ -0,0 +1,33 @@
1
+ const SECRET_PATTERNS = [
2
+ /\b(api[_-]?key|token|secret|password|passwd|bearer)\b/i,
3
+ /\b(database_url|aws_secret_access_key|aws_access_key_id|openai_api_key|github_token|github_pat|slack_bot_token)\b\s*[:=]\s*\S+/i,
4
+ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^/\s:@]+:[^@\s]+@/i,
5
+ /\bAKIA[0-9A-Z]{16}\b/,
6
+ /\bghp_[a-z0-9]{20,}\b/i,
7
+ /\bgithub_pat_[a-z0-9_]{20,}\b/i,
8
+ /\bglpat-[a-z0-9_-]{20,}\b/i,
9
+ /\bsk-[a-z0-9]{8,}\b/i,
10
+ /-----BEGIN [A-Z ]+PRIVATE KEY-----/i
11
+ ];
12
+
13
+ export function normalizeMemoryText(value) {
14
+ return String(value || '').replace(/\s+/g, ' ').trim();
15
+ }
16
+
17
+ export function isSensitiveMemoryContent(value) {
18
+ const text = normalizeMemoryText(value);
19
+ if (!text) return false;
20
+ return SECRET_PATTERNS.some((pattern) => pattern.test(text));
21
+ }
22
+
23
+ export function assertSafeMemoryContent(value) {
24
+ if (isSensitiveMemoryContent(value)) {
25
+ throw new Error('Refusing to store sensitive or secret-like memory content');
26
+ }
27
+ }
28
+
29
+ export function summarizeMemoryContent(value, maxChars = 72) {
30
+ const text = normalizeMemoryText(value);
31
+ if (text.length <= maxChars) return text;
32
+ return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
33
+ }
@@ -0,0 +1,45 @@
1
+ import { listMemories } from './memory-store.js';
2
+
3
+ function renderScope(title, items = []) {
4
+ if (!Array.isArray(items) || items.length === 0) return '';
5
+ const lines = items.map((item) =>
6
+ [
7
+ `- [${item.kind}] summary=${JSON.stringify(String(item.summary || item.content || ''))}`,
8
+ ` exact_text=${JSON.stringify(String(item.content || ''))}`
9
+ ].join('\n')
10
+ );
11
+ return `${title}\n${lines.join('\n')}`;
12
+ }
13
+
14
+ export async function buildMemorySnapshot({
15
+ config = {},
16
+ workspaceRoot = process.cwd()
17
+ }) {
18
+ if (config?.memory?.enabled === false || config?.memory?.inject_on_session_start === false) return '';
19
+
20
+ const [user, globalItems, project] = await Promise.all([
21
+ listMemories({ scope: 'user', workspaceRoot }),
22
+ listMemories({ scope: 'global', workspaceRoot }),
23
+ listMemories({ scope: 'project', workspaceRoot })
24
+ ]);
25
+
26
+ const maxItems = Math.max(1, Number(config?.memory?.max_items_per_scope || 12));
27
+ const sections = [
28
+ renderScope('User Memory:', user.slice(0, maxItems)),
29
+ renderScope('Global Memory:', globalItems.slice(0, maxItems)),
30
+ renderScope('Project Memory:', project.slice(0, maxItems))
31
+ ].filter(Boolean);
32
+
33
+ if (sections.length === 0) return '';
34
+
35
+ const snapshot = [
36
+ 'Persistent Memory:',
37
+ 'Use these durable notes only as stable guidance. Prefer fresh reads when code or files can verify the answer.',
38
+ 'When recalling memory, preserve command names, file paths, identifiers, and punctuation exactly. Do not rewrite exact_text values.',
39
+ ...sections
40
+ ].join('\n\n');
41
+
42
+ const maxChars = Math.max(200, Number(config?.memory?.max_prompt_chars || 4000));
43
+ if (snapshot.length <= maxChars) return snapshot;
44
+ return `${snapshot.slice(0, maxChars - 3)}...`;
45
+ }
@@ -0,0 +1,181 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { sha1 } from './crypto-utils.js';
4
+ import { getMemoryDir, getProjectMemoryDir } 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)}-${sha1(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), `${getProjectMemoryKey(workspaceRoot, projectAlias)}.json`);
45
+ }
46
+
47
+ async function readMemoryBucket(filePath) {
48
+ try {
49
+ const raw = await fs.readFile(filePath, 'utf8');
50
+ const parsed = JSON.parse(raw);
51
+ return Array.isArray(parsed?.items) ? parsed.items : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ async function writeMemoryBucket(filePath, items) {
58
+ await ensureParent(filePath);
59
+ await fs.writeFile(filePath, `${JSON.stringify({ items }, null, 2)}\n`, 'utf8');
60
+ }
61
+
62
+ function normalizeMemoryItem(item, scope, projectKey = '') {
63
+ const now = nowIso();
64
+ const content = normalizeMemoryText(item?.content || '');
65
+ return {
66
+ id: String(item?.id || `mem_${sha1(`${scope}:${projectKey}:${content}:${now}:${Math.random()}`).slice(0, 12)}`),
67
+ scope,
68
+ projectKey: projectKey || undefined,
69
+ kind: String(item?.kind || 'note').trim() || 'note',
70
+ content,
71
+ summary: normalizeMemoryText(item?.summary || summarizeMemoryContent(content)),
72
+ source: String(item?.source || 'tool').trim() || 'tool',
73
+ confidence: Number.isFinite(Number(item?.confidence)) ? Number(item.confidence) : 0.9,
74
+ createdAt: String(item?.createdAt || now),
75
+ updatedAt: String(item?.updatedAt || now),
76
+ hits: Number.isFinite(Number(item?.hits)) ? Number(item.hits) : 0,
77
+ pinned: item?.pinned === true
78
+ };
79
+ }
80
+
81
+ function sameMemory(left, right) {
82
+ const a = normalizeMemoryText(left?.content);
83
+ const b = normalizeMemoryText(right?.content);
84
+ if (!a || !b) return false;
85
+ return a === b;
86
+ }
87
+
88
+ function measureMemoryChars(item) {
89
+ return normalizeMemoryText(item?.content).length + normalizeMemoryText(item?.summary).length;
90
+ }
91
+
92
+ function budgetForScope(scope, config = {}) {
93
+ if (scope === 'user') return Math.max(80, Number(config?.memory?.max_user_chars || 1375));
94
+ if (scope === 'global') return Math.max(80, Number(config?.memory?.max_global_chars || 2200));
95
+ return Math.max(80, Number(config?.memory?.max_project_chars || 2200));
96
+ }
97
+
98
+ export async function listMemories({ scope, workspaceRoot = process.cwd(), projectAlias = '' }) {
99
+ const normalizedScope = ensureScope(scope);
100
+ const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
101
+ const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
102
+ const items = await readMemoryBucket(filePath);
103
+ return items
104
+ .map((item) => normalizeMemoryItem(item, normalizedScope, projectKey))
105
+ .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
106
+ }
107
+
108
+ export async function rememberMemory({
109
+ scope,
110
+ content,
111
+ kind = 'note',
112
+ summary = '',
113
+ source = 'tool',
114
+ confidence = 0.9,
115
+ replaceSimilar = true,
116
+ pinned = false,
117
+ workspaceRoot = process.cwd(),
118
+ projectAlias = '',
119
+ config = {}
120
+ }) {
121
+ const normalizedScope = ensureScope(scope);
122
+ const normalizedContent = normalizeMemoryText(content);
123
+ if (!normalizedContent) throw new Error('Memory content is required');
124
+ assertSafeMemoryContent(normalizedContent);
125
+
126
+ const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
127
+ const projectKey = normalizedScope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
128
+ const existing = (await readMemoryBucket(filePath)).map((item) => normalizeMemoryItem(item, normalizedScope, projectKey));
129
+ const probe = normalizeMemoryItem({ content: normalizedContent, kind, summary, source, confidence, pinned }, normalizedScope, projectKey);
130
+
131
+ const replaceIndex = replaceSimilar ? existing.findIndex((item) => sameMemory(item, probe)) : -1;
132
+ let saved;
133
+ if (replaceIndex >= 0) {
134
+ saved = {
135
+ ...existing[replaceIndex],
136
+ ...probe,
137
+ id: existing[replaceIndex].id,
138
+ createdAt: existing[replaceIndex].createdAt,
139
+ updatedAt: nowIso()
140
+ };
141
+ existing.splice(replaceIndex, 1, saved);
142
+ } else {
143
+ saved = probe;
144
+ existing.unshift(saved);
145
+ }
146
+
147
+ const maxItems = Math.max(1, Number(config?.memory?.max_items_per_scope || 12));
148
+ const maxChars = budgetForScope(normalizedScope, config);
149
+ const deduped = [];
150
+ const seen = new Set();
151
+ for (const item of existing) {
152
+ const key = `${item.kind}:${normalizeMemoryText(item.content)}`;
153
+ if (seen.has(key)) continue;
154
+ seen.add(key);
155
+ deduped.push(item);
156
+ if (deduped.length >= maxItems) break;
157
+ }
158
+ let totalChars = deduped.reduce((sum, item) => sum + measureMemoryChars(item), 0);
159
+ while (deduped.length > 1 && totalChars > maxChars) {
160
+ const removed = deduped.pop();
161
+ totalChars -= measureMemoryChars(removed);
162
+ }
163
+ await writeMemoryBucket(filePath, deduped);
164
+ return saved;
165
+ }
166
+
167
+ export async function forgetMemory({ scope, id, workspaceRoot = process.cwd(), projectAlias = '' }) {
168
+ const normalizedScope = ensureScope(scope);
169
+ const filePath = buildFilePath(normalizedScope, workspaceRoot, projectAlias);
170
+ const existing = await listMemories({ scope: normalizedScope, workspaceRoot, projectAlias });
171
+ const kept = existing.filter((item) => item.id !== id);
172
+ await writeMemoryBucket(filePath, kept);
173
+ return { removed: existing.length - kept.length };
174
+ }
175
+
176
+ export async function searchMemories({ scope, query, workspaceRoot = process.cwd(), projectAlias = '' }) {
177
+ const items = await listMemories({ scope, workspaceRoot, projectAlias });
178
+ const needle = normalizeMemoryText(query).toLowerCase();
179
+ if (!needle) return items;
180
+ return items.filter((item) => item.content.toLowerCase().includes(needle) || item.summary.toLowerCase().includes(needle));
181
+ }
package/src/core/paths.js CHANGED
@@ -50,6 +50,10 @@ export function getCommandsDir() {
50
50
  return path.join(getBaseConfigDir(), 'commands');
51
51
  }
52
52
 
53
+ export function getMemoryDir() {
54
+ return path.join(getBaseConfigDir(), 'memory');
55
+ }
56
+
53
57
  export function getInputHistoryFilePath() {
54
58
  return path.join(getBaseConfigDir(), 'input-history.json');
55
59
  }
@@ -101,3 +105,7 @@ export function getFileIndexPath(cwd = process.cwd()) {
101
105
  export function getProjectIndexDir(cwd = process.cwd()) {
102
106
  return path.join(cwd, PROJECT_INDEX_DIR);
103
107
  }
108
+
109
+ export function getProjectMemoryDir(cwd = process.cwd()) {
110
+ return path.join(getProjectIndexDir(cwd), 'memory');
111
+ }