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.
- package/.env.production +4 -6
- package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.js +1 -0
- package/dist/oclif/commands/curate/view.js +5 -25
- package/dist/oclif/commands/dream.d.ts +18 -0
- package/dist/oclif/commands/dream.js +230 -0
- package/dist/oclif/commands/query-log/summary.d.ts +18 -0
- package/dist/oclif/commands/query-log/summary.js +75 -0
- package/dist/oclif/commands/query-log/view.d.ts +23 -0
- package/dist/oclif/commands/query-log/view.js +95 -0
- package/dist/oclif/lib/time-filter.d.ts +10 -0
- package/dist/oclif/lib/time-filter.js +21 -0
- package/dist/server/config/environment.d.ts +10 -3
- package/dist/server/config/environment.js +34 -15
- package/dist/server/constants.d.ts +5 -0
- package/dist/server/constants.js +7 -0
- package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
- package/dist/server/core/domain/entities/query-log-entry.js +40 -0
- package/dist/server/core/domain/transport/schemas.d.ts +108 -7
- package/dist/server/core/domain/transport/schemas.js +34 -2
- package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
- package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
- package/dist/server/core/interfaces/i-terminal.js +1 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
- package/dist/server/infra/daemon/agent-process.js +79 -9
- package/dist/server/infra/daemon/brv-server.js +74 -5
- package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
- package/dist/server/infra/dream/dream-lock-service.js +88 -0
- package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
- package/dist/server/infra/dream/dream-log-schema.js +57 -0
- package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
- package/dist/server/infra/dream/dream-log-store.js +141 -0
- package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
- package/dist/server/infra/dream/dream-response-schemas.js +38 -0
- package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
- package/dist/server/infra/dream/dream-state-schema.js +23 -0
- package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
- package/dist/server/infra/dream/dream-state-service.js +91 -0
- package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
- package/dist/server/infra/dream/dream-trigger.js +65 -0
- package/dist/server/infra/dream/dream-undo.d.ts +38 -0
- package/dist/server/infra/dream/dream-undo.js +293 -0
- package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
- package/dist/server/infra/dream/operations/consolidate.js +514 -0
- package/dist/server/infra/dream/operations/prune.d.ts +45 -0
- package/dist/server/infra/dream/operations/prune.js +362 -0
- package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
- package/dist/server/infra/dream/operations/synthesize.js +278 -0
- package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
- package/dist/server/infra/dream/parse-dream-response.js +35 -0
- package/dist/server/infra/executor/curate-executor.js +10 -0
- package/dist/server/infra/executor/dream-executor.d.ts +97 -0
- package/dist/server/infra/executor/dream-executor.js +431 -0
- package/dist/server/infra/executor/query-executor.d.ts +2 -2
- package/dist/server/infra/executor/query-executor.js +92 -22
- package/dist/server/infra/mcp/mcp-server.js +3 -0
- package/dist/server/infra/mcp/tools/brv-curate-tool.js +3 -7
- package/dist/server/infra/mcp/tools/brv-query-tool.js +3 -7
- package/dist/server/infra/mcp/tools/index.d.ts +1 -0
- package/dist/server/infra/mcp/tools/index.js +1 -0
- package/dist/server/infra/mcp/tools/shared-schema.d.ts +3 -0
- package/dist/server/infra/mcp/tools/shared-schema.js +17 -0
- package/dist/server/infra/process/feature-handlers.js +10 -6
- package/dist/server/infra/process/query-log-handler.d.ts +42 -0
- package/dist/server/infra/process/query-log-handler.js +150 -0
- package/dist/server/infra/process/task-router.d.ts +40 -0
- package/dist/server/infra/process/task-router.js +67 -9
- package/dist/server/infra/process/transport-handlers.d.ts +4 -0
- package/dist/server/infra/process/transport-handlers.js +1 -0
- package/dist/server/infra/storage/file-curate-log-store.js +1 -1
- package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
- package/dist/server/infra/storage/file-query-log-store.js +249 -0
- package/dist/server/infra/transport/handlers/config-handler.js +1 -1
- package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
- package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
- package/dist/server/infra/usecase/query-log-use-case.js +128 -0
- package/dist/server/utils/log-format-utils.d.ts +5 -0
- package/dist/server/utils/log-format-utils.js +23 -0
- package/dist/shared/transport/events/config-events.d.ts +1 -1
- package/oclif.manifest.json +439 -184
- 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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {};
|