byterover-cli 3.5.0 → 3.6.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.
Files changed (91) hide show
  1. package/.env.production +4 -6
  2. package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
  3. package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
  4. package/dist/agent/infra/agent/cipher-agent.js +1 -0
  5. package/dist/oclif/commands/curate/view.js +5 -25
  6. package/dist/oclif/commands/dream.d.ts +18 -0
  7. package/dist/oclif/commands/dream.js +230 -0
  8. package/dist/oclif/commands/query-log/summary.d.ts +18 -0
  9. package/dist/oclif/commands/query-log/summary.js +75 -0
  10. package/dist/oclif/commands/query-log/view.d.ts +23 -0
  11. package/dist/oclif/commands/query-log/view.js +95 -0
  12. package/dist/oclif/lib/time-filter.d.ts +10 -0
  13. package/dist/oclif/lib/time-filter.js +21 -0
  14. package/dist/server/config/environment.d.ts +10 -3
  15. package/dist/server/config/environment.js +34 -15
  16. package/dist/server/constants.d.ts +5 -0
  17. package/dist/server/constants.js +7 -0
  18. package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
  19. package/dist/server/core/domain/entities/query-log-entry.js +40 -0
  20. package/dist/server/core/domain/transport/schemas.d.ts +108 -7
  21. package/dist/server/core/domain/transport/schemas.js +34 -2
  22. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
  23. package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
  24. package/dist/server/core/interfaces/i-terminal.js +1 -0
  25. package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
  26. package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
  27. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
  28. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
  29. package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
  30. package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
  31. package/dist/server/infra/daemon/agent-process.js +79 -9
  32. package/dist/server/infra/daemon/brv-server.js +74 -5
  33. package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
  34. package/dist/server/infra/dream/dream-lock-service.js +88 -0
  35. package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
  36. package/dist/server/infra/dream/dream-log-schema.js +57 -0
  37. package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
  38. package/dist/server/infra/dream/dream-log-store.js +141 -0
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +38 -0
  41. package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
  42. package/dist/server/infra/dream/dream-state-schema.js +23 -0
  43. package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
  44. package/dist/server/infra/dream/dream-state-service.js +91 -0
  45. package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
  46. package/dist/server/infra/dream/dream-trigger.js +65 -0
  47. package/dist/server/infra/dream/dream-undo.d.ts +38 -0
  48. package/dist/server/infra/dream/dream-undo.js +293 -0
  49. package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
  50. package/dist/server/infra/dream/operations/consolidate.js +514 -0
  51. package/dist/server/infra/dream/operations/prune.d.ts +45 -0
  52. package/dist/server/infra/dream/operations/prune.js +362 -0
  53. package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
  54. package/dist/server/infra/dream/operations/synthesize.js +278 -0
  55. package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
  56. package/dist/server/infra/dream/parse-dream-response.js +35 -0
  57. package/dist/server/infra/executor/curate-executor.js +10 -0
  58. package/dist/server/infra/executor/dream-executor.d.ts +97 -0
  59. package/dist/server/infra/executor/dream-executor.js +431 -0
  60. package/dist/server/infra/executor/query-executor.d.ts +2 -2
  61. package/dist/server/infra/executor/query-executor.js +92 -22
  62. package/dist/server/infra/mcp/mcp-server.js +3 -0
  63. package/dist/server/infra/mcp/tools/brv-curate-tool.js +3 -7
  64. package/dist/server/infra/mcp/tools/brv-query-tool.js +3 -7
  65. package/dist/server/infra/mcp/tools/index.d.ts +1 -0
  66. package/dist/server/infra/mcp/tools/index.js +1 -0
  67. package/dist/server/infra/mcp/tools/shared-schema.d.ts +3 -0
  68. package/dist/server/infra/mcp/tools/shared-schema.js +17 -0
  69. package/dist/server/infra/process/feature-handlers.js +10 -6
  70. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  71. package/dist/server/infra/process/query-log-handler.js +150 -0
  72. package/dist/server/infra/process/task-router.d.ts +40 -0
  73. package/dist/server/infra/process/task-router.js +67 -9
  74. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  75. package/dist/server/infra/process/transport-handlers.js +1 -0
  76. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  77. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  78. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  79. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  80. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  81. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  82. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  83. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  84. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  85. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  86. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  87. package/dist/server/utils/log-format-utils.d.ts +5 -0
  88. package/dist/server/utils/log-format-utils.js +23 -0
  89. package/dist/shared/transport/events/config-events.d.ts +1 -1
  90. package/oclif.manifest.json +439 -184
  91. package/package.json +1 -1
