codemini-cli 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -1
- package/deployment.md +6 -6
- package/package.json +6 -1
- package/skills/brainstorm/SKILL.md +49 -29
- package/skills/superpowers-lite/SKILL.md +82 -90
- package/skills/writing-plans/SKILL.md +67 -0
- package/src/commands/chat.js +51 -47
- package/src/commands/doctor.js +27 -7
- package/src/commands/run.js +36 -28
- package/src/core/agent-loop.js +191 -10
- package/src/core/chat-runtime.js +170 -11
- package/src/core/command-evaluator.js +66 -0
- package/src/core/command-policy.js +16 -0
- package/src/core/command-risk.js +148 -0
- package/src/core/config-store.js +7 -0
- package/src/core/constants.js +0 -1
- package/src/core/default-system-prompt.js +27 -0
- package/src/core/dream-audit.js +93 -0
- package/src/core/dream-consolidate.js +157 -0
- package/src/core/dream-evaluator.js +99 -0
- package/src/core/fff-adapter.js +386 -0
- package/src/core/memory-prompt.js +23 -0
- package/src/core/memory-store.js +228 -1
- package/src/core/paths.js +13 -1
- package/src/core/project-index.js +2 -2
- package/src/core/shell-profile.js +5 -1
- package/src/core/tool-output.js +184 -0
- package/src/core/tools.js +425 -110
- package/src/tui/chat-app.js +376 -47
- package/src/tui/tool-activity/presenters/system.js +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { LANGUAGE_FILE_TYPES } from './constants.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_COMMAND = 'fff-mcp';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
function clampNumber(value, min, max, fallback) {
|
|
8
|
+
const num = Number(value);
|
|
9
|
+
if (!Number.isFinite(num)) return fallback;
|
|
10
|
+
return Math.min(max, Math.max(min, num));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function encodeMessage(payload) {
|
|
14
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
15
|
+
return Buffer.concat([
|
|
16
|
+
Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'),
|
|
17
|
+
body
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createMessageParser(onMessage) {
|
|
22
|
+
let buffer = Buffer.alloc(0);
|
|
23
|
+
return (chunk) => {
|
|
24
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
25
|
+
while (buffer.length > 0) {
|
|
26
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
27
|
+
if (headerEnd < 0) return;
|
|
28
|
+
const headerText = buffer.slice(0, headerEnd).toString('utf8');
|
|
29
|
+
const match = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
30
|
+
if (!match) {
|
|
31
|
+
buffer = Buffer.alloc(0);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const bodyLength = Number(match[1]);
|
|
35
|
+
const totalLength = headerEnd + 4 + bodyLength;
|
|
36
|
+
if (buffer.length < totalLength) return;
|
|
37
|
+
const body = buffer.slice(headerEnd + 4, totalLength).toString('utf8');
|
|
38
|
+
buffer = buffer.slice(totalLength);
|
|
39
|
+
try {
|
|
40
|
+
onMessage(JSON.parse(body));
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore malformed frames.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class FffMcpClient {
|
|
49
|
+
constructor({ workspaceRoot, command, timeoutMs }) {
|
|
50
|
+
this.workspaceRoot = workspaceRoot;
|
|
51
|
+
this.command = command;
|
|
52
|
+
this.timeoutMs = timeoutMs;
|
|
53
|
+
this.child = null;
|
|
54
|
+
this.pending = new Map();
|
|
55
|
+
this.nextId = 1;
|
|
56
|
+
this.connectPromise = null;
|
|
57
|
+
this.connected = false;
|
|
58
|
+
this.closed = false;
|
|
59
|
+
this.parser = createMessageParser((message) => this.handleMessage(message));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleMessage(message) {
|
|
63
|
+
if (!message || typeof message !== 'object') return;
|
|
64
|
+
if (typeof message.id !== 'number') return;
|
|
65
|
+
const pending = this.pending.get(message.id);
|
|
66
|
+
if (!pending) return;
|
|
67
|
+
this.pending.delete(message.id);
|
|
68
|
+
if (message.error) {
|
|
69
|
+
pending.reject(new Error(String(message.error?.message || 'Unknown MCP error')));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
pending.resolve(message.result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async connect() {
|
|
76
|
+
if (this.connected) return;
|
|
77
|
+
if (this.connectPromise) return this.connectPromise;
|
|
78
|
+
this.connectPromise = this.start();
|
|
79
|
+
try {
|
|
80
|
+
await this.connectPromise;
|
|
81
|
+
this.connected = true;
|
|
82
|
+
} finally {
|
|
83
|
+
this.connectPromise = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async start() {
|
|
88
|
+
if (this.closed) {
|
|
89
|
+
throw new Error('FFF MCP client already disposed');
|
|
90
|
+
}
|
|
91
|
+
this.child = spawn(this.command, [], {
|
|
92
|
+
cwd: this.workspaceRoot,
|
|
93
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
this.child.stdout.on('data', this.parser);
|
|
97
|
+
this.child.stderr.on('data', () => {});
|
|
98
|
+
this.child.on('error', (error) => {
|
|
99
|
+
this.rejectAll(error);
|
|
100
|
+
});
|
|
101
|
+
this.child.on('exit', (code) => {
|
|
102
|
+
this.connected = false;
|
|
103
|
+
this.child = null;
|
|
104
|
+
if (!this.closed && code !== 0) {
|
|
105
|
+
this.rejectAll(new Error(`FFF MCP exited with code ${code}`));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await this.sendRequest('initialize', {
|
|
110
|
+
protocolVersion: '2024-11-05',
|
|
111
|
+
capabilities: {},
|
|
112
|
+
clientInfo: {
|
|
113
|
+
name: 'codemini-cli',
|
|
114
|
+
version: '0.4.0'
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
this.sendNotification('notifications/initialized', {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
rejectAll(error) {
|
|
121
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
reject(error);
|
|
124
|
+
}
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sendNotification(method, params) {
|
|
129
|
+
if (!this.child?.stdin) throw new Error('FFF MCP client is not connected');
|
|
130
|
+
this.child.stdin.write(
|
|
131
|
+
encodeMessage({
|
|
132
|
+
jsonrpc: '2.0',
|
|
133
|
+
method,
|
|
134
|
+
params
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
sendRequest(method, params) {
|
|
140
|
+
if (!this.child?.stdin) {
|
|
141
|
+
return Promise.reject(new Error('FFF MCP client is not connected'));
|
|
142
|
+
}
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const id = this.nextId++;
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
this.pending.delete(id);
|
|
147
|
+
reject(new Error(`FFF MCP request timed out after ${this.timeoutMs}ms`));
|
|
148
|
+
}, this.timeoutMs);
|
|
149
|
+
this.pending.set(id, {
|
|
150
|
+
resolve: (result) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve(result);
|
|
153
|
+
},
|
|
154
|
+
reject: (error) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
reject(error);
|
|
157
|
+
},
|
|
158
|
+
timer
|
|
159
|
+
});
|
|
160
|
+
this.child.stdin.write(
|
|
161
|
+
encodeMessage({
|
|
162
|
+
jsonrpc: '2.0',
|
|
163
|
+
id,
|
|
164
|
+
method,
|
|
165
|
+
params
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async callTool(name, args) {
|
|
172
|
+
await this.connect();
|
|
173
|
+
return this.sendRequest('tools/call', {
|
|
174
|
+
name,
|
|
175
|
+
arguments: args
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async dispose() {
|
|
180
|
+
this.closed = true;
|
|
181
|
+
this.connected = false;
|
|
182
|
+
if (this.child?.stdin) {
|
|
183
|
+
try {
|
|
184
|
+
this.child.stdin.end();
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
if (this.child && !this.child.killed) {
|
|
188
|
+
this.child.kill();
|
|
189
|
+
}
|
|
190
|
+
this.child = null;
|
|
191
|
+
this.rejectAll(new Error('FFF MCP client disposed'));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractTextContent(result) {
|
|
196
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
197
|
+
return content
|
|
198
|
+
.filter((item) => item?.type === 'text' && typeof item?.text === 'string')
|
|
199
|
+
.map((item) => item.text)
|
|
200
|
+
.join('\n')
|
|
201
|
+
.trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stripFffFileSuffix(line) {
|
|
205
|
+
return String(line || '')
|
|
206
|
+
.replace(/\s+-\s+(?:hot|warm|frequent)(?:\s+git:[a-z_]+)?$/i, '')
|
|
207
|
+
.replace(/\s+git:[a-z_]+$/i, '')
|
|
208
|
+
.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseFindFilesOutput(text) {
|
|
212
|
+
const lines = String(text || '')
|
|
213
|
+
.split(/\r?\n/)
|
|
214
|
+
.map((line) => line.trimEnd())
|
|
215
|
+
.filter(Boolean);
|
|
216
|
+
const matches = [];
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
if (
|
|
219
|
+
line.startsWith('→ ') ||
|
|
220
|
+
line.startsWith('cursor:') ||
|
|
221
|
+
/^\d+\/\d+\s+matches$/i.test(line) ||
|
|
222
|
+
/^0 results\b/i.test(line)
|
|
223
|
+
) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const normalized = stripFffFileSuffix(line);
|
|
227
|
+
if (normalized) matches.push(normalized);
|
|
228
|
+
}
|
|
229
|
+
return matches;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseGrepOutput(text, fallbackPattern = '') {
|
|
233
|
+
const lines = String(text || '')
|
|
234
|
+
.split(/\r?\n/)
|
|
235
|
+
.map((line) => line.trimEnd())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
const matches = [];
|
|
238
|
+
let currentPath = '';
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
if (
|
|
241
|
+
line.startsWith('→ ') ||
|
|
242
|
+
line.startsWith('cursor:') ||
|
|
243
|
+
/^! regex failed:/i.test(line) ||
|
|
244
|
+
/^\d+\/\d+\s+matches shown$/i.test(line) ||
|
|
245
|
+
/^0 (?:exact )?matches\b/i.test(line) ||
|
|
246
|
+
/^Auto-broadened to\b/i.test(line)
|
|
247
|
+
) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const sectionMatch = line.match(/^\s*(\d+)\s*[:|-]\s*(.*)$/);
|
|
251
|
+
if (sectionMatch && currentPath) {
|
|
252
|
+
const [, lineNumber, preview] = sectionMatch;
|
|
253
|
+
matches.push({
|
|
254
|
+
path: currentPath,
|
|
255
|
+
line: Number(lineNumber),
|
|
256
|
+
column: 1,
|
|
257
|
+
preview: String(preview || '').trim()
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const fileCandidate = stripFffFileSuffix(line);
|
|
262
|
+
if (fileCandidate && !/^\d/.test(fileCandidate)) {
|
|
263
|
+
currentPath = fileCandidate;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
pattern: fallbackPattern,
|
|
268
|
+
matches,
|
|
269
|
+
truncated: /cursor:/i.test(text)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizePathPrefix(value) {
|
|
274
|
+
const text = String(value || '').trim().replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
275
|
+
if (!text || text === '.') return '';
|
|
276
|
+
return text.endsWith('/') ? text : `${text}/`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildGrepQuery(pattern, args = {}) {
|
|
280
|
+
const pieces = [];
|
|
281
|
+
const pathPrefix = normalizePathPrefix(args.path);
|
|
282
|
+
if (pathPrefix) pieces.push(pathPrefix);
|
|
283
|
+
const fileTypes = Array.isArray(args.file_types) ? args.file_types : [];
|
|
284
|
+
const language = String(args.language || '').trim().toLowerCase();
|
|
285
|
+
const mergedTypes = [...new Set([...fileTypes, ...(LANGUAGE_FILE_TYPES[language] || [])])];
|
|
286
|
+
if (mergedTypes.length === 1) {
|
|
287
|
+
pieces.push(`*.${mergedTypes[0]}`);
|
|
288
|
+
} else if (mergedTypes.length > 1) {
|
|
289
|
+
pieces.push(`*.{${mergedTypes.join(',')}}`);
|
|
290
|
+
}
|
|
291
|
+
pieces.push(String(pattern || '').trim());
|
|
292
|
+
return pieces.filter(Boolean).join(' ');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildImmediateItems(relativePath, filePaths, includeHidden = false) {
|
|
296
|
+
const prefix = normalizePathPrefix(relativePath);
|
|
297
|
+
const directories = new Set();
|
|
298
|
+
const files = new Set();
|
|
299
|
+
for (const filePath of filePaths) {
|
|
300
|
+
const normalized = String(filePath || '').replace(/\\/g, '/');
|
|
301
|
+
if (!normalized.startsWith(prefix)) continue;
|
|
302
|
+
const remainder = normalized.slice(prefix.length);
|
|
303
|
+
if (!remainder) continue;
|
|
304
|
+
const [head, ...rest] = remainder.split('/');
|
|
305
|
+
if (!head) continue;
|
|
306
|
+
if (!includeHidden && head.startsWith('.')) continue;
|
|
307
|
+
if (rest.length > 0) {
|
|
308
|
+
directories.add(head);
|
|
309
|
+
} else {
|
|
310
|
+
files.add(head);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const dirItems = [...directories].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
314
|
+
name,
|
|
315
|
+
path: `${prefix}${name}`.replace(/\/$/, ''),
|
|
316
|
+
type: 'dir'
|
|
317
|
+
}));
|
|
318
|
+
const fileItems = [...files].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
319
|
+
name,
|
|
320
|
+
path: `${prefix}${name}`,
|
|
321
|
+
type: 'file'
|
|
322
|
+
}));
|
|
323
|
+
return [...dirItems, ...fileItems];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function createFffAdapter({ workspaceRoot, config }) {
|
|
327
|
+
const command = String(config?.search?.fff_command || config?.tooling?.fff_command || DEFAULT_COMMAND).trim() || DEFAULT_COMMAND;
|
|
328
|
+
const timeoutMs = clampNumber(
|
|
329
|
+
config?.search?.fff_timeout_ms || config?.tooling?.fff_timeout_ms,
|
|
330
|
+
1_000,
|
|
331
|
+
120_000,
|
|
332
|
+
DEFAULT_TIMEOUT_MS
|
|
333
|
+
);
|
|
334
|
+
const client = new FffMcpClient({ workspaceRoot, command, timeoutMs });
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
async connect() {
|
|
338
|
+
await client.connect();
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async grep(args) {
|
|
342
|
+
const pattern = String(args?.pattern || '').trim();
|
|
343
|
+
if (!pattern) return null;
|
|
344
|
+
const result = await client.callTool('grep', {
|
|
345
|
+
query: buildGrepQuery(pattern, args),
|
|
346
|
+
max_results: clampNumber(args?.max_results, 1, 200, 50)
|
|
347
|
+
});
|
|
348
|
+
return parseGrepOutput(extractTextContent(result), pattern);
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
async glob(args) {
|
|
352
|
+
const pattern = String(args?.pattern || '').trim();
|
|
353
|
+
if (!pattern) return null;
|
|
354
|
+
const limit = clampNumber(args?.max_results, 1, 500, 200);
|
|
355
|
+
const result = await client.callTool('find_files', {
|
|
356
|
+
query: pattern,
|
|
357
|
+
max_results: limit
|
|
358
|
+
});
|
|
359
|
+
const matches = parseFindFilesOutput(extractTextContent(result));
|
|
360
|
+
return {
|
|
361
|
+
pattern,
|
|
362
|
+
matches,
|
|
363
|
+
truncated: matches.length >= limit
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async list(args) {
|
|
368
|
+
const relativePath = String(args?.path || '.').trim();
|
|
369
|
+
if (!relativePath || relativePath === '.') return null;
|
|
370
|
+
const result = await client.callTool('find_files', {
|
|
371
|
+
query: normalizePathPrefix(relativePath),
|
|
372
|
+
max_results: 500
|
|
373
|
+
});
|
|
374
|
+
const filePaths = parseFindFilesOutput(extractTextContent(result));
|
|
375
|
+
return {
|
|
376
|
+
path: relativePath,
|
|
377
|
+
items: buildImmediateItems(relativePath, filePaths, Boolean(args?.include_hidden))
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async dispose() {
|
|
382
|
+
await client.dispose();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
@@ -11,6 +11,15 @@ function renderScope(title, items = []) {
|
|
|
11
11
|
return `${title}\n${lines.join('\n')}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function renderLifecycleGroup(title, items = []) {
|
|
15
|
+
if (!Array.isArray(items) || items.length === 0) return '';
|
|
16
|
+
const lines = items.map((item) => {
|
|
17
|
+
const prefix = item.lifecycle ? `[${item.lifecycle}]` : '';
|
|
18
|
+
return `- ${prefix} ${JSON.stringify(String(item.summary || item.content || ''))}`;
|
|
19
|
+
});
|
|
20
|
+
return `${title}\n${lines.join('\n')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export async function buildMemorySnapshot({
|
|
15
24
|
config = {},
|
|
16
25
|
workspaceRoot = process.cwd()
|
|
@@ -24,12 +33,26 @@ export async function buildMemorySnapshot({
|
|
|
24
33
|
]);
|
|
25
34
|
|
|
26
35
|
const maxItems = Math.max(1, Number(config?.memory?.max_items_per_scope || 12));
|
|
36
|
+
|
|
37
|
+
// Separate lifecycle-tagged items for projections
|
|
38
|
+
const allItems = [...user, ...globalItems, ...project];
|
|
39
|
+
const operational = allItems.filter((item) => item.lifecycle === 'operational');
|
|
40
|
+
const longterm = allItems.filter((item) => item.lifecycle === 'longterm');
|
|
41
|
+
|
|
27
42
|
const sections = [
|
|
28
43
|
renderScope('User Memory:', user.slice(0, maxItems)),
|
|
29
44
|
renderScope('Global Memory:', globalItems.slice(0, maxItems)),
|
|
30
45
|
renderScope('Project Memory:', project.slice(0, maxItems))
|
|
31
46
|
].filter(Boolean);
|
|
32
47
|
|
|
48
|
+
// Add lifecycle projection sections
|
|
49
|
+
if (operational.length > 0) {
|
|
50
|
+
sections.push(renderLifecycleGroup('Active Guidance (Operational — temporary but important for current phase):', operational));
|
|
51
|
+
}
|
|
52
|
+
if (longterm.length > 0) {
|
|
53
|
+
sections.push(renderLifecycleGroup('Stable Learnings (LongTerm — proven patterns across tasks):', longterm));
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
if (sections.length === 0) return '';
|
|
34
57
|
|
|
35
58
|
const snapshot = [
|
package/src/core/memory-store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { sha256 } from './crypto-utils.js';
|
|
4
|
-
import { getMemoryDir, getProjectMemoryDir } from './paths.js';
|
|
4
|
+
import { getMemoryDir, getProjectMemoryDir, getInboxDir, getArchiveDir } from './paths.js';
|
|
5
5
|
import { assertSafeMemoryContent, normalizeMemoryText, summarizeMemoryContent } from './memory-policy.js';
|
|
6
6
|
|
|
7
7
|
const ALLOWED_SCOPES = new Set(['user', 'global', 'project']);
|
|
@@ -179,3 +179,230 @@ export async function searchMemories({ scope, query, workspaceRoot = process.cwd
|
|
|
179
179
|
if (!needle) return items;
|
|
180
180
|
return items.filter((item) => item.content.toLowerCase().includes(needle) || item.summary.toLowerCase().includes(needle));
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Dream Loop: inbox capture, lifecycle, archive, promotion
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
const VALID_LIFECYCLE = new Set(['observed', 'candidate', 'operational', 'longterm', 'archived']);
|
|
188
|
+
const VALID_INBOX_SCOPES = new Set(['global', 'repo', 'thread', 'project', 'user']);
|
|
189
|
+
|
|
190
|
+
function validateLifecycle(value) {
|
|
191
|
+
const lc = String(value || '').trim().toLowerCase();
|
|
192
|
+
if (!VALID_LIFECYCLE.has(lc)) throw new Error(`Invalid lifecycle state: ${value}`);
|
|
193
|
+
return lc;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeInboxScope(value) {
|
|
197
|
+
const scope = String(value || 'global').trim().toLowerCase();
|
|
198
|
+
if (!VALID_INBOX_SCOPES.has(scope)) throw new Error(`Unsupported inbox scope: ${value}`);
|
|
199
|
+
return scope;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function todayDir(baseDir) {
|
|
203
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
204
|
+
return path.join(baseDir, date);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function readJsonArray(filePath) {
|
|
208
|
+
try {
|
|
209
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
210
|
+
const parsed = JSON.parse(raw);
|
|
211
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function writeJsonArray(filePath, items) {
|
|
218
|
+
await ensureParent(filePath);
|
|
219
|
+
await fs.writeFile(filePath, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function captureToInbox({
|
|
223
|
+
scope = 'global',
|
|
224
|
+
type = 'observation',
|
|
225
|
+
summary,
|
|
226
|
+
details = '',
|
|
227
|
+
suggestedAction = '',
|
|
228
|
+
tags = [],
|
|
229
|
+
source = 'tool'
|
|
230
|
+
} = {}) {
|
|
231
|
+
const normalizedSummary = normalizeMemoryText(summary);
|
|
232
|
+
if (!normalizedSummary) throw new Error('Inbox capture summary is required');
|
|
233
|
+
assertSafeMemoryContent(normalizedSummary);
|
|
234
|
+
|
|
235
|
+
const dir = todayDir(getInboxDir());
|
|
236
|
+
await fs.mkdir(dir, { recursive: true });
|
|
237
|
+
const now = nowIso();
|
|
238
|
+
const id = `inbox_${sha256(`${normalizedSummary}:${now}:${Math.random()}`).slice(0, 12)}`;
|
|
239
|
+
const entry = {
|
|
240
|
+
id,
|
|
241
|
+
timestamp: now,
|
|
242
|
+
scope: normalizeInboxScope(scope),
|
|
243
|
+
source,
|
|
244
|
+
type: String(type || 'observation').trim().toLowerCase(),
|
|
245
|
+
summary: normalizedSummary,
|
|
246
|
+
details: normalizeMemoryText(details),
|
|
247
|
+
suggestedAction: normalizeMemoryText(suggestedAction),
|
|
248
|
+
tags: Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [],
|
|
249
|
+
lifecycle: 'observed'
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const indexPath = path.join(dir, 'index.json');
|
|
253
|
+
const entries = await readJsonArray(indexPath);
|
|
254
|
+
entries.push(entry);
|
|
255
|
+
await writeJsonArray(indexPath, entries);
|
|
256
|
+
return entry;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function listInbox({ since, scope } = {}) {
|
|
260
|
+
const inboxBase = getInboxDir();
|
|
261
|
+
let dayDirs;
|
|
262
|
+
try {
|
|
263
|
+
const entries = await fs.readdir(inboxBase);
|
|
264
|
+
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
if (since) {
|
|
269
|
+
const sinceStr = String(since).slice(0, 10);
|
|
270
|
+
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
271
|
+
}
|
|
272
|
+
const all = [];
|
|
273
|
+
for (const day of dayDirs) {
|
|
274
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
275
|
+
const entries = await readJsonArray(indexPath);
|
|
276
|
+
all.push(...entries);
|
|
277
|
+
}
|
|
278
|
+
if (scope) {
|
|
279
|
+
const sc = String(scope).trim().toLowerCase();
|
|
280
|
+
return all.filter((e) => e.scope === sc);
|
|
281
|
+
}
|
|
282
|
+
return all;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function updateInboxEntry(id, updates = {}) {
|
|
286
|
+
const inboxBase = getInboxDir();
|
|
287
|
+
let dayDirs;
|
|
288
|
+
try {
|
|
289
|
+
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
for (const day of dayDirs) {
|
|
294
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
295
|
+
const entries = await readJsonArray(indexPath);
|
|
296
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
297
|
+
if (idx === -1) continue;
|
|
298
|
+
if (updates.lifecycle) updates.lifecycle = validateLifecycle(updates.lifecycle);
|
|
299
|
+
entries[idx] = { ...entries[idx], ...updates };
|
|
300
|
+
await writeJsonArray(indexPath, entries);
|
|
301
|
+
return entries[idx];
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function removeInboxEntry(id) {
|
|
307
|
+
const inboxBase = getInboxDir();
|
|
308
|
+
let dayDirs;
|
|
309
|
+
try {
|
|
310
|
+
dayDirs = (await fs.readdir(inboxBase)).filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
for (const day of dayDirs) {
|
|
315
|
+
const indexPath = path.join(inboxBase, day, 'index.json');
|
|
316
|
+
const entries = await readJsonArray(indexPath);
|
|
317
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
318
|
+
if (idx === -1) continue;
|
|
319
|
+
entries.splice(idx, 1);
|
|
320
|
+
await writeJsonArray(indexPath, entries);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function archiveEntry(entry, reason = '', auditNote = '') {
|
|
327
|
+
const archiveDir = getArchiveDir();
|
|
328
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
329
|
+
const dir = path.join(archiveDir, date);
|
|
330
|
+
await fs.mkdir(dir, { recursive: true });
|
|
331
|
+
const archived = {
|
|
332
|
+
...entry,
|
|
333
|
+
lifecycle: 'archived',
|
|
334
|
+
archivedAt: nowIso(),
|
|
335
|
+
archiveReason: normalizeMemoryText(reason),
|
|
336
|
+
auditNote: normalizeMemoryText(auditNote)
|
|
337
|
+
};
|
|
338
|
+
const indexPath = path.join(dir, 'index.json');
|
|
339
|
+
const entries = await readJsonArray(indexPath);
|
|
340
|
+
entries.push(archived);
|
|
341
|
+
await writeJsonArray(indexPath, entries);
|
|
342
|
+
await removeInboxEntry(entry.id);
|
|
343
|
+
return archived;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function listArchive({ since, scope } = {}) {
|
|
347
|
+
const archiveBase = getArchiveDir();
|
|
348
|
+
let dayDirs;
|
|
349
|
+
try {
|
|
350
|
+
const entries = await fs.readdir(archiveBase);
|
|
351
|
+
dayDirs = entries.filter((e) => /^\d{4}-\d{2}-\d{2}$/.test(e)).sort();
|
|
352
|
+
} catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
if (since) {
|
|
356
|
+
const sinceStr = String(since).slice(0, 10);
|
|
357
|
+
dayDirs = dayDirs.filter((d) => d >= sinceStr);
|
|
358
|
+
}
|
|
359
|
+
const all = [];
|
|
360
|
+
for (const day of dayDirs) {
|
|
361
|
+
const indexPath = path.join(archiveBase, day, 'index.json');
|
|
362
|
+
const entries = await readJsonArray(indexPath);
|
|
363
|
+
all.push(...entries);
|
|
364
|
+
}
|
|
365
|
+
if (scope) {
|
|
366
|
+
const sc = String(scope).trim().toLowerCase();
|
|
367
|
+
return all.filter((e) => e.scope === sc);
|
|
368
|
+
}
|
|
369
|
+
return all;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function promoteMemory({
|
|
373
|
+
entry,
|
|
374
|
+
scope = 'global',
|
|
375
|
+
lifecycle = 'operational',
|
|
376
|
+
workspaceRoot = process.cwd(),
|
|
377
|
+
projectAlias = '',
|
|
378
|
+
config = {},
|
|
379
|
+
confidence = 0.9
|
|
380
|
+
} = {}) {
|
|
381
|
+
if (!entry?.summary) throw new Error('Entry with summary is required for promotion');
|
|
382
|
+
const lc = validateLifecycle(lifecycle);
|
|
383
|
+
const content = normalizeMemoryText(entry.details || entry.summary);
|
|
384
|
+
const saved = await rememberMemory({
|
|
385
|
+
scope,
|
|
386
|
+
content,
|
|
387
|
+
kind: entry.type || 'note',
|
|
388
|
+
summary: normalizeMemoryText(entry.summary),
|
|
389
|
+
source: `dream-promote:${entry.id}`,
|
|
390
|
+
confidence: Math.min(1, Math.max(0.5, confidence)),
|
|
391
|
+
replaceSimilar: true,
|
|
392
|
+
workspaceRoot,
|
|
393
|
+
projectAlias,
|
|
394
|
+
config
|
|
395
|
+
});
|
|
396
|
+
// Tag the saved item with lifecycle
|
|
397
|
+
const filePath = buildFilePath(scope, workspaceRoot, projectAlias);
|
|
398
|
+
const projectKey = scope === 'project' ? getProjectMemoryKey(workspaceRoot, projectAlias) : '';
|
|
399
|
+
const items = (await readMemoryBucket(filePath)).map((item) => normalizeMemoryItem(item, scope, projectKey));
|
|
400
|
+
const target = items.find((item) => item.id === saved.id);
|
|
401
|
+
if (target) {
|
|
402
|
+
target.lifecycle = lc;
|
|
403
|
+
await writeMemoryBucket(filePath, items);
|
|
404
|
+
}
|
|
405
|
+
// Remove from inbox
|
|
406
|
+
await removeInboxEntry(entry.id);
|
|
407
|
+
return { promoted: saved, lifecycle: lc };
|
|
408
|
+
}
|
package/src/core/paths.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
const GLOBAL_APP_DIR = 'codemini-global';
|
|
5
5
|
const PROJECT_APP_DIR = '.codemini';
|
|
6
|
-
const PROJECT_INDEX_DIR = '.codemini
|
|
6
|
+
const PROJECT_INDEX_DIR = '.codemini';
|
|
7
7
|
|
|
8
8
|
export function getBaseConfigDir() {
|
|
9
9
|
if (process.env.CODEMINI_GLOBAL_DIR) {
|
|
@@ -109,3 +109,15 @@ export function getProjectIndexDir(cwd = process.cwd()) {
|
|
|
109
109
|
export function getProjectMemoryDir(cwd = process.cwd()) {
|
|
110
110
|
return path.join(getProjectIndexDir(cwd), 'memory');
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
export function getInboxDir() {
|
|
114
|
+
return path.join(getMemoryDir(), 'inbox');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getArchiveDir() {
|
|
118
|
+
return path.join(getMemoryDir(), 'archive');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getDreamAuditDir() {
|
|
122
|
+
return path.join(getMemoryDir(), 'audit');
|
|
123
|
+
}
|
|
@@ -387,7 +387,7 @@ export async function initializeProjectIndex(cwd = process.cwd()) {
|
|
|
387
387
|
projectRoot: targetRoot,
|
|
388
388
|
projectMap,
|
|
389
389
|
fileIndex,
|
|
390
|
-
summary: `initialized ${path.basename(targetRoot) || '.'}/.codemini
|
|
390
|
+
summary: `initialized ${path.basename(targetRoot) || '.'}/.codemini (${Array.isArray(fileIndex?.files) ? fileIndex.files.length : 0} files)`
|
|
391
391
|
};
|
|
392
392
|
})();
|
|
393
393
|
initCache.set(cacheKey, promise);
|
|
@@ -447,7 +447,7 @@ export async function refreshIndexedFile(cwd = process.cwd(), relativePath = '')
|
|
|
447
447
|
path: projectRelativePath,
|
|
448
448
|
projectRoot,
|
|
449
449
|
action,
|
|
450
|
-
summary: `${action} ${path.basename(projectRoot) || '.'}/.codemini
|
|
450
|
+
summary: `${action} ${path.basename(projectRoot) || '.'}/.codemini for ${projectRelativePath}`
|
|
451
451
|
};
|
|
452
452
|
}
|
|
453
453
|
|