byterover-cli 3.5.1 → 3.6.1

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 (84) 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/process/feature-handlers.js +10 -6
  63. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  64. package/dist/server/infra/process/query-log-handler.js +150 -0
  65. package/dist/server/infra/process/task-router.d.ts +40 -0
  66. package/dist/server/infra/process/task-router.js +67 -9
  67. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  68. package/dist/server/infra/process/transport-handlers.js +1 -0
  69. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  70. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  71. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  72. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  73. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  74. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  75. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  76. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  77. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  78. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  79. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  80. package/dist/server/utils/log-format-utils.d.ts +5 -0
  81. package/dist/server/utils/log-format-utils.js +23 -0
  82. package/dist/shared/transport/events/config-events.d.ts +1 -1
  83. package/oclif.manifest.json +510 -255
  84. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { join, relative } from 'node:path';
2
2
  import { ABSTRACT_EXTENSION, BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR } from '../../constants.js';
3
+ import { TIER_DIRECT_SEARCH, TIER_EXACT_CACHE, TIER_FULL_AGENTIC, TIER_FUZZY_CACHE, TIER_OPTIMIZED_LLM, } from '../../core/domain/entities/query-log-entry.js';
3
4
  import { loadSources } from '../../core/domain/source/source-schema.js';
4
5
  import { isDerivedArtifact } from '../context-tree/derived-artifact.js';
5
6
  import { FileContextTreeManifestService } from '../context-tree/file-context-tree-manifest-service.js';
@@ -7,6 +8,10 @@ import { canRespondDirectly, formatDirectResponse, formatNotFoundResponse, } fro
7
8
  import { QueryResultCache } from './query-result-cache.js';
8
9
  /** Attribution footer appended to all query responses */
9
10
  const ATTRIBUTION_FOOTER = '\n\n---\nSource: ByteRover Knowledge Base';
11
+ /** Map search results to the matchedDocs shape for QueryExecutorResult. */
12
+ function buildMatchedDocs(sr) {
13
+ return (sr?.results ?? []).map((r) => ({ path: r.path, score: r.score, title: r.title }));
14
+ }
10
15
  /** Minimum normalized score to consider a result high-confidence for pre-fetching */
11
16
  const SMART_ROUTING_SCORE_THRESHOLD = 0.7;
12
17
  /** Maximum number of documents to pre-fetch and inject into the prompt */
@@ -46,6 +51,7 @@ export class QueryExecutor {
46
51
  }
47
52
  }
