byterover-cli 3.10.3 → 3.12.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 (75) hide show
  1. package/README.md +4 -2
  2. package/dist/agent/core/domain/llm/registry.d.ts +12 -0
  3. package/dist/agent/core/domain/llm/registry.js +49 -0
  4. package/dist/agent/core/domain/llm/types.d.ts +6 -0
  5. package/dist/agent/core/interfaces/i-content-generator.d.ts +8 -0
  6. package/dist/agent/infra/llm/agent-llm-service.js +18 -6
  7. package/dist/agent/infra/llm/context/context-manager.d.ts +4 -1
  8. package/dist/agent/infra/llm/context/context-manager.js +5 -1
  9. package/dist/agent/infra/llm/generators/ai-sdk-content-generator.d.ts +13 -0
  10. package/dist/agent/infra/llm/generators/ai-sdk-content-generator.js +19 -6
  11. package/dist/agent/infra/llm/generators/ai-sdk-message-converter.js +16 -4
  12. package/dist/agent/infra/llm/generators/byterover-content-generator.d.ts +1 -0
  13. package/dist/agent/infra/llm/generators/byterover-content-generator.js +4 -1
  14. package/dist/agent/infra/llm/model-capabilities.d.ts +2 -1
  15. package/dist/agent/infra/llm/model-capabilities.js +6 -4
  16. package/dist/agent/infra/llm/providers/anthropic.js +2 -0
  17. package/dist/agent/infra/llm/providers/deepseek.d.ts +10 -0
  18. package/dist/agent/infra/llm/providers/deepseek.js +33 -0
  19. package/dist/agent/infra/llm/providers/glm-coding-plan.d.ts +9 -0
  20. package/dist/agent/infra/llm/providers/glm-coding-plan.js +32 -0
  21. package/dist/agent/infra/llm/providers/index.js +4 -0
  22. package/dist/agent/infra/llm/providers/openrouter.js +2 -0
  23. package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
  24. package/dist/oclif/commands/query.js +7 -1
  25. package/dist/oclif/lib/task-client.d.ts +9 -0
  26. package/dist/oclif/lib/task-client.js +11 -1
  27. package/dist/server/constants.d.ts +6 -0
  28. package/dist/server/constants.js +11 -0
  29. package/dist/server/core/domain/entities/provider-registry.js +26 -0
  30. package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
  31. package/dist/server/core/domain/entities/task-history-entry.js +88 -0
  32. package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
  33. package/dist/server/core/domain/transport/schemas.js +157 -6
  34. package/dist/server/core/domain/transport/task-info.d.ts +18 -0
  35. package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
  36. package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
  37. package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
  38. package/dist/server/infra/daemon/brv-server.js +43 -18
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +7 -0
  41. package/dist/server/infra/dream/operations/consolidate.js +21 -8
  42. package/dist/server/infra/dream/operations/synthesize.js +35 -8
  43. package/dist/server/infra/http/provider-model-fetcher-registry.js +5 -0
  44. package/dist/server/infra/http/provider-model-fetchers.js +54 -27
  45. package/dist/server/infra/process/query-log-handler.d.ts +6 -0
  46. package/dist/server/infra/process/query-log-handler.js +23 -0
  47. package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
  48. package/dist/server/infra/process/task-history-entry-builder.js +101 -0
  49. package/dist/server/infra/process/task-history-hook.d.ts +37 -0
  50. package/dist/server/infra/process/task-history-hook.js +70 -0
  51. package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
  52. package/dist/server/infra/process/task-history-store-cache.js +106 -0
  53. package/dist/server/infra/process/task-router.d.ts +72 -0
  54. package/dist/server/infra/process/task-router.js +690 -15
  55. package/dist/server/infra/process/transport-handlers.d.ts +8 -0
  56. package/dist/server/infra/process/transport-handlers.js +2 -0
  57. package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
  58. package/dist/server/infra/storage/file-task-history-store.js +912 -0
  59. package/dist/shared/transport/events/index.d.ts +5 -0
  60. package/dist/shared/transport/events/task-events.d.ts +204 -1
  61. package/dist/shared/transport/events/task-events.js +11 -0
  62. package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
  63. package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
  64. package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
  65. package/dist/tui/types/messages.d.ts +2 -9
  66. package/dist/webui/assets/index-DyVvFoM6.css +1 -0
  67. package/dist/webui/assets/index-lr0byHh9.js +130 -0
  68. package/dist/webui/index.html +2 -2
  69. package/dist/webui/sw.js +1 -1
  70. package/dist/webui/workbox-9c191d2f.js +1 -0
  71. package/oclif.manifest.json +985 -985
  72. package/package.json +1 -1
  73. package/dist/webui/assets/index-CvcqpMYn.css +0 -1
  74. package/dist/webui/assets/index-thSZZahh.js +0 -130
  75. package/dist/webui/workbox-8c29f6e4.js +0 -1