@@ -38,6 +38,7 @@ export class TaskRouter {
38
38
  completedTasks = new Map();
39
39
  getAgentForProject;
40
40
  lifecycleHooks;
41
+ preDispatchCheck;
41
42
  projectRegistry;
42
43
  projectRouter;
43
44
  resolveClientProjectPath;
@@ -49,6 +50,7 @@ export class TaskRouter {
49
50
  this.agentPool = options.agentPool;
50
51
  this.getAgentForProject = options.getAgentForProject;
51
52
  this.lifecycleHooks = options.lifecycleHooks ?? [];
53
+ this.preDispatchCheck = options.preDispatchCheck;
52
54
  this.projectRegistry = options.projectRegistry;
53
55
  this.projectRouter = options.projectRouter;
54
56
  this.resolveClientProjectPath = options.resolveClientProjectPath;
@@ -170,7 +172,7 @@ export class TaskRouter {
170
172
  }
171
173
  }
172
174
  handleTaskCompleted(data) {
173
- const { result, taskId } = data;
175
+ const { logId: eventLogId, result, taskId } = data;
174
176
  const task = this.tasks.get(taskId);
175
177
  transportLog(`Task completed: ${taskId}`);
176
178
  // Collect synchronous completion data from hooks (e.g. pendingReviewCount from CurateLogHandler).
@@ -187,24 +189,29 @@ export class TaskRouter {
187
189
  }
188
190
  }
189
191
  }
192
+ // Prefer logId from lifecycle hooks (curate), fall back to executor-provided logId (dream)
193
+ const resolvedLogId = task?.logId ?? eventLogId;
190
194
  if (task) {
191
195
  this.transport.sendTo(task.clientId, TransportTaskEventNames.COMPLETED, {
192
- ...(task.logId ? { logId: task.logId } : {}),
196
+ ...(resolvedLogId ? { logId: resolvedLogId } : {}),
193
197
  ...hookData,
194
198
  result,
195
199
  taskId,
196
200
  });
197
201
  }
198
202
  broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task?.projectPath, TransportTaskEventNames.COMPLETED, {
199
- ...(task?.logId ? { logId: task.logId } : {}),
203
+ ...(resolvedLogId ? { logId: resolvedLogId } : {}),
200
204
  ...hookData,
201
205
  result,
202
206
  taskId,
203
207
  }, task?.clientId);
204
208
  this.moveToCompleted(taskId);