48
53
  async executeWithAgent(agent, options) {
54
+ const startTime = Date.now();
49
55
  const { query, taskId, worktreeRoot } = options;
50
56
  const workspaceScope = this.deriveWorkspaceScope(worktreeRoot);
51
57
  // Start search early — runs in parallel with fingerprint computation (independent operations)
@@ -58,14 +64,24 @@ export class QueryExecutor {
58
64
  fingerprint = await this.computeContextTreeFingerprint(worktreeRoot);
59
65
  const cached = this.cache.get(query, fingerprint);
60
66
  if (cached) {
61
- return cached + ATTRIBUTION_FOOTER;
67
+ return {
68
+ matchedDocs: [],
69
+ response: cached + ATTRIBUTION_FOOTER,
70
+ tier: TIER_EXACT_CACHE,
71
+ timing: { durationMs: Date.now() - startTime },
72
+ };
62
73
  }
63
74
  }
64
75
  // === Tier 1: Fuzzy cache match (~50ms) ===
65
76
  if (this.cache && fingerprint) {
66
77
  const fuzzyHit = this.cache.findSimilar(query, fingerprint);
67
78
  if (fuzzyHit) {
68
- return fuzzyHit + ATTRIBUTION_FOOTER;
79
+ return {
80
+ matchedDocs: [],
81
+ response: fuzzyHit + ATTRIBUTION_FOOTER,
82
+ tier: TIER_FUZZY_CACHE,
83
+ timing: { durationMs: Date.now() - startTime },
84
+ };
69
85
  }
70
86
  }
71
87
  // Await search result (already started in parallel with fingerprint computation)
@@ -86,7 +102,13 @@ export class QueryExecutor {
86
102
  if (this.cache && fingerprint) {
87
103
  this.cache.set(query, response, fingerprint);
88
104
  }
89
- return response + ATTRIBUTION_FOOTER;
105
+ return {
106
+ matchedDocs: [],
107
+ response: response + ATTRIBUTION_FOOTER,
108
+ searchMetadata: { resultCount: 0, topScore: 0, totalFound: 0 },
109
+ tier: TIER_DIRECT_SEARCH,
110
+ timing: { durationMs: Date.now() - startTime },
111
+ };
90
112
  }
91
113
  // === Tier 2: Direct search response (~100-200ms) ===
92
114
  if (searchResult && this.fileSystem) {
@@ -95,7 +117,18 @@ export class QueryExecutor {
95
117
  if (this.cache && fingerprint) {
96
118
  this.cache.set(query, directResult, fingerprint);
97
119
  }
98
- return directResult + ATTRIBUTION_FOOTER;
120
+ return {
121
+ matchedDocs: buildMatchedDocs(searchResult),
122
+ response: directResult + ATTRIBUTION_FOOTER,
123
+ searchMetadata: {
124
+ cacheFingerprint: fingerprint,
125
+ resultCount: searchResult.results.length,
126
+ topScore: searchResult.results[0]?.score ?? 0,
127
+ totalFound: searchResult.totalFound,
128
+ },
129
+ tier: TIER_DIRECT_SEARCH,
130
+ timing: { durationMs: Date.now() - startTime },
131
+ };
99
132
  }
100
133
  }
101
134
  // === Tier 3/4: LLM call with RLM pattern (variable-based search results) ===
@@ -115,9 +148,7 @@ export class QueryExecutor {
115
148
  if (manifest) {
116
149
  const resolved = await manifestService.resolveForInjection(manifest, query, this.baseDirectory);
117
150
  if (resolved.length > 0) {
118
- manifestContext = resolved
119
- .map((e) => `[${e.type} ${e.path}]\n${e.content}`)
120
- .join('\n\n---\n\n');
151
+ manifestContext = resolved.map((e) => `[${e.type} ${e.path}]\n${e.content}`).join('\n\n---\n\n');
121
152
  }
122
153
  }
123
154
  }
@@ -169,7 +200,19 @@ export class QueryExecutor {
169
200
  if (this.cache && fingerprint) {
170
201
  this.cache.set(query, response, fingerprint);
171
202
  }
172
- return response + ATTRIBUTION_FOOTER;
203
+ const tier = prefetchedContext ? TIER_OPTIMIZED_LLM : TIER_FULL_AGENTIC;
204
+ return {
205
+ matchedDocs: buildMatchedDocs(searchResult),
206
+ response: response + ATTRIBUTION_FOOTER,
207
+ searchMetadata: {
208
+ cacheFingerprint: fingerprint,
209
+ resultCount: searchResult?.results.length ?? 0,
210
+ topScore: searchResult?.results[0]?.score ?? 0,
211
+ totalFound: searchResult?.totalFound ?? 0,
212
+ },
213
+ tier,
214
+ timing: { durationMs: Date.now() - startTime },
215
+ };
173
216
  }
174
217
  finally {
175
218
  // Clean up entire task session (sandbox + history) in one call
@@ -188,9 +231,7 @@ export class QueryExecutor {
188
231
  if (highConfidenceResults.length === 0)
189
232
  return undefined;
190
233
  const sections = highConfidenceResults.map((r) => {
191
- const source = r.origin === 'shared' && r.originAlias
192
- ? `[${r.originAlias}]:${r.path}`
193
- : `.brv/context-tree/${r.path}`;
234
+ const source = r.origin === 'shared' && r.originAlias ? `[${r.originAlias}]:${r.path}` : `.brv/context-tree/${r.path}`;
194
235
  return `### ${r.title}\n**Source**: ${source}\n\n${r.excerpt}`;
195
236
  });
196
237
  return sections.join('\n\n---\n\n');
@@ -274,9 +315,10 @@ ${responseFormat}`;
274
315
  async computeContextTreeFingerprint(worktreeRoot) {
275
316
  // Fast path: return cached fingerprint if still valid (avoids globFiles I/O)
276
317
  // Invalidate if worktreeRoot changed or knowledge source validity changed
277
- if (this.cachedFingerprint && Date.now() < this.cachedFingerprint.expiresAt
278
- && this.cachedFingerprint.worktreeRoot === worktreeRoot
279
- && this.cachedFingerprint.sourceValidityHash === this.computeSourceValidityHash()) {
318
+ if (this.cachedFingerprint &&
319
+ Date.now() < this.cachedFingerprint.expiresAt &&
320
+ this.cachedFingerprint.worktreeRoot === worktreeRoot &&
321
+ this.cachedFingerprint.sourceValidityHash === this.computeSourceValidityHash()) {
280
322
  return this.cachedFingerprint.value;
281
323
  }
282
324
  try {
@@ -298,7 +340,7 @@ ${responseFormat}`;
298
340
  }));