@@ -200,15 +200,36 @@ async function writeSynthesisFile(candidate, contextTreeDir, runtimeSignalStore,
200
200
  // ENOENT — good, proceed
201
201
  }
202
202
  const sources = candidate.evidence.map((e) => `${e.domain}/_index.md`);
203
+ // Normalize tags to lowercase kebab-case so card chips and BM25 search see
204
+ // a consistent label regardless of whether the model honored the prompt's
205
+ // formatting rule. Empty entries (post-trim) are dropped.
206
+ const normalizedTags = candidate.tags
207
+ .map((t) => t.toLowerCase().trim().replaceAll(/\s+/g, '-'))
208
+ .filter((t) => t.length > 0);
209
+ const now = new Date().toISOString();
210
+ // Field order is enforced by insertion order (yamlDump uses sortKeys:false).
211
+ // Synthesis markers (confidence, sources, synthesized_at, type) come first
212
+ // in the order pre-existing synthesized files use on disk, so re-generating
213
+ // an old file does not produce a mechanical reorder diff. The seven
214
+ // semantic fields below mirror the order in markdown-writer.ts's
215
+ // generateFrontmatter so the on-disk shape matches regular `brv save`
216
+ // files; cogit then exposes them in DtoV3MemoryCardResource for card-mode
217
+ // display in the web UI.
203
218
  /* eslint-disable camelcase */
204
- const frontmatter = {
205
- confidence: candidate.confidence,
206
- sources,
207
- synthesized_at: new Date().toISOString(),
208
- type: 'synthesis',
209
- };
219
+ const frontmatter = {};
220
+ frontmatter.confidence = candidate.confidence;
221
+ frontmatter.sources = sources;
222
+ frontmatter.synthesized_at = now;
223
+ frontmatter.type = 'synthesis';
224
+ frontmatter.title = candidate.title;
225
+ frontmatter.summary = candidate.summary;
226
+ frontmatter.tags = normalizedTags;
227
+ frontmatter.related = [];
228
+ frontmatter.keywords = candidate.keywords;
229
+ frontmatter.createdAt = now;
230
+ frontmatter.updatedAt = now;
210
231
  /* eslint-enable camelcase */
211
- const yaml = yamlDump(frontmatter, { lineWidth: -1, sortKeys: false }).trimEnd();
232
+ const yaml = yamlDump(frontmatter, { flowLevel: 1, lineWidth: -1, sortKeys: false }).trimEnd();
212
233
  const body = [
213
234
  `# ${candidate.title}`,
214
235
  '',
@@ -280,11 +301,17 @@ function buildPrompt(domains, existingSyntheses) {
280
301
  '- Do NOT report trivial or obvious connections (e.g., "both domains use TypeScript").',
281
302
  '- Each synthesis must reference at least 2 domains with specific evidence.',
282
303
  '- For "placement", choose the domain where this insight is MOST actionable.',
304
+ '- "summary" is one sentence (≤ 200 chars) describing the insight; this is what the UI shows as a card preview.',
305
+ '- "tags" are 3-5 short topical labels drawn from the source domains (e.g., "auth", "caching"). Lowercase, kebab-case.',
306
+ '- "keywords" are 5-10 single words a developer would search for to surface this synthesis.',
283
307
  '- If nothing meaningful is found, return an empty array. That is fine — but missing a clear cross-domain pattern is a failure.',
284
308
  '',
309
+ // Keep the JSON shape below in sync with SynthesisCandidateSchema in
310
+ // dream-response-schemas.ts; the schema rejects responses that omit any
311
+ // listed field, so adding a field there requires updating this example.
285
312
  'Respond with JSON:',
286
313
  '```',
287
- '{ "syntheses": [{ "title": "...", "claim": "...", "evidence": [{"domain": "...", "fact": "..."}], "confidence": 0.0-1.0, "placement": "..." }] }',
314
+ '{ "syntheses": [{ "title": "...", "summary": "...", "claim": "...", "evidence": [{"domain": "...", "fact": "..."}], "tags": ["..."], "keywords": ["..."], "confidence": 0.0-1.0, "placement": "..." }] }',
288
315
  '```',
289
316
  ].join('\n');
290
317
  }
