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.
- package/README.md +4 -2
- package/dist/agent/core/domain/llm/registry.d.ts +12 -0
- package/dist/agent/core/domain/llm/registry.js +49 -0
- package/dist/agent/core/domain/llm/types.d.ts +6 -0
- package/dist/agent/core/interfaces/i-content-generator.d.ts +8 -0
- package/dist/agent/infra/llm/agent-llm-service.js +18 -6
- package/dist/agent/infra/llm/context/context-manager.d.ts +4 -1
- package/dist/agent/infra/llm/context/context-manager.js +5 -1
- package/dist/agent/infra/llm/generators/ai-sdk-content-generator.d.ts +13 -0
- package/dist/agent/infra/llm/generators/ai-sdk-content-generator.js +19 -6
- package/dist/agent/infra/llm/generators/ai-sdk-message-converter.js +16 -4
- package/dist/agent/infra/llm/generators/byterover-content-generator.d.ts +1 -0
- package/dist/agent/infra/llm/generators/byterover-content-generator.js +4 -1
- package/dist/agent/infra/llm/model-capabilities.d.ts +2 -1
- package/dist/agent/infra/llm/model-capabilities.js +6 -4
- package/dist/agent/infra/llm/providers/anthropic.js +2 -0
- package/dist/agent/infra/llm/providers/deepseek.d.ts +10 -0
- package/dist/agent/infra/llm/providers/deepseek.js +33 -0
- package/dist/agent/infra/llm/providers/glm-coding-plan.d.ts +9 -0
- package/dist/agent/infra/llm/providers/glm-coding-plan.js +32 -0
- package/dist/agent/infra/llm/providers/index.js +4 -0
- package/dist/agent/infra/llm/providers/openrouter.js +2 -0
- package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
- package/dist/oclif/commands/query.js +7 -1
- package/dist/oclif/lib/task-client.d.ts +9 -0
- package/dist/oclif/lib/task-client.js +11 -1
- package/dist/server/constants.d.ts +6 -0
- package/dist/server/constants.js +11 -0
- package/dist/server/core/domain/entities/provider-registry.js +26 -0
- package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
- package/dist/server/core/domain/entities/task-history-entry.js +88 -0
- package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
- package/dist/server/core/domain/transport/schemas.js +157 -6
- package/dist/server/core/domain/transport/task-info.d.ts +18 -0
- package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
- package/dist/server/infra/daemon/brv-server.js +43 -18
- package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
- package/dist/server/infra/dream/dream-response-schemas.js +7 -0
- package/dist/server/infra/dream/operations/consolidate.js +21 -8
- package/dist/server/infra/dream/operations/synthesize.js +35 -8
- package/dist/server/infra/http/provider-model-fetcher-registry.js +5 -0
- package/dist/server/infra/http/provider-model-fetchers.js +54 -27
- package/dist/server/infra/process/query-log-handler.d.ts +6 -0
- package/dist/server/infra/process/query-log-handler.js +23 -0
- package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
- package/dist/server/infra/process/task-history-entry-builder.js +101 -0
- package/dist/server/infra/process/task-history-hook.d.ts +37 -0
- package/dist/server/infra/process/task-history-hook.js +70 -0
- package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
- package/dist/server/infra/process/task-history-store-cache.js +106 -0
- package/dist/server/infra/process/task-router.d.ts +72 -0
- package/dist/server/infra/process/task-router.js +690 -15
- package/dist/server/infra/process/transport-handlers.d.ts +8 -0
- package/dist/server/infra/process/transport-handlers.js +2 -0
- package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
- package/dist/server/infra/storage/file-task-history-store.js +912 -0
- package/dist/shared/transport/events/index.d.ts +5 -0
- package/dist/shared/transport/events/task-events.d.ts +204 -1
- package/dist/shared/transport/events/task-events.js +11 -0
- package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
- package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
- package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
- package/dist/tui/types/messages.d.ts +2 -9
- package/dist/webui/assets/index-DyVvFoM6.css +1 -0
- package/dist/webui/assets/index-lr0byHh9.js +130 -0
- package/dist/webui/index.html +2 -2
- package/dist/webui/sw.js +1 -1
- package/dist/webui/workbox-9c191d2f.js +1 -0
- package/oclif.manifest.json +985 -985
- package/package.json +1 -1
- package/dist/webui/assets/index-CvcqpMYn.css +0 -1
- package/dist/webui/assets/index-thSZZahh.js +0 -130
- 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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
+
}
|