299
341
  // Include shared source state in fingerprint so edits in shared
300
342
  // projects invalidate cached query answers.
301
- const loaded = this.baseDirectory ? loadSources(this.baseDirectory) : null;
343
+ const loaded = this.baseDirectory ? loadSources(this.baseDirectory) : undefined;
302
344
  if (loaded) {
303
345
  // sources-file mtime detects source additions/removals
304
346
  if (loaded.mtime) {
@@ -357,7 +399,10 @@ ${responseFormat}`;
357
399
  const loaded = loadSources(this.baseDirectory);
358
400
  if (!loaded)
359
401
  return 'no-sources';
360
- return loaded.origins.map((o) => o.originKey).sort().join(',');
402
+ return loaded.origins
403
+ .map((o) => o.originKey)
404
+ .sort()
405
+ .join(',');
361
406
  }
362
407
  /**
363
408
  * Derive a workspace scope for search from the worktreeRoot.
@@ -385,9 +430,36 @@ ${responseFormat}`;
385
430
  */
386
431
  extractQueryEntities(query) {
387
432
  const stopwords = new Set([
388
- 'a', 'about', 'an', 'and', 'by', 'did', 'do', 'does', 'for', 'from',
389
- 'how', 'in', 'is', 'my', 'of', 'or', 'our', 'that', 'the', 'their',
390
- 'this', 'to', 'was', 'were', 'what', 'when', 'where', 'which', 'who', 'with',
433
+ 'a',
434
+ 'about',
435
+ 'an',
436
+ 'and',
437
+ 'by',
438
+ 'did',
439
+ 'do',
440
+ 'does',
441
+ 'for',
442
+ 'from',
443
+ 'how',
444
+ 'in',
445
+ 'is',
446
+ 'my',
447
+ 'of',
448
+ 'or',
449
+ 'our',
450
+ 'that',
451
+ 'the',
452
+ 'their',
453
+ 'this',
454
+ 'to',
455
+ 'was',
456
+ 'were',
457
+ 'what',
458
+ 'when',
459
+ 'where',
460
+ 'which',
461
+ 'who',
462
+ 'with',
391
463
  ]);
392
464
  const words = query.toLowerCase().split(/\s+/);
393
465
  return words.filter((w) => w.length >= 3 && !stopwords.has(w));
@@ -461,9 +533,7 @@ ${responseFormat}`;
461
533
  // Use excerpt if full read fails
462
534
  }
463
535
  // Include source attribution in path for shared results
464
- const displayPath = result.origin === 'shared' && result.originAlias
465
- ? `[${result.originAlias}]:${result.path}`
466
- : result.path;
536
+ const displayPath = result.origin === 'shared' && result.originAlias ? `[${result.originAlias}]:${result.path}` : result.path;
467
537
  return { content, path: displayPath, score: result.score, title: result.title };
468
538
  }));
469
539
  if (!canRespondDirectly(fullResults))
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { ReviewEvents } from '../../../shared/transport/events/review-events.js';
10
10
  import { getAuthConfig } from '../../config/auth.config.js';
11
11
  import { getCurrentConfig } from '../../config/environment.js';
12
- import { BRV_DIR } from '../../constants.js';
12
+ import { API_V1_PATH, BRV_DIR } from '../../constants.js';
13
13
  import { getProjectDataDir } from '../../utils/path-utils.js';
14
14
  import { OAuthService } from '../auth/oauth-service.js';
15
15
  import { OidcDiscoveryService } from '../auth/oidc-discovery-service.js';
@@ -48,9 +48,12 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
48
48
  const envConfig = getCurrentConfig();
49
49
  const tokenStore = createTokenStore();
50
50
  const projectConfigStore = new ProjectConfigStore();
51
- const userService = new HttpUserService({ apiBaseUrl: envConfig.apiBaseUrl });
52
- const teamService = new HttpTeamService({ apiBaseUrl: envConfig.apiBaseUrl });
53
- const spaceService = new HttpSpaceService({ apiBaseUrl: envConfig.apiBaseUrl });
51
+ // API version paths appended at point of use.
52
+ // Note: IAM and Cogit currently share this version path, but may version independently in the future.
53
+ const iamApiV1 = `${envConfig.iamBaseUrl}${API_V1_PATH}`;
54
+ const userService = new HttpUserService({ apiBaseUrl: iamApiV1 });
55
+ const teamService = new HttpTeamService({ apiBaseUrl: iamApiV1 });
56
+ const spaceService = new HttpSpaceService({ apiBaseUrl: iamApiV1 });
54
57
  // Auth handler requires async OIDC discovery
55
58
  const discoveryService = new OidcDiscoveryService();
56
59
  const authConfig = await getAuthConfig(discoveryService);
@@ -86,8 +89,9 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
86
89
  const contextTreeWriterService = new FileContextTreeWriterService({ snapshotService: contextTreeSnapshotService });
87
90
  const contextTreeMerger = new FileContextTreeMerger({ snapshotService: contextTreeSnapshotService });
88
91
  const contextFileReader = new FileContextFileReader();
89
- const cogitPushService = new HttpCogitPushService({ apiBaseUrl: envConfig.cogitApiBaseUrl });
90
- const cogitPullService = new HttpCogitPullService({ apiBaseUrl: envConfig.cogitApiBaseUrl });
92
+ const cogitApiV1 = `${envConfig.cogitBaseUrl}${API_V1_PATH}`;
93
+ const cogitPushService = new HttpCogitPushService({ apiBaseUrl: cogitApiV1 });
94
+ const cogitPullService = new HttpCogitPullService({ apiBaseUrl: cogitApiV1 });
91
95
  // ConnectorManager factory — creates per-project instances since constructor binds to projectRoot
92
96
  const fileService = new FsFileService();
93
97
  const templateLoader = new FsTemplateLoader(fileService);
@@ -0,0 +1,42 @@
1
+ import type { TaskInfo } from '../../core/domain/transport/task-info.js';
2
+ import type { QueryExecutorResult } from '../../core/interfaces/executor/i-query-executor.js';
3
+ import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-lifecycle-hook.js';
4
+ import type { IQueryLogStore } from '../../core/interfaces/storage/i-query-log-store.js';
5
+ /** Query metadata without the response string (response arrives via task:completed). */
6
+ type QueryResultMetadata = Omit<QueryExecutorResult, 'response'>;
7
+ /**
8
+ * Lifecycle hook that transparently logs query task execution.
9
+ *
10
+ * Wired into TaskRouter via lifecycleHooks[]. Writes log entries to
11
+ * per-project FileQueryLogStore. All I/O errors are swallowed — logging
12
+ * must never block or affect query task execution.
13
+ *
14
+ * Key difference from CurateLogHandler: no onToolResult accumulation.
15
+ * Query metadata (tier, timing, matchedDocs, searchMetadata) arrives via
16
+ * setQueryResult() called after QueryExecutor.executeWithAgent() returns.
17
+ */
18
+ export declare class QueryLogHandler implements ITaskLifecycleHook {
19
+ private readonly createStore?;
20
+ /** Active task count per projectPath — used to evict idle stores. */
21
+ private readonly activeTaskCount;
22
+ /** Per-project store cache (one store per projectPath). Evicted when no active tasks remain. */
23
+ private readonly stores;
24
+ /** In-memory state per active task. Cleared on cleanup(). */
25
+ private readonly tasks;
26
+ constructor(createStore?: ((projectPath: string) => IQueryLogStore) | undefined);
27
+ cleanup(taskId: string): void;
28
+ onTaskCancelled(taskId: string, _task: TaskInfo): Promise<void>;
29
+ onTaskCompleted(taskId: string, result: string, _task: TaskInfo): Promise<void>;
30
+ onTaskCreate(task: TaskInfo): Promise<void | {
31
+ logId?: string;
32
+ }>;
33
+ onTaskError(taskId: string, errorMessage: string, _task: TaskInfo): Promise<void>;
34
+ /**
35
+ * Store query execution metadata for later finalization.
36
+ * Called by agent-process after QueryExecutor.executeWithAgent() returns.
37
+ * Synchronous — no I/O. Metadata is merged into the final entry on completion.
38
+ */
39
+ setQueryResult(taskId: string, result: QueryResultMetadata): void;
40
+ private getOrCreateStore;
41
+ }
42
+ export {};
@@ -0,0 +1,150 @@
1
+ import { getProjectDataDir } from '../../utils/path-utils.js';
2
+ import { transportLog } from '../../utils/process-logger.js';
3
+ import { FileQueryLogStore } from '../storage/file-query-log-store.js';
4
+ const QUERY_TASK_TYPES = new Set(['query']);
5
+ // ── QueryLogHandler ──────────────────────────────────────────────────────────
6
+ /**
7
+ * Lifecycle hook that transparently logs query task execution.
8
+ *
9
+ * Wired into TaskRouter via lifecycleHooks[]. Writes log entries to
10
+ * per-project FileQueryLogStore. All I/O errors are swallowed — logging
11
+ * must never block or affect query task execution.
12
+ *
13
+ * Key difference from CurateLogHandler: no onToolResult accumulation.
14
+ * Query metadata (tier, timing, matchedDocs, searchMetadata) arrives via
15
+ * setQueryResult() called after QueryExecutor.executeWithAgent() returns.
16
+ */
17
+ export class QueryLogHandler {
18
+ createStore;
19
+ /** Active task count per projectPath — used to evict idle stores. */
20
+ activeTaskCount = new Map();
21
+ /** Per-project store cache (one store per projectPath). Evicted when no active tasks remain. */
22
+ stores = new Map();
23
+ /** In-memory state per active task. Cleared on cleanup(). */
24
+ tasks = new Map();
25
+ constructor(createStore) {
26
+ this.createStore = createStore;
27
+ }
28
+ cleanup(taskId) {
29
+ const state = this.tasks.get(taskId);
30
+ this.tasks.delete(taskId);
31
+ if (state) {
32
+ const remaining = (this.activeTaskCount.get(state.projectPath) ?? 1) - 1;
33
+ if (remaining <= 0) {
34
+ this.activeTaskCount.delete(state.projectPath);
35
+ this.stores.delete(state.projectPath);
36
+ }
37
+ else {
38
+ this.activeTaskCount.set(state.projectPath, remaining);
39
+ }
40
+ }
41
+ }
42
+ async onTaskCancelled(taskId, _task) {
43
+ const state = this.tasks.get(taskId);
44
+ if (!state)
45
+ return;
46
+ const store = this.getOrCreateStore(state.projectPath);
47
+ const updated = {
48
+ ...state.entry,
49
+ completedAt: Date.now(),
50
+ matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
51
+ searchMetadata: state.queryResult?.searchMetadata,
52
+ status: 'cancelled',
53
+ tier: state.queryResult?.tier,
54
+ timing: state.queryResult?.timing,
55
+ };
56
+ await store.save(updated).catch((error) => {
57
+ transportLog(`QueryLogHandler: failed to save cancelled entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
58
+ });
59
+ }
60
+ async onTaskCompleted(taskId, result, _task) {
61
+ const state = this.tasks.get(taskId);
62
+ if (!state)
63
+ return;
64
+ const store = this.getOrCreateStore(state.projectPath);
65
+ const updated = {
66
+ ...state.entry,
67
+ completedAt: Date.now(),
68
+ matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
69
+ response: result.length > 0 ? result : undefined,
70
+ searchMetadata: state.queryResult?.searchMetadata,
71
+ status: 'completed',
72
+ tier: state.queryResult?.tier,
73
+ timing: state.queryResult?.timing,
74
+ };
75
+ await store.save(updated).catch((error) => {
76
+ transportLog(`QueryLogHandler: failed to save completed entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
77
+ });
78
+ }
79
+ async onTaskCreate(task) {
80
+ if (!QUERY_TASK_TYPES.has(task.type))
81
+ return;
82
+ if (!task.projectPath)
83
+ return;
84
+ const store = this.getOrCreateStore(task.projectPath);
85
+ const logId = await store.getNextId().catch((error) => {
86
+ transportLog(`QueryLogHandler: getNextId failed for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
87
+ });
88
+ if (!logId)
89
+ return;
90
+ const entry = {
91
+ id: logId,
92
+ matchedDocs: [],
93
+ query: task.content,
94
+ startedAt: task.createdAt,
95
+ status: 'processing',
96
+ taskId: task.taskId,
97
+ };
98
+ // MEMORY-FIRST: Set in-memory state BEFORE disk write so setQueryResult can access it immediately.
99
+ // Caching `entry` here lets onTaskCompleted/onTaskError rebuild the final entry
100
+ // without a getById round-trip — so completion is never lost even if this initial
101
+ // save fails.
102
+ this.tasks.set(task.taskId, { entry, projectPath: task.projectPath });
103
+ this.activeTaskCount.set(task.projectPath, (this.activeTaskCount.get(task.projectPath) ?? 0) + 1);
104
+ // Fire-and-forget disk I/O — logId is already known and returned.
105
+ store.save(entry).catch((error) => {
106
+ transportLog(`QueryLogHandler: failed to save processing entry for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
107
+ });
108
+ return { logId };
109
+ }
110
+ async onTaskError(taskId, errorMessage, _task) {
111
+ const state = this.tasks.get(taskId);
112
+ if (!state)
113
+ return;
114
+ const store = this.getOrCreateStore(state.projectPath);
115
+ const updated = {
116
+ ...state.entry,
117
+ completedAt: Date.now(),
118
+ error: errorMessage,
119
+ matchedDocs: state.queryResult?.matchedDocs ?? state.entry.matchedDocs,
120
+ searchMetadata: state.queryResult?.searchMetadata,
121
+ status: 'error',
122
+ tier: state.queryResult?.tier,
123
+ timing: state.queryResult?.timing,
124
+ };
125
+ await store.save(updated).catch((error) => {
126
+ transportLog(`QueryLogHandler: failed to save error entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
127
+ });
128
+ }
129
+ /**
130
+ * Store query execution metadata for later finalization.
131
+ * Called by agent-process after QueryExecutor.executeWithAgent() returns.
132
+ * Synchronous — no I/O. Metadata is merged into the final entry on completion.
133
+ */
134
+ setQueryResult(taskId, result) {
135
+ const state = this.tasks.get(taskId);
136
+ if (!state)
137
+ return;
138
+ state.queryResult = result;
139
+ }
140
+ getOrCreateStore(projectPath) {
141
+ const existing = this.stores.get(projectPath);
142
+ if (existing)
143
+ return existing;
144
+ const store = this.createStore
145
+ ? this.createStore(projectPath)
146
+ : new FileQueryLogStore({ baseDir: getProjectDataDir(projectPath) });
147
+ this.stores.set(projectPath, store);
148
+ return store;
149
+ }
150
+ }
@@ -14,18 +14,41 @@
14
14
  *
15
15
  * Consumed by TransportHandlers (orchestrator).
16
16
  */
17
+ import type { TaskCreateRequest } from '../../core/domain/transport/schemas.js';
17
18
  import type { IAgentPool } from '../../core/interfaces/agent/i-agent-pool.js';
18
19
  import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-lifecycle-hook.js';
19
20
  import type { IProjectRegistry } from '../../core/interfaces/project/i-project-registry.js';
20
21
  import type { IProjectRouter } from '../../core/interfaces/routing/i-project-router.js';
21
22
  import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
22
23
  import type { TaskInfo } from './types.js';
24
+ /**
25
+ * Outcome of the daemon-side pre-dispatch check.
26
+ *
27
+ * `skipResult` is the full string sent to the client as the task:completed `result`.
28
+ * The callback owns the message format so task-router stays task-type-agnostic
29
+ * (e.g. dream uses "Dream skipped: <reason>"; future task types can use their own).
30
+ */
31
+ export type PreDispatchCheckResult = {
32
+ eligible: false;
33
+ skipResult: string;
34
+ } | {
35
+ eligible: true;
36
+ };
37
+ export type PreDispatchCheck = (task: TaskCreateRequest, projectPath?: string) => Promise<PreDispatchCheckResult>;
23
38
  type TaskRouterOptions = {
24
39
  agentPool?: IAgentPool;
25
40
  /** Function to resolve agent clientId for a given project */
26
41
  getAgentForProject: (projectPath?: string) => string | undefined;
27
42
  /** Lifecycle hooks for task events (e.g. CurateLogHandler). */
28
43
  lifecycleHooks?: ITaskLifecycleHook[];
44
+ /**
45
+ * Optional daemon-side gate run before dispatching to the agent pool. If it
46
+ * resolves ineligible, task-router short-circuits with task:completed carrying
47
+ * the skip reason and never submits the task to an agent.
48
+ * Used for dream task type to enforce gates 1-3 (time, activity, queue) even
49
+ * on the CLI dispatch path — mirrors the idle-trigger pre-check pattern.
50
+ */
51
+ preDispatchCheck?: PreDispatchCheck;
29
52
  projectRegistry?: IProjectRegistry;
30
53
  projectRouter?: IProjectRouter;
31
54
  /** Resolves the projectPath a client registered with (from client:register). */
@@ -41,6 +64,7 @@ export declare class TaskRouter {
41
64
  private completedTasks;
42
65
  private readonly getAgentForProject;
43
66
  private readonly lifecycleHooks;
67
+ private readonly preDispatchCheck;
44
68
  private readonly projectRegistry;
45
69
  private readonly projectRouter;
46
70
  private readonly resolveClientProjectPath;
@@ -100,6 +124,22 @@ export declare class TaskRouter {
100
124
  */
101
125
  private handleTaskCreate;
102
126
  private handleTaskError;
127
+ /**
128
+ * Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
129
+ * before it ever reached `AgentPool.submitTask`.
130
+ *
131
+ * Distinct from {@link handleTaskCompleted}:
132
+ * - does NOT call `agentPool.notifyTaskCompleted` (the pool's `activeTasks`
133
+ * counter was never incremented, so decrementing here would undercount real
134
+ * load and let `drainQueue` dispatch an extra queued task)
135
+ * - does NOT fire `onTaskCompleted` lifecycle hooks (counters/metrics that
136
+ * act on completed tasks should not see pre-check skips as completions)
137
+ *
138
+ * Still emits the event to the client and the project room so REPL/TUI
139
+ * receive the skip result, and still calls `moveToCompleted` so the task is
140
+ * removed from the active set.
141
+ */
142
+ private handleTaskSkippedByPreCheck;
103
143
  private handleTaskStarted;
104
144
  /**
105
145
  * Move a task to the completed tasks map with grace period cleanup.
@@ -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);