@@ -45,6 +45,7 @@ export async function getModelFetcher(providerId) {
45
45
  case 'cerebras': // falls through
46
46
  case 'cohere': // falls through
47
47
  case 'deepinfra': // falls through
48
+ case 'deepseek': // falls through
48
49
  case 'groq': // falls through
49
50
  case 'mistral': // falls through
50
51
  case 'togetherai': // falls through
@@ -59,6 +60,10 @@ export async function getModelFetcher(providerId) {
59
60
  fetcher = new ChatBasedModelFetcher('https://api.z.ai/api/paas/v4', 'GLM (Z.AI)', ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-flash']);
60
61
  break;
61
62
  }
63
+ case 'glm-coding-plan': {
64
+ fetcher = new ChatBasedModelFetcher('https://api.z.ai/api/coding/paas/v4', 'GLM Coding Plan (Z.AI)', ['glm-4.7', 'glm-4.7-flash', 'glm-4.7-flashx', 'glm-5-turbo', 'glm-4.5', 'glm-4.5-flash']);
65
+ break;
66
+ }
62
67
  case 'google': {
63
68
  fetcher = new GoogleModelFetcher();
64
69
  break;
@@ -429,36 +429,63 @@ export class ChatBasedModelFetcher {
429
429
  return this.knownModels;
430
430
  }
431
431
  async validateApiKey(apiKey) {
432
- try {
433
- await axios.post(`${this.baseUrl}/chat/completions`, {
434
- max_tokens: 1,
435
- messages: [{ content: 'hi', role: 'user' }],
436
- model: this.knownModels[0]?.id ?? 'default',
437
- }, {
438
- headers: {
439
- Authorization: `Bearer ${apiKey}`,
440
- 'Content-Type': 'application/json',
441
- },
442
- httpAgent: ProxyConfig.getProxyAgent(),
443
- httpsAgent: ProxyConfig.getProxyAgent(),
444
- proxy: false,
445
- timeout: 15_000,
446
- });
447
- return { isValid: true };
448
- }
449
- catch (error) {
450
- if (isAxiosError(error)) {
451
- if (error.response?.status === 401) {
452
- return { error: 'Invalid API key', isValid: false };
453
- }
454
- if (error.response?.status === 403) {
455
- return { error: 'API key does not have required permissions', isValid: false };
456
- }
457
- // Other errors (429, 400, etc.) mean the key was accepted
432
+ // Iterate through known models so a single missing model on a tier (e.g.
433
+ // GLM Coding Plan doesn't yet serve the latest glm-4.7) doesn't
434
+ // misclassify a valid key as invalid. We accept the key as soon as ANY
435
+ // model responds successfully, OR returns a non-auth error like 429/5xx
436
+ // (which still proves the key passed auth).
437
+ const candidates = this.knownModels.length > 0 ? this.knownModels : [{ id: 'default' }];
438
+ let lastNonAuthError;
439
+ for (const candidate of candidates) {
440
+ try {
441
+ // eslint-disable-next-line no-await-in-loop
442
+ await axios.post(`${this.baseUrl}/chat/completions`, {
443
+ max_tokens: 1,
444
+ messages: [{ content: 'hi', role: 'user' }],
445
+ model: candidate.id,
446
+ }, {
447
+ headers: {
448
+ Authorization: `Bearer ${apiKey}`,
449
+ 'Content-Type': 'application/json',
450
+ },
451
+ httpAgent: ProxyConfig.getProxyAgent(),
452
+ httpsAgent: ProxyConfig.getProxyAgent(),
453
+ proxy: false,
454
+ timeout: 15_000,
455
+ });
458
456
  return { isValid: true };
459
457
  }
460
- return { error: error instanceof Error ? error.message : 'Unknown error', isValid: false };
458
+ catch (error) {
459
+ if (isAxiosError(error)) {
460
+ if (error.response?.status === 401) {
461
+ return { error: 'Invalid API key', isValid: false };
462
+ }
463
+ if (error.response?.status === 403) {
464
+ return { error: 'API key does not have required permissions', isValid: false };
465
+ }
466
+ // 400/404 may mean "model not available on this tier" — try next.
467
+ if (error.response?.status === 400 || error.response?.status === 404) {
468
+ lastNonAuthError = error;
469
+ continue;
470
+ }
471
+ // Axios errors that are not 401/403/400/404 (e.g. 429, 5xx, or
472
+ // network-level errors with no response like ECONNREFUSED) are
473
+ // treated as "key accepted" — either auth was passed (429/5xx) or
474
+ // we can't determine otherwise (no response). Optimistic: prefer a
475
+ // false-positive valid over a false-negative invalid.
476
+ return { isValid: true };
477
+ }
478
+ lastNonAuthError = error;
479
+ }
461
480
  }
481
+ // Every candidate model returned 400/404 or a non-axios error and none
482
+ // gave us a positive auth signal. Treat the key as inconclusive — but
483
+ // since 401/403 was never observed, surface the last error so the user
484
+ // can see the real cause (often a model-availability issue, not auth).
485
+ return {
486
+ error: lastNonAuthError instanceof Error ? lastNonAuthError.message : 'Validation failed for all known models',
487
+ isValid: false,
488
+ };
462
489
  }
463
490
  }
464
491
  // ============================================================================
@@ -25,6 +25,12 @@ export declare class QueryLogHandler implements ITaskLifecycleHook {
25
25
  private readonly tasks;
26
26
  constructor(createStore?: ((projectPath: string) => IQueryLogStore) | undefined);
27
27
  cleanup(taskId: string): void;
28
+ /**
29
+ * Expose query metadata via the lifecycle-hook contract so TaskRouter can merge it into
30
+ * the task:completed payload sent to the originating client. Returning {} when no metadata
31
+ * is available keeps the merge a no-op and lets the daemon emit task:completed unchanged.
32
+ */
33
+ getTaskCompletionData(taskId: string): Record<string, unknown>;
28
34
  onTaskCancelled(taskId: string, _task: TaskInfo): Promise<void>;
29
35
  onTaskCompleted(taskId: string, result: string, _task: TaskInfo): Promise<void>;
30
36
  onTaskCreate(task: TaskInfo): Promise<void | {
@@ -39,6 +39,29 @@ export class QueryLogHandler {
39
39
  }
40
40
  }
41
41
  }
42
+ /**
43
+ * Expose query metadata via the lifecycle-hook contract so TaskRouter can merge it into
44
+ * the task:completed payload sent to the originating client. Returning {} when no metadata
45
+ * is available keeps the merge a no-op and lets the daemon emit task:completed unchanged.
46
+ */
47
+ getTaskCompletionData(taskId) {
48
+ const state = this.tasks.get(taskId);
49
+ if (!state?.queryResult)
50
+ return {};
51
+ // Flatten the QueryExecutorResult's nested shape onto the task:completed payload so
52
+ // it matches the public RecallResult contract (flat `durationMs` / `topScore`).
53
+ // `timing` is always populated by every QueryExecutor branch, so no guard.
54
+ // `searchMetadata` is omitted on cache hits (Tier 0/1), so guard before extracting.
55
+ const out = {
56
+ durationMs: state.queryResult.timing.durationMs,
57
+ matchedDocs: state.queryResult.matchedDocs,
58
+ tier: state.queryResult.tier,
59
+ };
60
+ if (state.queryResult.searchMetadata !== undefined) {
61
+ out.topScore = state.queryResult.searchMetadata.topScore;
62
+ }
63
+ return out;
64
+ }
42
65
  async onTaskCancelled(taskId, _task) {
43
66
  const state = this.tasks.get(taskId);
44
67
  if (!state)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Build a `TaskHistoryEntry` from a live `TaskInfo`. Single source of truth
3
+ * for `task-router.ts:handleTaskGet` (in-memory synthesis) and
4
+ * `task-history-hook.ts:persist` (lifecycle persistence) — extracting it here
5
+ * eliminates the duplicate `baseFromTaskInfo` + `statusShapeFromTaskInfo`
6
+ * pair that previously lived in both modules and would silently drift.
7
+ *
8
+ * Two functions are exposed:
9
+ * - `buildTaskHistoryEntry(task)` — full Zod-parsed `TaskHistoryEntry`,
10
+ * used by `task-router.handleTaskGet` to return the same shape as
11
+ * `store.getById` for in-flight tasks. Returns `undefined` when the
12
+ * `TaskInfo` is incomplete (e.g. missing `projectPath`) or when the
13
+ * inferred shape fails Zod validation.
14
+ * - `buildTaskHistoryEntryCandidate({task, override})` — pre-Zod object
15
+ * used by the lifecycle hook, which sometimes injects branch-specific
16
+ * fields (terminal completedAt / error / result) before validation.
17
+ */
18
+ import type { TaskInfo } from '../../core/domain/transport/task-info.js';
19
+ import { type TaskHistoryEntry } from '../../core/domain/entities/task-history-entry.js';
20
+ /**
21
+ * Build the pre-validation candidate object. The lifecycle hook calls this
22
+ * with an `override` to inject terminal-status fields before Zod-parsing.
23
+ */
24
+ export declare function buildTaskHistoryEntryCandidate(args: {
25
+ override?: Record<string, unknown>;
26
+ task: TaskInfo;
27
+ }): Record<string, unknown>;
28
+ /**
29
+ * Build a fully-validated `TaskHistoryEntry` from in-memory `TaskInfo`.
30
+ * Returns `undefined` when `task.projectPath` is missing or when the
31
+ * candidate fails Zod validation.
32
+ *
33
+ * Used by `task-router.handleTaskGet` to return live in-flight tasks in the
34
+ * same shape `store.getById` would have returned for persisted ones.
35
+ */
36
+ export declare function buildTaskHistoryEntry(task: TaskInfo): TaskHistoryEntry | undefined;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Build a `TaskHistoryEntry` from a live `TaskInfo`. Single source of truth
3
+ * for `task-router.ts:handleTaskGet` (in-memory synthesis) and
4
+ * `task-history-hook.ts:persist` (lifecycle persistence) — extracting it here
5
+ * eliminates the duplicate `baseFromTaskInfo` + `statusShapeFromTaskInfo`
6
+ * pair that previously lived in both modules and would silently drift.
7
+ *
8
+ * Two functions are exposed:
9
+ * - `buildTaskHistoryEntry(task)` — full Zod-parsed `TaskHistoryEntry`,
10
+ * used by `task-router.handleTaskGet` to return the same shape as
11
+ * `store.getById` for in-flight tasks. Returns `undefined` when the
12
+ * `TaskInfo` is incomplete (e.g. missing `projectPath`) or when the
13
+ * inferred shape fails Zod validation.
14
+ * - `buildTaskHistoryEntryCandidate({task, override})` — pre-Zod object
15
+ * used by the lifecycle hook, which sometimes injects branch-specific
16
+ * fields (terminal completedAt / error / result) before validation.
17
+ */
18
+ import { TASK_HISTORY_ID_PREFIX } from '../../constants.js';
19
+ import { TASK_HISTORY_SCHEMA_VERSION, TaskHistoryEntrySchema, } from '../../core/domain/entities/task-history-entry.js';
20
+ /** Build the base shape (fields shared by every status branch). */
21
+ function baseFromTaskInfo(task) {
22
+ return {
23
+ content: task.content,
24
+ createdAt: task.createdAt,
25
+ id: `${TASK_HISTORY_ID_PREFIX}-${task.taskId}`,
26
+ projectPath: task.projectPath,
27
+ schemaVersion: TASK_HISTORY_SCHEMA_VERSION,
28
+ taskId: task.taskId,
29
+ type: task.type,
30
+ ...(task.clientCwd === undefined ? {} : { clientCwd: task.clientCwd }),
31
+ ...(task.files === undefined ? {} : { files: task.files }),
32
+ ...(task.folderPath === undefined ? {} : { folderPath: task.folderPath }),
33
+ ...(task.logId === undefined ? {} : { logId: task.logId }),
34
+ ...(task.model === undefined ? {} : { model: task.model }),
35
+ ...(task.provider === undefined ? {} : { provider: task.provider }),
36
+ ...(task.reasoningContents === undefined ? {} : { reasoningContents: task.reasoningContents }),
37
+ ...(task.responseContent === undefined ? {} : { responseContent: task.responseContent }),
38
+ ...(task.sessionId === undefined ? {} : { sessionId: task.sessionId }),
39
+ ...(task.toolCalls === undefined ? {} : { toolCalls: task.toolCalls }),
40
+ ...(task.worktreeRoot === undefined ? {} : { worktreeRoot: task.worktreeRoot }),
41
+ };
42
+ }
43
+ /**
44
+ * Build the per-branch shape inferred from `task.status`. Override-only
45
+ * paths (terminal hooks) supply their own status; this is the default for
46
+ * in-flight transitions.
47
+ */
48
+ function statusShapeFromTaskInfo(task) {
49
+ switch (task.status) {
50
+ case 'cancelled':
51
+ case 'completed': {
52
+ return {
53
+ completedAt: task.completedAt ?? Date.now(),
54
+ status: task.status,
55
+ ...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
56
+ ...(task.status === 'completed' && task.result !== undefined ? { result: task.result } : {}),
57
+ };
58
+ }
59
+ case 'error': {
60
+ return {
61
+ completedAt: task.completedAt ?? Date.now(),
62
+ error: task.error ?? { code: 'TASK_ERROR', message: 'unknown error', name: 'TaskError' },
63
+ status: 'error',
64
+ ...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
65
+ };
66
+ }
67
+ case 'started': {
68
+ return { startedAt: task.startedAt ?? task.createdAt, status: 'started' };
69
+ }
70
+ // 'created' or undefined — minimal base, no extra branch fields.
71
+ default: {
72
+ return { status: 'created' };
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * Build the pre-validation candidate object. The lifecycle hook calls this
78
+ * with an `override` to inject terminal-status fields before Zod-parsing.
79
+ */
80
+ export function buildTaskHistoryEntryCandidate(args) {
81
+ const { override, task } = args;
82
+ if (override !== undefined) {
83
+ return { ...baseFromTaskInfo(task), ...statusShapeFromTaskInfo(task), ...override };
84
+ }
85
+ return { ...baseFromTaskInfo(task), ...statusShapeFromTaskInfo(task) };
86
+ }
87
+ /**
88
+ * Build a fully-validated `TaskHistoryEntry` from in-memory `TaskInfo`.
89
+ * Returns `undefined` when `task.projectPath` is missing or when the
90
+ * candidate fails Zod validation.
91
+ *
92
+ * Used by `task-router.handleTaskGet` to return live in-flight tasks in the
93
+ * same shape `store.getById` would have returned for persisted ones.
94
+ */
95
+ export function buildTaskHistoryEntry(task) {
96
+ if (task.projectPath === undefined)
97
+ return undefined;
98
+ const candidate = buildTaskHistoryEntryCandidate({ task });
99
+ const parsed = TaskHistoryEntrySchema.safeParse(candidate);
100
+ return parsed.success ? parsed.data : undefined;
101
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * TaskHistoryHook — persists `TaskInfo` to `ITaskHistoryStore` at every
3
+ * lifecycle transition (created / started-via-throttle / terminal).
4
+ *
5
+ * Wired into TaskRouter via `lifecycleHooks[]`. The 4 existing methods fire
6
+ * synchronously at create + terminal; the new `onTaskUpdate` fires on the
7
+ * throttled flush (~100ms) for in-flight mutations populated by the
8
+ * llmservice accumulator.
9
+ *
10
+ * Holds NO per-task state — every method reads from the live `TaskInfo`
11
+ * passed in. Errors are swallowed via `processLog`; tasks without
12
+ * `projectPath` are skipped silently.
13
+ */
14
+ import type { TaskInfo } from '../../core/domain/transport/task-info.js';
15
+ import type { ITaskLifecycleHook } from '../../core/interfaces/process/i-task-lifecycle-hook.js';
16
+ import type { ITaskHistoryStore } from '../../core/interfaces/storage/i-task-history-store.js';
17
+ type TaskHistoryHookOptions = {
18
+ /** Per-project store factory (DIP — never depends on FileTaskHistoryStore directly). */
19
+ getStore: (projectPath: string) => ITaskHistoryStore;
20
+ };
21
+ export declare class TaskHistoryHook implements ITaskLifecycleHook {
22
+ private readonly getStore;
23
+ constructor(opts: TaskHistoryHookOptions);
24
+ onTaskCancelled(_taskId: string, task: TaskInfo): Promise<void>;
25
+ onTaskCompleted(_taskId: string, result: string, task: TaskInfo): Promise<void>;
26
+ onTaskCreate(task: TaskInfo): Promise<void>;
27
+ onTaskError(_taskId: string, errorMessage: string, task: TaskInfo): Promise<void>;
28
+ onTaskUpdate(task: TaskInfo): Promise<void>;
29
+ /**
30
+ * Build + save a `TaskHistoryEntry` from the current `TaskInfo`. Optional
31
+ * `override` injects branch-specific fields (status / completedAt / error /
32
+ * result). When omitted, the branch shape is inferred from `task.status`
33
+ * by `buildTaskHistoryEntryCandidate`.
34
+ */
35
+ private persist;
36
+ }
37
+ export {};
@@ -0,0 +1,70 @@
1
+ /**
2
+ * TaskHistoryHook — persists `TaskInfo` to `ITaskHistoryStore` at every
3
+ * lifecycle transition (created / started-via-throttle / terminal).
4
+ *
5
+ * Wired into TaskRouter via `lifecycleHooks[]`. The 4 existing methods fire
6
+ * synchronously at create + terminal; the new `onTaskUpdate` fires on the
7
+ * throttled flush (~100ms) for in-flight mutations populated by the
8
+ * llmservice accumulator.
9
+ *
10
+ * Holds NO per-task state — every method reads from the live `TaskInfo`
11
+ * passed in. Errors are swallowed via `processLog`; tasks without
12
+ * `projectPath` are skipped silently.
13
+ */
14
+ import { TaskHistoryEntrySchema } from '../../core/domain/entities/task-history-entry.js';
15
+ import { processLog } from '../../utils/process-logger.js';
16
+ import { buildTaskHistoryEntryCandidate } from './task-history-entry-builder.js';
17
+ export class TaskHistoryHook {
18
+ getStore;
19
+ constructor(opts) {
20
+ this.getStore = opts.getStore;
21
+ }
22
+ async onTaskCancelled(_taskId, task) {
23
+ await this.persist(task, { completedAt: Date.now(), status: 'cancelled' });
24
+ }
25
+ async onTaskCompleted(_taskId, result, task) {
26
+ await this.persist(task, {
27
+ completedAt: Date.now(),
28
+ ...(result ? { result } : {}),
29
+ status: 'completed',
30
+ });
31
+ }
32
+ async onTaskCreate(task) {
33
+ await this.persist(task, { status: 'created' });
34
+ }
35
+ async onTaskError(_taskId, errorMessage, task) {
36
+ await this.persist(task, {
37
+ completedAt: Date.now(),
38
+ error: { code: 'TASK_ERROR', message: errorMessage, name: 'TaskError' },
39
+ status: 'error',
40
+ });
41
+ }
42
+ async onTaskUpdate(task) {
43
+ await this.persist(task);
44
+ }
45
+ /**
46
+ * Build + save a `TaskHistoryEntry` from the current `TaskInfo`. Optional
47
+ * `override` injects branch-specific fields (status / completedAt / error /
48
+ * result). When omitted, the branch shape is inferred from `task.status`
49
+ * by `buildTaskHistoryEntryCandidate`.
50
+ */
51
+ async persist(task, override) {
52
+ if (!task.projectPath)
53
+ return;
54
+ const candidate = buildTaskHistoryEntryCandidate({ override, task });
55
+ let entry;
56
+ try {
57
+ entry = TaskHistoryEntrySchema.parse(candidate);
58
+ }
59
+ catch (error) {
60
+ processLog(`TaskHistoryHook: failed to build entry for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
61
+ return;
62
+ }
63
+ try {
64
+ await this.getStore(task.projectPath).save(entry);
65
+ }
66
+ catch (error) {
67
+ processLog(`TaskHistoryHook: store.save failed for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`);
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-project FileTaskHistoryStore cache + lazy startup audit.
3
+ *
4
+ * Module-scoped so M2.09's wire handlers can reuse the same store instances
5
+ * the M2.06 lifecycle hook writes to. Audit fires once per project on first
6
+ * access, comparing `_index.jsonl` ↔ `data/` files to flag orphans without
7
+ * auto-fixing (M2.03 compaction owns the cleanup pass).
8
+ */
9
+ import { FileTaskHistoryStore } from '../storage/file-task-history-store.js';
10
+ /**
11
+ * Resolve (or lazily create) the per-project store. The first call for a
12
+ * given `projectPath` schedules a best-effort audit; subsequent calls reuse
13
+ * the cached store and skip re-auditing.
14
+ */
15
+ export declare function getStore(projectPath: string): FileTaskHistoryStore;
16
+ /**
17
+ * Compare `_index.jsonl` (live entries) against the `data/` directory and log
18
+ * orphans. Best-effort: never throws to caller. The `log` parameter defaults
19
+ * to `processLog` for production; tests inject a stub.
20
+ */
21
+ export declare function auditTaskHistory(projectPath: string, store: FileTaskHistoryStore, log?: ((msg: string) => void) | undefined): Promise<void>;
22
+ /** Test-only: clear module-scope state so each test sees a fresh cache. */
23
+ export declare function resetTaskHistoryStoreCache(): void;
24
+ /** Test-only: inject a logger into the audit path triggered by `getStore`. Pass no arg to clear. */
25
+ export declare function _setTestLoggerForGetStore(log?: (msg: string) => void): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-project FileTaskHistoryStore cache + lazy startup audit.
3
+ *
4
+ * Module-scoped so M2.09's wire handlers can reuse the same store instances
5
+ * the M2.06 lifecycle hook writes to. Audit fires once per project on first
6
+ * access, comparing `_index.jsonl` ↔ `data/` files to flag orphans without
7
+ * auto-fixing (M2.03 compaction owns the cleanup pass).
8
+ */
9
+ import { readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { TASK_HISTORY_DIR } from '../../constants.js';
12
+ import { getProjectDataDir } from '../../utils/path-utils.js';
13
+ import { processLog } from '../../utils/process-logger.js';
14
+ import { FileTaskHistoryStore } from '../storage/file-task-history-store.js';
15
+ const FILENAME_PATTERN = /^tsk-(.+)\.json$/;
16
+ const MAX_LISTED_ORPHANS = 5;
17
+ const stores = new Map();
18
+ const auditedProjects = new Set();
19
+ /**
20
+ * Daemon boot wall-clock timestamp. Captured at module load so EVERY per-project
21
+ * store shares the same boot reference. The C0 daemon-startup gate inside
22
+ * `FileTaskHistoryStore.isStaleAndRecoverable` uses this to skip stale-recovery
23
+ * for entries written post-boot — those belong to live in-memory tasks whose
24
+ * lifecycle hooks are still firing throttled saves and must not be tombstoned
25
+ * to `INTERRUPTED`.
26
+ *
27
+ * `resetTaskHistoryStoreCache()` re-captures it so tests see fresh boot
28
+ * semantics per `beforeEach`.
29
+ */
30
+ let daemonStartedAt = Date.now();
31
+ /** Optional logger override for tests — when set, audit triggered inside getStore uses this. */
32
+ let testLoggerForGetStore;
33
+ /**
34
+ * Resolve (or lazily create) the per-project store. The first call for a
35
+ * given `projectPath` schedules a best-effort audit; subsequent calls reuse
36
+ * the cached store and skip re-auditing.
37
+ */
38
+ export function getStore(projectPath) {
39
+ let store = stores.get(projectPath);
40
+ if (!store) {
41
+ store = new FileTaskHistoryStore({ baseDir: getProjectDataDir(projectPath), daemonStartedAt });
42
+ stores.set(projectPath, store);
43
+ }
44
+ if (!auditedProjects.has(projectPath)) {
45
+ auditedProjects.add(projectPath);
46
+ auditTaskHistory(projectPath, store, testLoggerForGetStore).catch((error) => {
47
+ processLog(`[task-history] audit failed for ${projectPath}: ${error instanceof Error ? error.message : String(error)}`);
48
+ });
49
+ }
50
+ return store;
51
+ }
52
+ /**
53
+ * Compare `_index.jsonl` (live entries) against the `data/` directory and log
54
+ * orphans. Best-effort: never throws to caller. The `log` parameter defaults
55
+ * to `processLog` for production; tests inject a stub.
56
+ */
57
+ export async function auditTaskHistory(projectPath, store, log = undefined) {
58
+ const effectiveLog = log ?? processLog;
59
+ const liveEntries = await store.list();
60
+ const liveTaskIds = new Set(liveEntries.map((e) => e.taskId));
61
+ const dataDir = join(getProjectDataDir(projectPath), TASK_HISTORY_DIR, 'data');
62
+ let dataFiles;
63
+ try {
64
+ dataFiles = await readdir(dataDir);
65
+ }
66
+ catch {
67
+ dataFiles = [];
68
+ }
69
+ const dataTaskIds = new Set();
70
+ for (const filename of dataFiles) {
71
+ const match = FILENAME_PATTERN.exec(filename);
72
+ if (match)
73
+ dataTaskIds.add(match[1]);
74
+ }
75
+ const orphanIndex = [...liveTaskIds].filter((id) => !dataTaskIds.has(id));
76
+ const orphanData = [...dataTaskIds].filter((id) => !liveTaskIds.has(id));
77
+ const head = `[task-history] audit ${projectPath} — ${liveTaskIds.size} live entries, ${dataTaskIds.size} data files.`;
78
+ if (orphanIndex.length === 0 && orphanData.length === 0) {
79
+ effectiveLog(`${head} ok.`);
80
+ return;
81
+ }
82
+ const parts = [];
83
+ if (orphanIndex.length > 0)
84
+ parts.push(formatOrphans('orphan-index', orphanIndex));
85
+ if (orphanData.length > 0)
86
+ parts.push(formatOrphans('orphan-data', orphanData));
87
+ effectiveLog(`${head} WARN: ${parts.join('; ')}.`);
88
+ }
89
+ /** Test-only: clear module-scope state so each test sees a fresh cache. */
90
+ export function resetTaskHistoryStoreCache() {
91
+ stores.clear();
92
+ auditedProjects.clear();
93
+ testLoggerForGetStore = undefined;
94
+ // Re-capture boot time so tests see fresh "this daemon just started" semantics
95
+ // for the C0 stale-recovery gate.
96
+ daemonStartedAt = Date.now();
97
+ }
98
+ /** Test-only: inject a logger into the audit path triggered by `getStore`. Pass no arg to clear. */
99
+ export function _setTestLoggerForGetStore(log) {
100
+ testLoggerForGetStore = log;
101
+ }
102
+ function formatOrphans(label, ids) {
103
+ const listed = ids.slice(0, MAX_LISTED_ORPHANS).map((id) => `tsk-${id}`).join(', ');
104
+ const remainder = ids.length > MAX_LISTED_ORPHANS ? ` (+${ids.length - MAX_LISTED_ORPHANS} more)` : '';
105
+ return `${ids.length} ${label} ${listed}${remainder}`;
106
+ }