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.
- package/OPERATIONS.md +4 -0
- package/README.md +199 -133
- package/package.json +2 -1
- package/src/commands/chat.js +1 -0
- package/src/commands/run.js +6 -2
- package/src/core/agent-loop.js +20 -19
- package/src/core/chat-runtime.js +567 -233
- package/src/core/checkpoint-store.js +2 -3
- package/src/core/command-policy.js +144 -10
- package/src/core/config-store.js +36 -10
- package/src/core/context-compact.js +7 -1
- package/src/core/default-system-prompt.js +12 -1
- package/src/core/memory-policy.js +33 -0
- package/src/core/memory-prompt.js +45 -0
- package/src/core/memory-store.js +181 -0
- package/src/core/paths.js +8 -0
- package/src/core/provider/anthropic.js +388 -0
- package/src/core/provider/index.js +37 -0
- package/src/core/session-store.js +4 -0
- package/src/core/shell-profile.js +29 -17
- package/src/core/todo-state.js +19 -0
- package/src/core/tools.js +486 -235
- package/src/tui/chat-app.js +278 -57
- package/src/tui/tool-activity/presenters/command.js +8 -15
- package/src/tui/tool-activity/presenters/misc.js +2 -5
- package/src/core/task-store.js +0 -117
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
allowed: false,
|
|
57
|
-
|
|
58
|
-
|
|
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, '\\');
|
package/src/core/config-store.js
CHANGED
|
@@ -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
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
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
|
-
'
|
|
140
|
-
'
|
|
141
|
-
'
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|