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.
Files changed (72) hide show
  1. package/README.md +13 -2
  2. package/dist/commands/curate.js +1 -1
  3. package/dist/commands/main.d.ts +13 -0
  4. package/dist/commands/main.js +53 -2
  5. package/dist/commands/query.js +1 -1
  6. package/dist/constants.d.ts +1 -1
  7. package/dist/constants.js +1 -1
  8. package/dist/core/domain/cipher/llm/registry.js +53 -2
  9. package/dist/core/domain/cipher/llm/types.d.ts +2 -0
  10. package/dist/core/domain/cipher/process/types.d.ts +7 -0
  11. package/dist/core/domain/cipher/session/session-metadata.d.ts +178 -0
  12. package/dist/core/domain/cipher/session/session-metadata.js +147 -0
  13. package/dist/core/domain/knowledge/markdown-writer.d.ts +15 -18
  14. package/dist/core/domain/knowledge/markdown-writer.js +232 -34
  15. package/dist/core/domain/knowledge/relation-parser.d.ts +25 -39
  16. package/dist/core/domain/knowledge/relation-parser.js +39 -61
  17. package/dist/core/domain/transport/schemas.d.ts +37 -2
  18. package/dist/core/domain/transport/schemas.js +23 -2
  19. package/dist/core/interfaces/cipher/i-session-persistence.d.ts +133 -0
  20. package/dist/core/interfaces/cipher/i-session-persistence.js +7 -0
  21. package/dist/core/interfaces/cipher/message-types.d.ts +6 -0
  22. package/dist/core/interfaces/executor/i-curate-executor.d.ts +2 -2
  23. package/dist/core/interfaces/i-context-file-reader.d.ts +3 -0
  24. package/dist/core/interfaces/usecase/{i-clear-use-case.d.ts → i-reset-use-case.d.ts} +1 -1
  25. package/dist/infra/cipher/agent/agent-schemas.d.ts +6 -6
  26. package/dist/infra/cipher/agent/service-initializer.js +4 -4
  27. package/dist/infra/cipher/file-system/context-tree-file-system-factory.js +3 -2
  28. package/dist/infra/cipher/file-system/file-system-service.js +1 -0
  29. package/dist/infra/cipher/http/internal-llm-http-service.js +3 -5
  30. package/dist/infra/cipher/interactive-loop.js +3 -1
  31. package/dist/infra/cipher/llm/context/context-manager.js +40 -16
  32. package/dist/infra/cipher/llm/formatters/gemini-formatter.d.ts +13 -0
  33. package/dist/infra/cipher/llm/formatters/gemini-formatter.js +98 -6
  34. package/dist/infra/cipher/llm/generators/byterover-content-generator.js +6 -2
  35. package/dist/infra/cipher/llm/thought-parser.d.ts +21 -0
  36. package/dist/infra/cipher/llm/thought-parser.js +27 -0
  37. package/dist/infra/cipher/llm/tool-output-processor.d.ts +10 -0
  38. package/dist/infra/cipher/llm/tool-output-processor.js +80 -7
  39. package/dist/infra/cipher/process/process-service.js +11 -3
  40. package/dist/infra/cipher/session/chat-session.d.ts +7 -2
  41. package/dist/infra/cipher/session/chat-session.js +90 -52
  42. package/dist/infra/cipher/session/session-metadata-store.d.ts +52 -0
  43. package/dist/infra/cipher/session/session-metadata-store.js +406 -0
  44. package/dist/infra/cipher/tools/implementations/curate-tool.js +113 -35
  45. package/dist/infra/cipher/tools/implementations/task-tool.js +1 -0
  46. package/dist/infra/context-tree/file-context-file-reader.js +4 -0
  47. package/dist/infra/core/task-processor.d.ts +2 -2
  48. package/dist/infra/process/process-manager.d.ts +10 -1
  49. package/dist/infra/process/process-manager.js +16 -6
  50. package/dist/infra/process/transport-handlers.js +31 -0
  51. package/dist/infra/repl/commands/index.js +5 -2
  52. package/dist/infra/repl/commands/new-command.d.ts +14 -0
  53. package/dist/infra/repl/commands/new-command.js +61 -0
  54. package/dist/infra/repl/commands/{clear-command.d.ts → reset-command.d.ts} +2 -2
  55. package/dist/infra/repl/commands/{clear-command.js → reset-command.js} +10 -10
  56. package/dist/infra/usecase/generate-rules-use-case.js +2 -2
  57. package/dist/infra/usecase/init-use-case.js +4 -4
  58. package/dist/infra/usecase/logout-use-case.js +1 -1
  59. package/dist/infra/usecase/push-use-case.js +1 -1
  60. package/dist/infra/usecase/{clear-use-case.d.ts → reset-use-case.d.ts} +5 -5
  61. package/dist/infra/usecase/{clear-use-case.js → reset-use-case.js} +5 -5
  62. package/dist/resources/prompts/curate.yml +68 -13
  63. package/dist/resources/tools/curate.txt +60 -15
  64. package/dist/tui/components/inline-prompts/inline-confirm.js +2 -2
  65. package/dist/tui/components/onboarding/onboarding-flow.js +1 -0
  66. package/dist/tui/views/command-view.js +15 -0
  67. package/dist/utils/file-validator.js +9 -7
  68. package/oclif.manifest.json +3 -3
  69. package/package.json +1 -1
  70. package/dist/config/context-tree-domains.d.ts +0 -29
  71. package/dist/config/context-tree-domains.js +0 -29
  72. /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.string().optional().describe('Title for the context file (saved as {title}.md in snake_case). Required for ADD/UPDATE/MERGE, optional for DELETE'),
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
- async function validateDomain(basePath, domainName) {
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, existingDomains };
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 = await validateDomain(basePath, parsed.domain);
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 { basePath, operations } = input;
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: ${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
- - Example: { type: "ADD", path: "code_style/error_handling", title: "Best Practices", content: { snippets: ["..."], relations: ["logging/basics"] }, reason: "New pattern" }
455
- - Creates: code_style/error_handling/best_practices.md
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
- - Example: { type: "UPDATE", path: "code_style/error_handling", title: "Best Practices", content: { snippets: ["Updated"] }, reason: "Improved" }
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;