byterover-cli 1.0.4 → 1.0.5
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 +13 -2
- package/dist/commands/curate.js +1 -1
- package/dist/commands/main.d.ts +13 -0
- package/dist/commands/main.js +53 -2
- package/dist/commands/query.js +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/domain/cipher/llm/registry.js +53 -2
- package/dist/core/domain/cipher/llm/types.d.ts +2 -0
- package/dist/core/domain/cipher/process/types.d.ts +7 -0
- package/dist/core/domain/cipher/session/session-metadata.d.ts +178 -0
- package/dist/core/domain/cipher/session/session-metadata.js +147 -0
- package/dist/core/domain/knowledge/markdown-writer.d.ts +15 -18
- package/dist/core/domain/knowledge/markdown-writer.js +232 -34
- package/dist/core/domain/knowledge/relation-parser.d.ts +25 -39
- package/dist/core/domain/knowledge/relation-parser.js +39 -61
- package/dist/core/domain/transport/schemas.d.ts +37 -2
- package/dist/core/domain/transport/schemas.js +23 -2
- package/dist/core/interfaces/cipher/i-session-persistence.d.ts +133 -0
- package/dist/core/interfaces/cipher/i-session-persistence.js +7 -0
- package/dist/core/interfaces/cipher/message-types.d.ts +6 -0
- package/dist/core/interfaces/executor/i-curate-executor.d.ts +2 -2
- package/dist/core/interfaces/i-context-file-reader.d.ts +3 -0
- package/dist/core/interfaces/usecase/{i-clear-use-case.d.ts → i-reset-use-case.d.ts} +1 -1
- package/dist/infra/cipher/agent/agent-schemas.d.ts +6 -6
- package/dist/infra/cipher/agent/service-initializer.js +4 -4
- package/dist/infra/cipher/file-system/context-tree-file-system-factory.js +3 -2
- package/dist/infra/cipher/file-system/file-system-service.js +1 -0
- package/dist/infra/cipher/http/internal-llm-http-service.js +3 -5
- package/dist/infra/cipher/interactive-loop.js +3 -1
- package/dist/infra/cipher/llm/context/context-manager.js +40 -16
- package/dist/infra/cipher/llm/formatters/gemini-formatter.d.ts +13 -0
- package/dist/infra/cipher/llm/formatters/gemini-formatter.js +98 -6
- package/dist/infra/cipher/llm/generators/byterover-content-generator.js +6 -2
- package/dist/infra/cipher/llm/thought-parser.d.ts +21 -0
- package/dist/infra/cipher/llm/thought-parser.js +27 -0
- package/dist/infra/cipher/llm/tool-output-processor.d.ts +10 -0
- package/dist/infra/cipher/llm/tool-output-processor.js +80 -7
- package/dist/infra/cipher/process/process-service.js +11 -3
- package/dist/infra/cipher/session/chat-session.d.ts +7 -2
- package/dist/infra/cipher/session/chat-session.js +90 -52
- package/dist/infra/cipher/session/session-metadata-store.d.ts +52 -0
- package/dist/infra/cipher/session/session-metadata-store.js +406 -0
- package/dist/infra/cipher/tools/implementations/curate-tool.js +113 -35
- package/dist/infra/cipher/tools/implementations/task-tool.js +1 -0
- package/dist/infra/context-tree/file-context-file-reader.js +4 -0
- package/dist/infra/core/task-processor.d.ts +2 -2
- package/dist/infra/process/process-manager.d.ts +10 -1
- package/dist/infra/process/process-manager.js +16 -6
- package/dist/infra/process/transport-handlers.js +31 -0
- package/dist/infra/repl/commands/index.js +5 -2
- package/dist/infra/repl/commands/new-command.d.ts +14 -0
- package/dist/infra/repl/commands/new-command.js +61 -0
- package/dist/infra/repl/commands/{clear-command.d.ts → reset-command.d.ts} +2 -2
- package/dist/infra/repl/commands/{clear-command.js → reset-command.js} +10 -10
- package/dist/infra/usecase/generate-rules-use-case.js +2 -2
- package/dist/infra/usecase/init-use-case.js +4 -4
- package/dist/infra/usecase/logout-use-case.js +1 -1
- package/dist/infra/usecase/push-use-case.js +1 -1
- package/dist/infra/usecase/{clear-use-case.d.ts → reset-use-case.d.ts} +5 -5
- package/dist/infra/usecase/{clear-use-case.js → reset-use-case.js} +5 -5
- package/dist/resources/prompts/curate.yml +68 -13
- package/dist/resources/tools/curate.txt +60 -15
- package/dist/tui/components/inline-prompts/inline-confirm.js +2 -2
- package/dist/tui/components/onboarding/onboarding-flow.js +1 -0
- package/dist/tui/views/command-view.js +15 -0
- package/dist/utils/file-validator.js +9 -7
- package/oclif.manifest.json +3 -3
- package/package.json +1 -1
- package/dist/config/context-tree-domains.d.ts +0 -29
- package/dist/config/context-tree-domains.js +0 -29
- /package/dist/core/interfaces/usecase/{i-clear-use-case.js → i-reset-use-case.js} +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionMetadataStore - Manages session metadata persistence.
|
|
3
|
+
*
|
|
4
|
+
* Stores session metadata in .brv/sessions/ directory:
|
|
5
|
+
* - active.json: Current active session pointer
|
|
6
|
+
* - session-*.json: Individual session metadata files
|
|
7
|
+
*
|
|
8
|
+
* Design adapted from gemini-cli's ChatRecordingService pattern.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import * as fs from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { ACTIVE_SESSION_FILE, ActiveSessionPointerSchema, cleanMessageForTitle, generateSessionFilename, parseSessionFilename, SESSION_FILE_PREFIX, SessionMetadataSchema, SESSIONS_DIR, } from '../../../core/domain/cipher/session/session-metadata.js';
|
|
14
|
+
/**
|
|
15
|
+
* Check if a process with given PID is running.
|
|
16
|
+
*
|
|
17
|
+
* @param pid - Process ID to check
|
|
18
|
+
* @returns True if process is running
|
|
19
|
+
*/
|
|
20
|
+
function isProcessRunning(pid) {
|
|
21
|
+
try {
|
|
22
|
+
// Sending signal 0 checks if process exists without actually sending a signal
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Session ID prefix used in the application.
|
|
32
|
+
*/
|
|
33
|
+
const SESSION_ID_PREFIX = 'agent-session-';
|
|
34
|
+
/**
|
|
35
|
+
* Unique token for this process instance.
|
|
36
|
+
* Used to detect PID reuse: different process instance = different token,
|
|
37
|
+
* even if the OS assigned the same PID after the original process crashed.
|
|
38
|
+
*/
|
|
39
|
+
const PROCESS_TOKEN = randomUUID();
|
|
40
|
+
/**
|
|
41
|
+
* Extract the UUID portion from a session ID.
|
|
42
|
+
*
|
|
43
|
+
* Session IDs have format: "agent-session-<UUID>" or just "<UUID>"
|
|
44
|
+
* This function handles both formats for backward compatibility.
|
|
45
|
+
*
|
|
46
|
+
* @param sessionId - The full session ID
|
|
47
|
+
* @returns The UUID portion without the prefix
|
|
48
|
+
*/
|
|
49
|
+
function extractUuidFromSessionId(sessionId) {
|
|
50
|
+
return sessionId.startsWith(SESSION_ID_PREFIX)
|
|
51
|
+
? sessionId.slice(SESSION_ID_PREFIX.length)
|
|
52
|
+
: sessionId;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* SessionMetadataStore implementation.
|
|
56
|
+
*
|
|
57
|
+
* Manages session metadata stored in .brv/sessions/ directory.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionMetadataStore {
|
|
60
|
+
activeSessionPath;
|
|
61
|
+
sessionsDir;
|
|
62
|
+
workingDirectory;
|
|
63
|
+
/**
|
|
64
|
+
* Create a new SessionMetadataStore.
|
|
65
|
+
*
|
|
66
|
+
* @param workingDirectory - Project working directory (defaults to process.cwd())
|
|
67
|
+
*/
|
|
68
|
+
constructor(workingDirectory) {
|
|
69
|
+
this.workingDirectory = workingDirectory ?? process.cwd();
|
|
70
|
+
this.sessionsDir = join(this.workingDirectory, '.brv', SESSIONS_DIR);
|
|
71
|
+
this.activeSessionPath = join(this.sessionsDir, ACTIVE_SESSION_FILE);
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Active Session Management
|
|
75
|
+
// ============================================================================
|
|
76
|
+
async cleanupSessions(config) {
|
|
77
|
+
const result = {
|
|
78
|
+
corruptedRemoved: 0,
|
|
79
|
+
deletedByAge: 0,
|
|
80
|
+
deletedByCount: 0,
|
|
81
|
+
remaining: 0,
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
const files = await fs.readdir(this.sessionsDir);
|
|
85
|
+
const sessionFiles = files.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'));
|
|
86
|
+
// IMPORTANT: Capture the active session ID as an immutable primitive BEFORE
|
|
87
|
+
// any file mutations. This prevents race conditions where concurrent cleanup
|
|
88
|
+
// operations could modify the active session pointer while we're iterating.
|
|
89
|
+
// Using a primitive string (not object reference) ensures we have a stable
|
|
90
|
+
// value to check against throughout the entire cleanup operation.
|
|
91
|
+
const active = await this.getActiveSession();
|
|
92
|
+
const activeSessionId = active?.sessionId;
|
|
93
|
+
const validSessions = [];
|
|
94
|
+
// First pass: identify corrupted files and valid sessions
|
|
95
|
+
for (const file of sessionFiles) {
|
|
96
|
+
const filePath = join(this.sessionsDir, file);
|
|
97
|
+
try {
|
|
98
|
+
// eslint-disable-next-line no-await-in-loop
|
|
99
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
100
|
+
const data = JSON.parse(content);
|
|
101
|
+
const parseResult = SessionMetadataSchema.safeParse(data);
|
|
102
|
+
if (!parseResult.success) {
|
|
103
|
+
// Corrupted file - delete it
|
|
104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
105
|
+
await fs.unlink(filePath);
|
|
106
|
+
result.corruptedRemoved++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
validSessions.push({ file, metadata: parseResult.data });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Can't read/parse - delete it
|
|
113
|
+
try {
|
|
114
|
+
// eslint-disable-next-line no-await-in-loop
|
|
115
|
+
await fs.unlink(filePath);
|
|
116
|
+
result.corruptedRemoved++;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Ignore delete errors
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Sort by lastUpdated (newest first)
|
|
124
|
+
validSessions.sort((a, b) => new Date(b.metadata.lastUpdated).getTime() - new Date(a.metadata.lastUpdated).getTime());
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const maxAgeMs = config.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
127
|
+
// Second pass: apply retention policies
|
|
128
|
+
for (const [i, { file, metadata }] of validSessions.entries()) {
|
|
129
|
+
// Never delete the current active session (uses captured primitive ID)
|
|
130
|
+
if (activeSessionId && metadata.sessionId === activeSessionId) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const age = now - new Date(metadata.lastUpdated).getTime();
|
|
134
|
+
const shouldDeleteByAge = age > maxAgeMs;
|
|
135
|
+
const shouldDeleteByCount = i >= config.maxCount;
|
|
136
|
+
const shouldDelete = shouldDeleteByAge || shouldDeleteByCount;
|
|
137
|
+
if (!shouldDelete) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
// eslint-disable-next-line no-await-in-loop
|
|
142
|
+
await fs.unlink(join(this.sessionsDir, file));
|
|
143
|
+
if (shouldDeleteByAge) {
|
|
144
|
+
result.deletedByAge++;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
result.deletedByCount++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Ignore delete errors
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Count remaining
|
|
155
|
+
const remainingFiles = await fs.readdir(this.sessionsDir);
|
|
156
|
+
result.remaining = remainingFiles.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json')).length;
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === 'ENOENT') {
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async clearActiveSession() {
|
|
167
|
+
try {
|
|
168
|
+
await fs.unlink(this.activeSessionPath);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
// Ignore if file doesn't exist
|
|
172
|
+
if (error.code !== 'ENOENT') {
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Create a new session metadata object.
|
|
179
|
+
*
|
|
180
|
+
* @param sessionId - Session ID
|
|
181
|
+
* @returns New session metadata with defaults
|
|
182
|
+
*/
|
|
183
|
+
createSessionMetadata(sessionId) {
|
|
184
|
+
const now = new Date().toISOString();
|
|
185
|
+
return {
|
|
186
|
+
createdAt: now,
|
|
187
|
+
lastUpdated: now,
|
|
188
|
+
messageCount: 0,
|
|
189
|
+
sessionId,
|
|
190
|
+
status: 'active',
|
|
191
|
+
workingDirectory: this.workingDirectory,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
async deleteSession(sessionId) {
|
|
195
|
+
try {
|
|
196
|
+
const files = await fs.readdir(this.sessionsDir);
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
if (!file.startsWith(SESSION_FILE_PREFIX) || !file.endsWith('.json')) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const parsed = parseSessionFilename(file);
|
|
202
|
+
// Extract UUID from sessionId (removes "agent-session-" prefix if present)
|
|
203
|
+
// then compare with the filename's uuid prefix
|
|
204
|
+
const uuid = extractUuidFromSessionId(sessionId);
|
|
205
|
+
if (parsed && uuid.startsWith(parsed.uuidPrefix)) {
|
|
206
|
+
// eslint-disable-next-line no-await-in-loop
|
|
207
|
+
await fs.unlink(join(this.sessionsDir, file));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
// Also check by reading the file to match full sessionId
|
|
211
|
+
try {
|
|
212
|
+
const filePath = join(this.sessionsDir, file);
|
|
213
|
+
// eslint-disable-next-line no-await-in-loop
|
|
214
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
215
|
+
const data = JSON.parse(content);
|
|
216
|
+
if (data.sessionId === sessionId) {
|
|
217
|
+
// eslint-disable-next-line no-await-in-loop
|
|
218
|
+
await fs.unlink(filePath);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Continue to next file
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (error.code === 'ENOENT') {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async getActiveSession() {
|
|
236
|
+
try {
|
|
237
|
+
const content = await fs.readFile(this.activeSessionPath, 'utf8');
|
|
238
|
+
const data = JSON.parse(content);
|
|
239
|
+
const result = ActiveSessionPointerSchema.safeParse(data);
|
|
240
|
+
if (!result.success) {
|
|
241
|
+
// Invalid format - treat as no active session
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return result.data;
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// File doesn't exist or can't be read
|
|
248
|
+
if (error.code === 'ENOENT') {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async getSession(sessionId) {
|
|
255
|
+
const sessions = await this.listSessions();
|
|
256
|
+
return sessions.find((s) => s.sessionId === sessionId) ?? null;
|
|
257
|
+
}
|
|
258
|
+
async isActiveSessionStale() {
|
|
259
|
+
const active = await this.getActiveSession();
|
|
260
|
+
if (!active) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
// If the process is not running, the session is definitely stale
|
|
264
|
+
if (!isProcessRunning(active.pid)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
// If process is running but token is missing or doesn't match,
|
|
268
|
+
// it's either an old session file or a different process with the same PID.
|
|
269
|
+
// Both cases indicate a stale session.
|
|
270
|
+
if (!active.processToken || active.processToken !== PROCESS_TOKEN) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
async isSessionForCurrentProject(sessionId) {
|
|
276
|
+
const session = await this.getSession(sessionId);
|
|
277
|
+
if (!session) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
return session.workingDirectory === this.workingDirectory;
|
|
281
|
+
}
|
|
282
|
+
async listSessions() {
|
|
283
|
+
try {
|
|
284
|
+
await this.ensureSessionsDir();
|
|
285
|
+
const files = await fs.readdir(this.sessionsDir);
|
|
286
|
+
const sessionFiles = files.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'));
|
|
287
|
+
const active = await this.getActiveSession();
|
|
288
|
+
const sessions = [];
|
|
289
|
+
for (const file of sessionFiles) {
|
|
290
|
+
try {
|
|
291
|
+
const filePath = join(this.sessionsDir, file);
|
|
292
|
+
// eslint-disable-next-line no-await-in-loop
|
|
293
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
294
|
+
const data = JSON.parse(content);
|
|
295
|
+
const result = SessionMetadataSchema.safeParse(data);
|
|
296
|
+
if (!result.success) {
|
|
297
|
+
// Skip corrupted files
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const metadata = result.data;
|
|
301
|
+
const isCurrentSession = active?.sessionId === metadata.sessionId;
|
|
302
|
+
sessions.push({
|
|
303
|
+
...metadata,
|
|
304
|
+
file: file.replace('.json', ''),
|
|
305
|
+
fileName: file,
|
|
306
|
+
firstUserMessage: metadata.title,
|
|
307
|
+
index: 0, // Will be set after sorting
|
|
308
|
+
isCurrentSession,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Skip files that can't be read or parsed
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Sort by lastUpdated (newest first)
|
|
317
|
+
sessions.sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime());
|
|
318
|
+
// Set 1-based indexes
|
|
319
|
+
for (const [index, session] of sessions.entries()) {
|
|
320
|
+
session.index = index + 1;
|
|
321
|
+
}
|
|
322
|
+
return sessions;
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
// Directory doesn't exist
|
|
326
|
+
if (error.code === 'ENOENT') {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Session Lifecycle
|
|
334
|
+
// ============================================================================
|
|
335
|
+
async markSessionEnded(sessionId) {
|
|
336
|
+
const session = await this.getSession(sessionId);
|
|
337
|
+
if (session) {
|
|
338
|
+
session.status = 'ended';
|
|
339
|
+
session.lastUpdated = new Date().toISOString();
|
|
340
|
+
await this.saveSession(session);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async markSessionInterrupted(sessionId) {
|
|
344
|
+
const session = await this.getSession(sessionId);
|
|
345
|
+
if (session) {
|
|
346
|
+
session.status = 'interrupted';
|
|
347
|
+
session.lastUpdated = new Date().toISOString();
|
|
348
|
+
await this.saveSession(session);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async saveSession(metadata) {
|
|
352
|
+
await this.ensureSessionsDir();
|
|
353
|
+
// Find existing file for this session or create new
|
|
354
|
+
let filename;
|
|
355
|
+
try {
|
|
356
|
+
const files = await fs.readdir(this.sessionsDir);
|
|
357
|
+
const existingFile = files.find((f) => {
|
|
358
|
+
if (!f.startsWith(SESSION_FILE_PREFIX) || !f.endsWith('.json')) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
const parsed = parseSessionFilename(f);
|
|
362
|
+
// Extract UUID from sessionId and compare with filename's uuid prefix
|
|
363
|
+
const uuid = extractUuidFromSessionId(metadata.sessionId);
|
|
364
|
+
return parsed && uuid.startsWith(parsed.uuidPrefix);
|
|
365
|
+
});
|
|
366
|
+
filename = existingFile ?? generateSessionFilename(metadata.sessionId);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
filename = generateSessionFilename(metadata.sessionId);
|
|
370
|
+
}
|
|
371
|
+
const filePath = join(this.sessionsDir, filename);
|
|
372
|
+
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2), 'utf8');
|
|
373
|
+
}
|
|
374
|
+
async setActiveSession(sessionId) {
|
|
375
|
+
await this.ensureSessionsDir();
|
|
376
|
+
const pointer = {
|
|
377
|
+
activatedAt: new Date().toISOString(),
|
|
378
|
+
pid: process.pid,
|
|
379
|
+
processToken: PROCESS_TOKEN,
|
|
380
|
+
sessionId,
|
|
381
|
+
};
|
|
382
|
+
await fs.writeFile(this.activeSessionPath, JSON.stringify(pointer, null, 2), 'utf8');
|
|
383
|
+
}
|
|
384
|
+
async setSessionTitle(sessionId, title) {
|
|
385
|
+
const session = await this.getSession(sessionId);
|
|
386
|
+
if (session && !session.title) {
|
|
387
|
+
session.title = cleanMessageForTitle(title);
|
|
388
|
+
session.lastUpdated = new Date().toISOString();
|
|
389
|
+
await this.saveSession(session);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async updateSessionActivity(sessionId, messageCount) {
|
|
393
|
+
const session = await this.getSession(sessionId);
|
|
394
|
+
if (session) {
|
|
395
|
+
session.lastUpdated = new Date().toISOString();
|
|
396
|
+
session.messageCount = messageCount;
|
|
397
|
+
await this.saveSession(session);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Ensure the sessions directory exists.
|
|
402
|
+
*/
|
|
403
|
+
async ensureSessionsDir() {
|
|
404
|
+
await fs.mkdir(this.sessionsDir, { recursive: true });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import { ToolName } from '../../../../core/domain/cipher/tools/constants.js';
|
|
@@ -10,14 +9,43 @@ import { toSnakeCase } from '../../../../utils/file-helpers.js';
|
|
|
10
9
|
* Inspired by ACE Curator patterns.
|
|
11
10
|
*/
|
|
12
11
|
const OperationType = z.enum(['ADD', 'UPDATE', 'MERGE', 'DELETE']);
|
|
12
|
+
/**
|
|
13
|
+
* Raw Concept schema for structured metadata and technical footprint.
|
|
14
|
+
*/
|
|
15
|
+
const RawConceptSchema = z.object({
|
|
16
|
+
changes: z.array(z.string()).optional().describe('What changes in the codebase are induced by this concept'),
|
|
17
|
+
files: z.array(z.string()).optional().describe('Which files are related to this concept'),
|
|
18
|
+
flow: z.string().optional().describe('What is the flow included in this concept'),
|
|
19
|
+
task: z.string().optional().describe('What is the task related to this concept'),
|
|
20
|
+
timestamp: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('When the concept was created or modified (ISO 8601 format, e.g., 2025-03-18)'),
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Narrative schema for descriptive and structural context.
|
|
27
|
+
*/
|
|
28
|
+
const NarrativeSchema = z.object({
|
|
29
|
+
dependencies: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('Dependency management information (e.g., "Singleton, init when service starts, hard dependency in smoke test")'),
|
|
33
|
+
features: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Feature documentation for this concept (e.g., "User permission can be stale for up to 300 seconds due to Redis cache")'),
|
|
37
|
+
structure: z.string().optional().describe('Code structure documentation (e.g., "clients/redis_client.go")'),
|
|
38
|
+
});
|
|
13
39
|
/**
|
|
14
40
|
* Content structure for ADD and UPDATE operations.
|
|
15
41
|
*/
|
|
16
42
|
const ContentSchema = z.object({
|
|
43
|
+
narrative: NarrativeSchema.optional().describe('Narrative section with descriptive and structural context'),
|
|
44
|
+
rawConcept: RawConceptSchema.optional().describe('Raw concept section with metadata and technical footprint'),
|
|
17
45
|
relations: z
|
|
18
46
|
.array(z.string())
|
|
19
47
|
.optional()
|
|
20
|
-
.describe('Related topics using domain/topic or domain/topic/subtopic notation'),
|
|
48
|
+
.describe('Related topics using domain/topic/title.md or domain/topic/subtopic/title.md notation'),
|
|
21
49
|
snippets: z.array(z.string()).optional().describe('Code/text snippets'),
|
|
22
50
|
});
|
|
23
51
|
/**
|
|
@@ -27,9 +55,12 @@ const OperationSchema = z.object({
|
|
|
27
55
|
content: ContentSchema.optional().describe('Content for ADD/UPDATE operations'),
|
|
28
56
|
mergeTarget: z.string().optional().describe('Target path for MERGE operation'),
|
|
29
57
|
mergeTargetTitle: z.string().optional().describe('Title of the target file for MERGE operation'),
|
|
30
|
-
path: z.string().describe('Path: domain/topic or domain/topic/subtopic'),
|
|
58
|
+
path: z.string().describe('Path: domain/topic/title.md or domain/topic/subtopic/title.md'),
|
|
31
59
|
reason: z.string().describe('Reasoning for this operation'),
|
|
32
|
-
title: z
|
|
60
|
+
title: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('Title for the context file (saved as {title}.md in snake_case). Required for ADD/UPDATE/MERGE, optional for DELETE'),
|
|
33
64
|
type: OperationType.describe('Operation type: ADD, UPDATE, MERGE, or DELETE'),
|
|
34
65
|
});
|
|
35
66
|
/**
|
|
@@ -53,33 +84,17 @@ function parsePath(path) {
|
|
|
53
84
|
topic: parts[1],
|
|
54
85
|
};
|
|
55
86
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Get existing domain names from the context tree.
|
|
58
|
-
* Returns domain folder names that exist in the context tree.
|
|
59
|
-
*/
|
|
60
|
-
async function getExistingDomains(basePath) {
|
|
61
|
-
try {
|
|
62
|
-
const entries = await fs.readdir(basePath, { withFileTypes: true });
|
|
63
|
-
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Directory doesn't exist yet
|
|
67
|
-
return [];
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
87
|
/**
|
|
71
88
|
* Validate domain name format.
|
|
72
89
|
* Dynamic domains are allowed - no predefined list or limits.
|
|
73
90
|
* The agent is responsible for creating semantically meaningful domains.
|
|
74
91
|
*/
|
|
75
|
-
|
|
92
|
+
function validateDomain(domainName) {
|
|
76
93
|
const normalizedDomain = toSnakeCase(domainName);
|
|
77
|
-
const existingDomains = await getExistingDomains(basePath);
|
|
78
94
|
// Validate domain name format (must be non-empty and valid for filesystem)
|
|
79
95
|
if (!normalizedDomain || normalizedDomain.length === 0) {
|
|
80
96
|
return {
|
|
81
97
|
allowed: false,
|
|
82
|
-
existingDomains,
|
|
83
98
|
reason: 'Domain name cannot be empty.',
|
|
84
99
|
};
|
|
85
100
|
}
|
|
@@ -87,12 +102,11 @@ async function validateDomain(basePath, domainName) {
|
|
|
87
102
|
if (!/^[\w-]+$/.test(normalizedDomain)) {
|
|
88
103
|
return {
|
|
89
104
|
allowed: false,
|
|
90
|
-
existingDomains,
|
|
91
105
|
reason: `Domain name "${normalizedDomain}" contains invalid characters. Use only letters, numbers, underscores, and hyphens.`,
|
|
92
106
|
};
|
|
93
107
|
}
|
|
94
108
|
// All valid domain names are allowed - dynamic domain creation enabled
|
|
95
|
-
return { allowed: true
|
|
109
|
+
return { allowed: true };
|
|
96
110
|
}
|
|
97
111
|
/**
|
|
98
112
|
* Build the full filesystem path from base path and knowledge path.
|
|
@@ -142,7 +156,7 @@ async function executeAdd(basePath, operation) {
|
|
|
142
156
|
};
|
|
143
157
|
}
|
|
144
158
|
// Validate domain before creating
|
|
145
|
-
const domainValidation =
|
|
159
|
+
const domainValidation = validateDomain(parsed.domain);
|
|
146
160
|
if (!domainValidation.allowed) {
|
|
147
161
|
return {
|
|
148
162
|
message: domainValidation.reason,
|
|
@@ -159,8 +173,10 @@ async function executeAdd(basePath, operation) {
|
|
|
159
173
|
// Note: writeFileAtomic creates parent directories as needed, avoiding empty folder creation
|
|
160
174
|
const contextContent = MarkdownWriter.generateContext({
|
|
161
175
|
name: title,
|
|
176
|
+
narrative: content.narrative,
|
|
177
|
+
rawConcept: content.rawConcept,
|
|
162
178
|
relations: content.relations,
|
|
163
|
-
snippets: content.snippets
|
|
179
|
+
snippets: content.snippets ?? [],
|
|
164
180
|
});
|
|
165
181
|
const filename = `${toSnakeCase(title)}.md`;
|
|
166
182
|
const contextPath = join(finalPath, filename);
|
|
@@ -220,8 +236,10 @@ async function executeUpdate(basePath, operation) {
|
|
|
220
236
|
// Generate and write updated content (full replacement)
|
|
221
237
|
const contextContent = MarkdownWriter.generateContext({
|
|
222
238
|
name: title,
|
|
239
|
+
narrative: content.narrative,
|
|
240
|
+
rawConcept: content.rawConcept,
|
|
223
241
|
relations: content.relations,
|
|
224
|
-
snippets: content.snippets
|
|
242
|
+
snippets: content.snippets ?? [],
|
|
225
243
|
});
|
|
226
244
|
await DirectoryManager.writeFileAtomic(contextPath, contextContent);
|
|
227
245
|
return {
|
|
@@ -381,7 +399,27 @@ async function executeDelete(basePath, operation) {
|
|
|
381
399
|
* Execute curate operations on knowledge topics.
|
|
382
400
|
*/
|
|
383
401
|
async function executeCurate(input, _context) {
|
|
384
|
-
const
|
|
402
|
+
const parseResult = CurateInputSchema.safeParse(input);
|
|
403
|
+
if (!parseResult.success) {
|
|
404
|
+
return {
|
|
405
|
+
applied: [
|
|
406
|
+
{
|
|
407
|
+
message: `Invalid input: ${parseResult.error.message}`,
|
|
408
|
+
path: '',
|
|
409
|
+
status: 'failed',
|
|
410
|
+
type: 'ADD',
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
summary: {
|
|
414
|
+
added: 0,
|
|
415
|
+
deleted: 0,
|
|
416
|
+
failed: 1,
|
|
417
|
+
merged: 0,
|
|
418
|
+
updated: 0,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const { basePath, operations } = parseResult.data;
|
|
385
423
|
const applied = [];
|
|
386
424
|
const summary = {
|
|
387
425
|
added: 0,
|
|
@@ -420,8 +458,10 @@ async function executeCurate(input, _context) {
|
|
|
420
458
|
break;
|
|
421
459
|
}
|
|
422
460
|
default: {
|
|
461
|
+
// Exhaustive type check - TypeScript will error if any case is missed
|
|
462
|
+
const exhaustiveCheck = operation.type;
|
|
423
463
|
result = {
|
|
424
|
-
message: `Unknown operation type: ${
|
|
464
|
+
message: `Unknown operation type: ${exhaustiveCheck}`,
|
|
425
465
|
path: operation.path,
|
|
426
466
|
status: 'failed',
|
|
427
467
|
type: operation.type,
|
|
@@ -446,29 +486,65 @@ async function executeCurate(input, _context) {
|
|
|
446
486
|
*/
|
|
447
487
|
export function createCurateTool() {
|
|
448
488
|
return {
|
|
449
|
-
description: `Curate knowledge topics with atomic operations. This tool manages the knowledge structure using four operation types:
|
|
489
|
+
description: `Curate knowledge topics with atomic operations. This tool manages the knowledge structure using four operation types and supports a two-part context model: Raw Concept + Narrative.
|
|
490
|
+
|
|
491
|
+
**Content Structure (Two-Part Model):**
|
|
492
|
+
- **rawConcept**: Captures essential metadata and technical footprint
|
|
493
|
+
- task: What is the task related to this concept
|
|
494
|
+
- changes: Array of changes induced in the codebase
|
|
495
|
+
- files: Array of related files
|
|
496
|
+
- flow: The execution flow of this concept
|
|
497
|
+
- timestamp: When created/modified (ISO 8601 format)
|
|
498
|
+
- **narrative**: Captures descriptive and structural context
|
|
499
|
+
- structure: Code structure documentation
|
|
500
|
+
- dependencies: Dependency management information
|
|
501
|
+
- features: Feature documentation
|
|
502
|
+
- **snippets**: Code/text snippets (legacy support)
|
|
503
|
+
- **relations**: Related topics using @domain/topic notation
|
|
450
504
|
|
|
451
505
|
**Operations:**
|
|
452
506
|
1. **ADD** - Create new titled context file in domain/topic/subtopic
|
|
453
507
|
- Requires: path, title, content (snippets and/or relations), reason
|
|
454
|
-
-
|
|
455
|
-
-
|
|
508
|
+
- Relations must be in the format of "domain/topic/title.md" or "domain/topic/subtopic/title.md"
|
|
509
|
+
- Example with Raw Concept + Narrative:
|
|
510
|
+
{
|
|
511
|
+
type: "ADD",
|
|
512
|
+
path: "structure/caching",
|
|
513
|
+
title: "Redis User Permissions",
|
|
514
|
+
content: {
|
|
515
|
+
rawConcept: {
|
|
516
|
+
task: "Introduce Redis cache for getUserPermissions(userId)",
|
|
517
|
+
changes: ["Cached result using remote Redis", "Redis client: singleton"],
|
|
518
|
+
files: ["services/permission_service.go", "clients/redis_client.go"],
|
|
519
|
+
flow: "getUserPermissions -> check Redis -> on miss query DB -> store result -> return",
|
|
520
|
+
timestamp: "2025-03-18"
|
|
521
|
+
},
|
|
522
|
+
narrative: {
|
|
523
|
+
structure: "# Redis client\\n- clients/redis_client.go",
|
|
524
|
+
dependencies: "# Redis client\\n- Singleton, init when service starts",
|
|
525
|
+
features: "# Authorization\\n- User permission can be stale for up to 300 seconds"
|
|
526
|
+
},
|
|
527
|
+
relations: ["structure/api-endpoints/validation.md", "structure/api-endpoints/error-handling/retry-logic.md"]
|
|
528
|
+
},
|
|
529
|
+
reason: "New caching pattern"
|
|
530
|
+
}
|
|
531
|
+
- Creates: structure/caching/redis_user_permissions.md
|
|
456
532
|
|
|
457
533
|
2. **UPDATE** - Modify existing titled context file (full replacement)
|
|
458
534
|
- Requires: path, title, content, reason
|
|
459
|
-
-
|
|
535
|
+
- Relations must be in the format of "domain/topic/title.md" or "domain/topic/subtopic/title.md"
|
|
536
|
+
- Supports same content structure as ADD
|
|
460
537
|
|
|
461
538
|
3. **MERGE** - Combine source file into target file, delete source
|
|
462
539
|
- Requires: path (source), title (source file), mergeTarget (destination path), mergeTargetTitle (destination file), reason
|
|
463
540
|
- Example: { type: "MERGE", path: "code_style/old_topic", title: "Old Guide", mergeTarget: "code_style/new_topic", mergeTargetTitle: "New Guide", reason: "Consolidating" }
|
|
541
|
+
- Raw concepts and narratives are intelligently merged
|
|
464
542
|
|
|
465
543
|
4. **DELETE** - Remove specific file or entire folder
|
|
466
544
|
- Requires: path, title (optional), reason
|
|
467
545
|
- With title: deletes specific file; without title: deletes entire folder
|
|
468
|
-
- Example (file): { type: "DELETE", path: "code_style/deprecated", title: "Old Guide", reason: "No longer relevant" }
|
|
469
|
-
- Example (folder): { type: "DELETE", path: "code_style/deprecated", title: "", reason: "Removing topic" }
|
|
470
546
|
|
|
471
|
-
**Path format:** domain/topic or domain/topic/subtopic (uses snake_case automatically)
|
|
547
|
+
**Path format:** domain/topic/title.md or domain/topic/subtopic/title.md (uses snake_case automatically)
|
|
472
548
|
**File naming:** Titles are converted to snake_case (e.g., "Best Practices" -> "best_practices.md")
|
|
473
549
|
|
|
474
550
|
**Dynamic Domain Creation:**
|
|
@@ -485,6 +561,8 @@ export function createCurateTool() {
|
|
|
485
561
|
- Avoid overly specific names that only fit one topic
|
|
486
562
|
- Keep domain count reasonable by consolidating related concepts
|
|
487
563
|
|
|
564
|
+
**Backward Compatibility:** Existing context entries using only snippets and relations continue to work.
|
|
565
|
+
|
|
488
566
|
**Output:** Returns applied operations with status (success/failed), filePath (for created/modified files), and a summary of counts.`,
|
|
489
567
|
execute: executeCurate,
|
|
490
568
|
id: ToolName.CURATE,
|
|
@@ -95,6 +95,7 @@ export function createTaskTool(dependencies) {
|
|
|
95
95
|
const registry = getAgentRegistry();
|
|
96
96
|
return {
|
|
97
97
|
description: buildTaskToolDescription(registry),
|
|
98
|
+
// eslint-disable-next-line complexity -- Inherent complexity: validates agent, manages sessions, handles errors
|
|
98
99
|
async execute(input, context) {
|
|
99
100
|
const params = input;
|
|
100
101
|
const { contextTreeOnly, description, prompt, sessionId, subagentType } = params;
|