205
- // Notify pool so it can clear busy flag and drain queued tasks
206
- if (task?.projectPath) {
207
- this.agentPool?.notifyTaskCompleted(task.projectPath);
209
+ // Notify pool so it can clear busy flag and drain queued tasks.
210
+ // Fallback to data.projectPath for daemon-submitted tasks (e.g. idle dream)
211
+ // that bypass handleTaskCreate and are not registered in this.tasks.
212
+ const projectPath = task?.projectPath ?? data.projectPath;
213
+ if (projectPath) {
214
+ this.agentPool?.notifyTaskCompleted(projectPath);
208
215
  }
209
216
  // Notify hooks (fire-and-forget)
210
217
  if (task) {
@@ -303,6 +310,27 @@ export class TaskRouter {
303
310
  ...(logId ? { logId } : {}),
304
311
  taskId,
305
312
  });
313
+ // ── Daemon-side pre-dispatch gate (dream uses this for gates 1-3) ────────
314
+ // Runs after ack so the client has a logId to correlate; short-circuits with
315
+ // task:completed + skip-reason when ineligible. Mirrors the idle-trigger
316
+ // pattern in brv-server.ts:260 for the CLI dispatch path.
317
+ if (this.preDispatchCheck) {
318
+ let check = { eligible: true };
319
+ try {
320
+ check = await this.preDispatchCheck(data, projectPath);
321
+ }
322
+ catch (error_) {
323
+ transportLog(`preDispatchCheck threw for task ${taskId}, proceeding with dispatch: ${error_ instanceof Error ? error_.message : String(error_)}`);
324
+ }
325
+ if (!check.eligible) {
326
+ transportLog(`Task ${taskId} (type=${data.type}) skipped by daemon pre-check: ${check.skipResult}`);
327
+ // Use the skip-specific handler so the pool's activeTasks counter and
328
+ // onTaskCompleted hooks aren't notified for a task that never reached
329
+ // submitTask. See handleTaskSkippedByPreCheck for rationale.
330
+ this.handleTaskSkippedByPreCheck(taskId, check.skipResult);
331
+ return { taskId };
332
+ }
333
+ }
306
334
  // ── Submit to AgentPool (fire-and-forget) ─────────────────────────────────
307
335
  const executeMsg = {
308
336
  clientId,
@@ -310,6 +338,7 @@ export class TaskRouter {
310
338
  ...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
311
339
  ...(data.files?.length ? { files: data.files } : {}),
312
340
  ...(data.folderPath ? { folderPath: data.folderPath } : {}),
341
+ ...(data.force === undefined ? {} : { force: data.force }),
313
342
  ...(projectPath ? { projectPath } : {}),
314
343
  taskId,
315
344
  type: data.type,
@@ -386,15 +415,44 @@ export class TaskRouter {
386
415
  taskId,
387
416
  }, task?.clientId);
388
417
  this.moveToCompleted(taskId);
389
- // Notify pool so it can clear busy flag and drain queued tasks
390
- if (task?.projectPath) {
391
- this.agentPool?.notifyTaskCompleted(task.projectPath);
418
+ // Notify pool so it can clear busy flag and drain queued tasks.
419
+ // Fallback to data.projectPath for daemon-submitted tasks (e.g. idle dream).
420
+ const errorProjectPath = task?.projectPath ?? data.projectPath;
421
+ if (errorProjectPath) {
422
+ this.agentPool?.notifyTaskCompleted(errorProjectPath);
392
423
  }
393
424
  // Notify hooks (fire-and-forget)
394
425
  if (task) {
395
426
  this.notifyHooksError(taskId, error.message, task).catch(() => { });
396
427
  }
397
428
  }
429
+ /**
430
+ * Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
431
+ * before it ever reached `AgentPool.submitTask`.
432
+ *
433
+ * Distinct from {@link handleTaskCompleted}:
434
+ * - does NOT call `agentPool.notifyTaskCompleted` (the pool's `activeTasks`
435
+ * counter was never incremented, so decrementing here would undercount real
436
+ * load and let `drainQueue` dispatch an extra queued task)
437
+ * - does NOT fire `onTaskCompleted` lifecycle hooks (counters/metrics that
438
+ * act on completed tasks should not see pre-check skips as completions)
439
+ *
440
+ * Still emits the event to the client and the project room so REPL/TUI
441
+ * receive the skip result, and still calls `moveToCompleted` so the task is
442
+ * removed from the active set.
443
+ */
444
+ handleTaskSkippedByPreCheck(taskId, result) {
445
+ const task = this.tasks.get(taskId);
446
+ transportLog(`Task skipped by pre-dispatch gate: ${taskId}`);
447
+ if (task) {
448
+ this.transport.sendTo(task.clientId, TransportTaskEventNames.COMPLETED, {
449
+ result,
450
+ taskId,
451
+ });
452
+ }
453
+ broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task?.projectPath, TransportTaskEventNames.COMPLETED, { result, taskId }, task?.clientId);
454
+ this.moveToCompleted(taskId);
455
+ }
398
456
  handleTaskStarted(data) {
399
457
  const { taskId } = data;
400
458
  const task = this.tasks.get(taskId);
@@ -32,12 +32,16 @@ import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-li
32
32
  import type { IProjectRegistry } from '../../core/interfaces/project/i-project-registry.js';
33
33
  import type { IProjectRouter } from '../../core/interfaces/routing/i-project-router.js';
34
34
  import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
35
+ import type { PreDispatchCheck } from './task-router.js';
36
+ export type { PreDispatchCheck, PreDispatchCheckResult } from './task-router.js';
35
37
  export type { TaskInfo } from './types.js';
36
38
  type TransportHandlersOptions = {
37
39
  agentPool?: IAgentPool;
38
40
  clientManager?: IClientManager;
39
41
  /** Lifecycle hooks for task events (e.g. CurateLogHandler). */
40
42
  lifecycleHooks?: ITaskLifecycleHook[];
43
+ /** Optional daemon-side gate run before dispatching a task to the agent pool. */
44
+ preDispatchCheck?: PreDispatchCheck;
41
45
  projectRegistry?: IProjectRegistry;
42
46
  projectRouter?: IProjectRouter;
43
47
  transport: ITransportServer;
@@ -42,6 +42,7 @@ export class TransportHandlers {
42
42
  agentPool: options.agentPool,
43
43
  getAgentForProject: (projectPath) => this.connectionCoordinator.getAgentForProject(projectPath),
44
44
  lifecycleHooks: options.lifecycleHooks,
45
+ preDispatchCheck: options.preDispatchCheck,
45
46
  projectRegistry: options.projectRegistry,
46
47
  projectRouter: options.projectRouter,
47
48
  resolveClientProjectPath: (clientId) => options.clientManager?.getClient(clientId)?.projectPath,
@@ -57,7 +57,7 @@ const CurateLogEntryFileSchema = z.discriminatedUnion('status', [
57
57
  ]);
58
58
  // ── FileCurateLogStore ────────────────────────────────────────────────────────
59
59
  const ID_PATTERN = new RegExp(`^${CURATE_LOG_ID_PREFIX}-\\d+$`);
60
- const DEFAULT_MAX_ENTRIES = 100;
60
+ const DEFAULT_MAX_ENTRIES = 1000;
61
61
  /** Entries stuck in "processing" longer than this are considered interrupted (daemon was killed). */
62
62
  const STALE_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
63
63
  /**
@@ -0,0 +1,81 @@
1
+ import type { QueryLogEntry } from '../../core/domain/entities/query-log-entry.js';
2
+ import type { IQueryLogStore, QueryLogStatus, QueryLogTier } from '../../core/interfaces/storage/i-query-log-store.js';
3
+ type FileQueryLogStoreOptions = {
4
+ baseDir: string;
5
+ maxAgeDays?: number;
6
+ maxEntries?: number;
7
+ };
8
+ /**
9
+ * File-based implementation of IQueryLogStore.
10
+ *
11
+ * Each log entry is stored as a JSON file:
12
+ * {baseDir}/query-log/qry-{timestamp_ms}.json
13
+ *
14
+ * Writes are atomic (tmp → rename). Reads validate with Zod and return undefined
15
+ * for corrupt/missing files. Prunes by age (default 30 days) then by count (default 1000).
16
+ */
17
+ export declare class FileQueryLogStore implements IQueryLogStore {
18
+ private lastTimestamp;
19
+ private readonly logDir;
20
+ private readonly maxAgeDays;
21
+ private readonly maxEntries;
22
+ private pruneInFlight;
23
+ constructor(opts: FileQueryLogStoreOptions);
24
+ /**
25
+ * Retrieve an entry by ID. Returns undefined if:
26
+ * - ID format is invalid (security: prevents path traversal)
27
+ * - File does not exist
28
+ * - File content fails Zod validation (corrupted)
29
+ */
30
+ getById(id: string): Promise<QueryLogEntry | undefined>;
31
+ /**
32
+ * Generate the next monotonic log entry ID in the format `qry-{timestamp_ms}`.
33
+ * Guaranteed to increase even if called multiple times in the same millisecond.
34
+ *
35
+ * Note: monotonicity is instance-local. A new instance resets `lastTimestamp` to 0
36
+ * and relies on wall-clock time. Two instances pointing at the same baseDir could
37
+ * theoretically collide in the same millisecond, but this is practically impossible
38
+ * given the sequential task queue (max concurrency = 1 per project).
39
+ */
40
+ getNextId(): Promise<string>;
41
+ /**
42
+ * List entries sorted newest-first (by timestamp embedded in filename).
43
+ * Filters (status, tier, after, before) are applied before limit.
44
+ * Reads stop early once `limit` matches are found, so filtered queries with small limits
45
+ * are O(matches) rather than O(total entries). Skips corrupt entries silently.
46
+ */
47
+ list({ after, before, limit, status, tier, }?: {
48
+ after?: number;
49
+ before?: number;
50
+ limit?: number;
51
+ status?: QueryLogStatus[];
52
+ tier?: QueryLogTier[];
53
+ }): Promise<QueryLogEntry[]>;
54
+ /**
55
+ * Persist a log entry atomically (write to tmp, then rename).
56
+ * On rename failure, cleans up the tmp file. After saving, prunes by age then by count (best-effort).
57
+ */
58
+ save(entry: QueryLogEntry): Promise<void>;
59
+ private entryPath;
60
+ /**
61
+ * Schedule a prune pass without blocking the caller.
62
+ * Deduplicates concurrent calls — only one prune runs at a time.
63
+ */
64
+ private firePrune;
65
+ private pruneOldest;
66
+ /**
67
+ * If a "processing" entry is older than STALE_PROCESSING_THRESHOLD_MS, the daemon
68
+ * was killed before it could finalize it. Rewrite it as "error" on disk (best-effort)
69
+ * and return the corrected entry so the display shows "interrupted" instead of processing.
70
+ *
71
+ * Uses writeAtomic directly (not save) to skip the prune cascade — list() with N stale
72
+ * entries would otherwise trigger N concurrent prune passes.
73
+ */
74
+ private resolveStale;
75
+ /**
76
+ * Atomic write: write to a tmp file with random UUID suffix, then rename.
77
+ * On failure, cleans up the tmp file and re-throws the original error.
78
+ */
79
+ private writeAtomic;
80
+ }
81
+ export {};
@@ -0,0 +1,249 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { QUERY_LOG_DIR, QUERY_LOG_ID_PREFIX } from '../../constants.js';
6
+ import { QUERY_LOG_TIERS } from '../../core/domain/entities/query-log-entry.js';
7
+ const QueryLogMatchedDocFileSchema = z.object({
8
+ path: z.string(),
9
+ score: z.number(),
10
+ title: z.string(),
11
+ });
12
+ const QueryLogSearchMetadataFileSchema = z.object({
13
+ cacheFingerprint: z.string().optional(),
14
+ resultCount: z.number(),
15
+ topScore: z.number(),
16
+ totalFound: z.number(),
17
+ });
18
+ const QueryLogTimingFileSchema = z.object({
19
+ durationMs: z.number(),
20
+ });
21
+ // Single source of truth: tier validation is derived from QUERY_LOG_TIERS at runtime.
22
+ // Adding/removing a tier in the entity automatically updates schema validation.
23
+ const QUERY_LOG_TIER_SET = new Set(QUERY_LOG_TIERS);
24
+ const QueryLogTierSchema = z.custom((val) => QUERY_LOG_TIER_SET.has(val), { message: 'Invalid query log tier' });
25
+ const QueryLogEntryBaseSchema = z.object({
26
+ id: z.string(),
27
+ matchedDocs: z.array(QueryLogMatchedDocFileSchema),
28
+ query: z.string(),
29
+ searchMetadata: QueryLogSearchMetadataFileSchema.optional(),
30
+ startedAt: z.number(),
31
+ taskId: z.string(),
32
+ tier: QueryLogTierSchema.optional(),
33
+ timing: QueryLogTimingFileSchema.optional(),
34
+ });
35
+ const QueryLogEntryFileSchema = z.discriminatedUnion('status', [
36
+ QueryLogEntryBaseSchema.extend({ status: z.literal('processing') }),
37
+ QueryLogEntryBaseSchema.extend({
38
+ completedAt: z.number(),
39
+ response: z.string().optional(),
40
+ status: z.literal('completed'),
41
+ }),
42
+ QueryLogEntryBaseSchema.extend({
43
+ completedAt: z.number(),
44
+ error: z.string(),
45
+ status: z.literal('error'),
46
+ }),
47
+ QueryLogEntryBaseSchema.extend({
48
+ completedAt: z.number(),
49
+ status: z.literal('cancelled'),
50
+ }),
51
+ ]);
52
+ const ID_PATTERN = new RegExp(`^${QUERY_LOG_ID_PREFIX}-\\d+$`);
53
+ const DEFAULT_MAX_ENTRIES = 1000;
54
+ const DEFAULT_MAX_AGE_DAYS = 30;
55
+ /** Entries stuck in "processing" longer than this are considered interrupted (daemon was killed). */
56
+ const STALE_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
57
+ /**
58
+ * File-based implementation of IQueryLogStore.
59
+ *
60
+ * Each log entry is stored as a JSON file:
61
+ * {baseDir}/query-log/qry-{timestamp_ms}.json
62
+ *
63
+ * Writes are atomic (tmp → rename). Reads validate with Zod and return undefined
64
+ * for corrupt/missing files. Prunes by age (default 30 days) then by count (default 1000).
65
+ */
66
+ export class FileQueryLogStore {
67
+ lastTimestamp = 0;
68
+ logDir;
69
+ maxAgeDays;
70
+ maxEntries;
71
+ pruneInFlight = false;
72
+ constructor(opts) {
73
+ this.logDir = join(opts.baseDir, QUERY_LOG_DIR);
74
+ this.maxAgeDays = opts.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
75
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
76
+ }
77
+ /**
78
+ * Retrieve an entry by ID. Returns undefined if:
79
+ * - ID format is invalid (security: prevents path traversal)
80
+ * - File does not exist
81
+ * - File content fails Zod validation (corrupted)
82
+ */
83
+ async getById(id) {
84
+ if (!ID_PATTERN.test(id))
85
+ return undefined;
86
+ try {
87
+ const raw = await readFile(this.entryPath(id), 'utf8');
88
+ const parsed = QueryLogEntryFileSchema.safeParse(JSON.parse(raw));
89
+ if (!parsed.success)
90
+ return undefined;
91
+ return await this.resolveStale(parsed.data);
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ /**
98
+ * Generate the next monotonic log entry ID in the format `qry-{timestamp_ms}`.
99
+ * Guaranteed to increase even if called multiple times in the same millisecond.
100
+ *
101
+ * Note: monotonicity is instance-local. A new instance resets `lastTimestamp` to 0
102
+ * and relies on wall-clock time. Two instances pointing at the same baseDir could
103
+ * theoretically collide in the same millisecond, but this is practically impossible
104
+ * given the sequential task queue (max concurrency = 1 per project).
105
+ */
106
+ async getNextId() {
107
+ const now = Date.now();
108
+ this.lastTimestamp = now <= this.lastTimestamp ? this.lastTimestamp + 1 : now;
109
+ return `${QUERY_LOG_ID_PREFIX}-${this.lastTimestamp}`;
110
+ }
111
+ /**
112
+ * List entries sorted newest-first (by timestamp embedded in filename).
113
+ * Filters (status, tier, after, before) are applied before limit.
114
+ * Reads stop early once `limit` matches are found, so filtered queries with small limits
115
+ * are O(matches) rather than O(total entries). Skips corrupt entries silently.
116
+ */
117
+ async list({ after, before, limit, status, tier, } = {}) {
118
+ let files;
119
+ try {
120
+ const entries = await readdir(this.logDir, { withFileTypes: true });
121
+ files = entries
122
+ .filter((e) => e.isFile() && e.name.endsWith('.json') && ID_PATTERN.test(e.name.slice(0, -5)))
123
+ .map((e) => e.name)
124
+ .sort()
125
+ .reverse(); // newest-first (lexicographic descending)
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ // Sequential read with early termination once limit matches are found.
131
+ // Avoids scanning all files for queries like list({status: ['error'], limit: 1}).
132
+ const results = [];
133
+ const targetCount = limit ?? Number.POSITIVE_INFINITY;
134
+ for (const filename of files) {
135
+ if (results.length >= targetCount)
136
+ break;
137
+ const id = filename.slice(0, -5);
138
+ // eslint-disable-next-line no-await-in-loop -- early termination requires sequential reads
139
+ const entry = await this.getById(id);
140
+ if (!entry)
141
+ continue;
142
+ if (status?.length && !status.includes(entry.status))
143
+ continue;
144
+ if (tier?.length && (entry.tier === undefined || !tier.includes(entry.tier)))
145
+ continue;
146
+ if (after !== undefined && entry.startedAt < after)
147
+ continue;
148
+ if (before !== undefined && entry.startedAt > before)
149
+ continue;
150
+ results.push(entry);
151
+ }
152
+ return results;
153
+ }
154
+ /**
155
+ * Persist a log entry atomically (write to tmp, then rename).
156
+ * On rename failure, cleans up the tmp file. After saving, prunes by age then by count (best-effort).
157
+ */
158
+ async save(entry) {
159
+ await mkdir(this.logDir, { recursive: true });
160
+ await this.writeAtomic(this.entryPath(entry.id), JSON.stringify(entry, null, 2));
161
+ this.firePrune();
162
+ }
163
+ entryPath(id) {
164
+ return join(this.logDir, `${id}.json`);
165
+ }
166
+ /**
167
+ * Schedule a prune pass without blocking the caller.
168
+ * Deduplicates concurrent calls — only one prune runs at a time.
169
+ */
170
+ firePrune() {
171
+ if (this.pruneInFlight)
172
+ return;
173
+ this.pruneInFlight = true;
174
+ this.pruneOldest()
175
+ .catch(() => { })
176
+ .finally(() => {
177
+ this.pruneInFlight = false;
178
+ });
179
+ }
180
+ async pruneOldest() {
181
+ const dirEntries = await readdir(this.logDir, { withFileTypes: true });
182
+ const files = dirEntries
183
+ .filter((e) => e.isFile() && e.name.endsWith('.json') && ID_PATTERN.test(e.name.slice(0, -5)))
184
+ .map((e) => e.name)
185
+ .sort(); // oldest-first
186
+ // Phase 1: Age-based pruning (skip if maxAgeDays === 0)
187
+ let remaining = files;
188
+ if (this.maxAgeDays > 0) {
189
+ const cutoff = Date.now() - this.maxAgeDays * 86_400_000;
190
+ const expired = [];
191
+ const kept = [];
192
+ for (const f of files) {
193
+ const ts = Number(f.slice(QUERY_LOG_ID_PREFIX.length + 1, -5)); // extract timestamp from "qry-{ts}.json"
194
+ if (ts < cutoff) {
195
+ expired.push(f);
196
+ }
197
+ else {
198
+ kept.push(f);
199
+ }
200
+ }
201
+ if (expired.length > 0) {
202
+ await Promise.all(expired.map((f) => rm(join(this.logDir, f), { force: true }).catch(() => { })));
203
+ }
204
+ remaining = kept;
205
+ }
206
+ // Phase 2: Count-based pruning
207
+ if (remaining.length <= this.maxEntries)
208
+ return;
209
+ const toDelete = remaining.slice(0, remaining.length - this.maxEntries);
210
+ await Promise.all(toDelete.map((f) => rm(join(this.logDir, f), { force: true }).catch(() => { })));
211
+ }
212
+ /**
213
+ * If a "processing" entry is older than STALE_PROCESSING_THRESHOLD_MS, the daemon
214
+ * was killed before it could finalize it. Rewrite it as "error" on disk (best-effort)
215
+ * and return the corrected entry so the display shows "interrupted" instead of processing.
216
+ *
217
+ * Uses writeAtomic directly (not save) to skip the prune cascade — list() with N stale
218
+ * entries would otherwise trigger N concurrent prune passes.
219
+ */
220
+ async resolveStale(entry) {
221
+ if (entry.status !== 'processing')
222
+ return entry;
223
+ if (Date.now() - entry.startedAt <= STALE_PROCESSING_THRESHOLD_MS)
224
+ return entry;
225
+ const recovered = {
226
+ ...entry,
227
+ completedAt: Date.now(),
228
+ error: 'Interrupted (daemon terminated)',
229
+ status: 'error',
230
+ };
231
+ this.writeAtomic(this.entryPath(recovered.id), JSON.stringify(recovered, null, 2)).catch(() => { });
232
+ return recovered;
233
+ }
234
+ /**
235
+ * Atomic write: write to a tmp file with random UUID suffix, then rename.
236
+ * On failure, cleans up the tmp file and re-throws the original error.
237
+ */
238
+ async writeAtomic(filePath, content) {
239
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
240
+ try {
241
+ await writeFile(tmpPath, content, 'utf8');
242
+ await rename(tmpPath, filePath);
243
+ }
244
+ catch (error) {
245
+ await rm(tmpPath, { force: true }).catch(() => { });
246
+ throw error;
247
+ }
248
+ }
249
+ }
@@ -13,7 +13,7 @@ export class ConfigHandler {
13
13
  this.transport.onRequest(ConfigEvents.GET_ENVIRONMENT, () => {
14
14
  const config = getCurrentConfig();
15
15
  return {
16
- apiBaseUrl: config.apiBaseUrl,
16
+ iamBaseUrl: config.iamBaseUrl,
17
17
  isDevelopment: isDevelopment(),
18
18
  webAppUrl: config.webAppUrl,
19
19
  };
@@ -67,8 +67,12 @@ export class CurateLogUseCase {
67
67
  }
68
68
  this.log();
69
69
  this.log('Input:');
70
- if (entry.input.context)
71
- this.log(` Context: ${entry.input.context.slice(0, 200)}${entry.input.context.length > 200 ? '...' : ''}`);
70
+ if (entry.input.context) {
71
+ const [firstLine, ...rest] = entry.input.context.split('\n');
72
+ this.log(` Context: ${firstLine}`);
73
+ for (const line of rest)
74
+ this.log(` ${line}`);
75
+ }
72
76
  if (entry.input.files?.length)
73
77
  this.log(` Files: ${entry.input.files.join(', ')}`);
74
78
  if (entry.input.folders?.length)
@@ -89,7 +93,7 @@ export class CurateLogUseCase {
89
93
  if (entry.status === 'completed' && entry.response) {
90
94
  this.log();
91
95
  this.log('Response:');
92
- this.log(` ${entry.response.slice(0, 500)}${entry.response.length > 500 ? '...' : ''}`);
96
+ this.log(entry.response.split('\n').map((line) => ` ${line}`).join('\n'));
93
97
  }
94
98
  }
95
99
  async showList({ after, before, detail, format, limit, status }) {
@@ -0,0 +1,15 @@
1
+ import type { QueryLogSummary } from '../../core/interfaces/usecase/i-query-log-summary-use-case.js';
2
+ export declare function formatQueryLogSummaryNarrative(summary: QueryLogSummary): string;
3
+ /**
4
+ * Describe the time period as a human-readable label.
5
+ *
6
+ * Two formats:
7
+ * - 'short': "last 1h", "last 24h", "last 7d" (for text summary header)
8
+ * - 'long': "in the last hour", "in the last 24 hours" (for narrative prose)
9
+ *
10
+ * Only produces a period label when we have a clear relative window
11
+ * (--since/--last without --before). Bounded or ambiguous ranges
12
+ * return empty string (short) or "in the selected period" (long).
13
+ */
14
+ export declare function describePeriod(period: QueryLogSummary['period'], format?: 'long' | 'short'): string;
15
+ export declare function formatDurationMs(ms: number): string;
@@ -0,0 +1,79 @@
1
+ const NARRATIVE_TOP_DOCS = 2;
2
+ const NARRATIVE_TOP_GAPS = 2;
3
+ const MS_PER_HOUR = 3_600_000;
4
+ const MS_PER_DAY = 86_400_000;
5
+ export function formatQueryLogSummaryNarrative(summary) {
6
+ const periodLabel = describePeriod(summary.period);
7
+ if (summary.totalQueries === 0) {
8
+ return `No queries recorded ${periodLabel}. Your knowledge base is ready — try asking a question!`;
9
+ }
10
+ const paragraphs = [];
11
+ paragraphs.push(buildOverviewParagraph(summary, periodLabel));
12
+ if (summary.totalMatchedDocs > 0 && summary.topRecalledDocs.length > 0) {
13
+ paragraphs.push(buildTopDocsParagraph(summary));
14
+ }
15
+ paragraphs.push(buildGapsParagraph(summary));
16
+ return paragraphs.join('\n\n');
17
+ }
18
+ /**
19
+ * Describe the time period as a human-readable label.
20
+ *
21
+ * Two formats:
22
+ * - 'short': "last 1h", "last 24h", "last 7d" (for text summary header)
23
+ * - 'long': "in the last hour", "in the last 24 hours" (for narrative prose)
24
+ *
25
+ * Only produces a period label when we have a clear relative window
26
+ * (--since/--last without --before). Bounded or ambiguous ranges
27
+ * return empty string (short) or "in the selected period" (long).
28
+ */
29
+ export function describePeriod(period, format = 'long') {
30
+ if (period.from > 0 && period.to === 0) {
31
+ const spanMs = Date.now() - period.from;
32
+ const hours = Math.round(spanMs / MS_PER_HOUR);
33
+ const days = Math.round(spanMs / MS_PER_DAY);
34
+ if (format === 'short') {
35
+ if (hours <= 1)
36
+ return 'last 1h';
37
+ if (hours <= 24)
38
+ return 'last 24h';
39
+ return `last ${days}d`;
40
+ }
41
+ if (hours <= 1)
42
+ return 'in the last hour';
43
+ if (hours <= 24)
44
+ return 'in the last 24 hours';
45
+ return `in the last ${days} days`;
46
+ }
47
+ return format === 'short' ? 'selected period' : 'in the selected period';
48
+ }
49
+ function buildOverviewParagraph(summary, periodLabel) {
50
+ const { byStatus, cacheHitRate, coverageRate, queriesWithoutMatches, responseTime, totalQueries } = summary;
51
+ const answered = byStatus.completed - queriesWithoutMatches;
52
+ const coveragePct = Math.round(coverageRate * 100);
53
+ const cachePct = Math.round(cacheHitRate * 100);
54
+ return (`Your team asked ${totalQueries} questions ${periodLabel}. ` +
55
+ `ByteRover answered ${answered} from curated knowledge ` +
56
+ `(${coveragePct}% coverage), with ${cachePct}% served from cache. ` +
57
+ `Average response time was ${formatDurationMs(responseTime.avgMs)}.`);
58
+ }
59
+ function buildTopDocsParagraph(summary) {
60
+ const topDocs = summary.topRecalledDocs.slice(0, NARRATIVE_TOP_DOCS);
61
+ const docsList = topDocs.map((doc) => `${doc.path} (${doc.count} queries)`).join(', ');
62
+ return `Most useful knowledge: ${docsList}.`;
63
+ }
64
+ function buildGapsParagraph(summary) {
65
+ if (summary.knowledgeGaps.length === 0) {
66
+ return 'Every question was answered from curated knowledge.';
67
+ }
68
+ const unansweredCount = summary.queriesWithoutMatches;
69
+ const topGaps = summary.knowledgeGaps.slice(0, NARRATIVE_TOP_GAPS);
70
+ const gapsList = topGaps.map((gap) => `"${gap.topic}"`).join(' and ');
71
+ return (`${unansweredCount} question${unansweredCount === 1 ? '' : 's'} couldn't be answered — ` +
72
+ `consider curating more about ${gapsList}.`);
73
+ }
74
+ export function formatDurationMs(ms) {
75
+ if (ms >= 1000) {
76
+ return `${(ms / 1000).toFixed(1)}s`;
77
+ }
78
+ return `${Math.round(ms)}ms`;
79
+ }
@@ -0,0 +1,13 @@
1
+ import type { ITerminal } from '../../core/interfaces/i-terminal.js';
2
+ import type { IQueryLogStore } from '../../core/interfaces/storage/i-query-log-store.js';
3
+ import type { IQueryLogSummaryUseCase } from '../../core/interfaces/usecase/i-query-log-summary-use-case.js';
4
+ type QueryLogSummaryUseCaseDeps = {
5
+ queryLogStore: IQueryLogStore;
6
+ terminal: ITerminal;
7
+ };
8
+ export declare class QueryLogSummaryUseCase implements IQueryLogSummaryUseCase {
9
+ private readonly deps;
10
+ constructor(deps: QueryLogSummaryUseCaseDeps);
11
+ run(options: Parameters<IQueryLogSummaryUseCase['run']>[0]): Promise<void>;
12
+ }
13
+ export {};