dsh-continual-evolve 0.7.0 → 0.7.2
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 +22 -8
- package/README.zh.md +17 -8
- package/lib/apply.js +17 -9
- package/lib/auto.d.ts +24 -5
- package/lib/auto.js +159 -71
- package/lib/command.d.ts +5 -0
- package/lib/command.js +141 -3
- package/lib/evolve-event.d.ts +4 -2
- package/lib/evolve-event.js +12 -2
- package/lib/fate.d.ts +1 -1
- package/lib/fate.js +11 -5
- package/lib/index.d.ts +60 -4
- package/lib/index.js +52 -22
- package/lib/llm-text.d.ts +23 -0
- package/lib/llm-text.js +50 -33
- package/lib/planner.d.ts +17 -0
- package/lib/planner.js +23 -5
- package/lib/prefix-cache.d.ts +78 -0
- package/lib/prefix-cache.js +168 -0
- package/lib/review-scheduler.d.ts +37 -0
- package/lib/review-scheduler.js +110 -0
- package/lib/review.d.ts +14 -1
- package/lib/review.js +23 -2
- package/lib/runtime.d.ts +24 -0
- package/lib/runtime.js +87 -0
- package/lib/service.d.ts +11 -1
- package/lib/service.js +19 -3
- package/lib/store.d.ts +38 -0
- package/lib/store.js +88 -1
- package/lib/token-usage.d.ts +68 -0
- package/lib/token-usage.js +211 -0
- package/lib/tool.d.ts +9 -0
- package/lib/tool.js +37 -6
- package/lib/turn-snapshot.d.ts +58 -0
- package/lib/turn-snapshot.js +188 -0
- package/lib/wrapup-command.js +10 -1
- package/lib/wrapup.d.ts +5 -0
- package/lib/wrapup.js +6 -0
- package/package.json +1 -1
package/lib/review.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { extractJsonObject } from "./plan.js";
|
|
2
2
|
import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
|
|
3
|
+
import { sessionEventsOf } from "./inject.js";
|
|
4
|
+
import { buildPrefixMessages, detectPlannerRoute, resolvePrefixCache } from "./prefix-cache.js";
|
|
3
5
|
import { streamText } from "./llm-text.js";
|
|
6
|
+
import { createTokenUsageObserver } from "./token-usage.js";
|
|
4
7
|
export const AUTO_REVIEW_SYSTEM_PROMPT = `You are the automatic /evolve review gate.
|
|
5
8
|
|
|
6
9
|
Decide whether this checkpoint should run /evolve. Auto /evolve writes local
|
|
@@ -96,13 +99,29 @@ export async function reviewAutoRefine(ctx, options) {
|
|
|
96
99
|
if (!options.trajectory || options.trajectory.length === 0) {
|
|
97
100
|
throw new Error("evolve: review gate has no trajectory to judge");
|
|
98
101
|
}
|
|
102
|
+
// Route A carries the judged conversation as a session-derived message
|
|
103
|
+
// prefix and drops the flat block; an empty prefix falls back to B.
|
|
104
|
+
const events = options.trajectoryEvents ?? sessionEventsOf(agent);
|
|
105
|
+
const routing = resolvePrefixCache(options.prefixCache);
|
|
106
|
+
const route = detectPlannerRoute(events, routing.mode);
|
|
107
|
+
let prefixMessages = [];
|
|
108
|
+
if (route === "A") {
|
|
109
|
+
prefixMessages = buildPrefixMessages(events, {
|
|
110
|
+
...(provider ? { provider } : {}),
|
|
111
|
+
...(model ? { model } : {}),
|
|
112
|
+
maxChars: routing.maxChars,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const conversationBlock = prefixMessages.length > 0 ? "" : `<conversation>\n${options.trajectory}\n</conversation>`;
|
|
99
116
|
const userPrompt = [
|
|
100
117
|
`<trigger>\n${options.context.reason}; ${options.context.turnsSinceLastReview} turns since the last review\n</trigger>`,
|
|
101
118
|
`<current_harness_state>\n${formatHarnessStateForPrompt(state)}\n</current_harness_state>`,
|
|
102
119
|
`<refinement_history>\n${historyForPrompt(history)}\n</refinement_history>`,
|
|
103
|
-
|
|
120
|
+
conversationBlock,
|
|
104
121
|
"Return shouldRefine=true when the trajectory contains evidence useful to this session's future turns. Prefer local edits; do not ask for global refinement here.",
|
|
105
|
-
]
|
|
122
|
+
]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join("\n\n");
|
|
106
125
|
const text = await streamText(ctx, {
|
|
107
126
|
provider,
|
|
108
127
|
model,
|
|
@@ -110,6 +129,8 @@ export async function reviewAutoRefine(ctx, options) {
|
|
|
110
129
|
prompt: userPrompt,
|
|
111
130
|
maxTokens: options.budgetTokens ?? 8000,
|
|
112
131
|
signal: options.signal,
|
|
132
|
+
...(prefixMessages.length > 0 ? { prefixMessages } : {}),
|
|
133
|
+
...(options.tokenUsage ? { onUsage: createTokenUsageObserver(options.tokenUsage, "review", provider, model) } : {}),
|
|
113
134
|
});
|
|
114
135
|
return parseAutoRefineReview(text);
|
|
115
136
|
}
|
package/lib/runtime.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** On-disk runtime gate state. Version 2 adds an explicit enable bit. */
|
|
2
|
+
export interface GateRuntime {
|
|
3
|
+
version: 2;
|
|
4
|
+
/** Whether the human has enabled automatic review. */
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/** Whether the human has explicitly paused automatic review. */
|
|
7
|
+
paused: boolean;
|
|
8
|
+
/** ISO timestamp of the last pause/resume write. */
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
}
|
|
11
|
+
/** Full path of the runtime switch file. */
|
|
12
|
+
export declare function runtimePath(baseDir: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Load the runtime switch. Missing, malformed, or legacy content uses the
|
|
15
|
+
* supplied configured default and never throws; this is a trigger-path read.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadGateRuntime(baseDir: string, defaultEnabled?: boolean): GateRuntime;
|
|
18
|
+
/** Persist the runtime switch atomically (temp file + rename). */
|
|
19
|
+
export declare function saveGateRuntime(baseDir: string, paused: boolean, enabled?: boolean): string;
|
|
20
|
+
/** True while the human paused the automatic review gate. */
|
|
21
|
+
export declare function isGatePaused(baseDir: string): boolean;
|
|
22
|
+
/** Effective automatic-review state, including the explicit enable bit. */
|
|
23
|
+
export declare function isGateEnabled(baseDir: string, defaultEnabled?: boolean): boolean;
|
|
24
|
+
//# sourceMappingURL=runtime.d.ts.map
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime gate switch: a small human-owned file that enables or pauses the
|
|
3
|
+
* automatic review listener without editing the profile or restarting DSH.
|
|
4
|
+
*
|
|
5
|
+
* - file: `<baseDir>/evolve/runtime.json` (`{version: 2, enabled, paused, updatedAt}`)
|
|
6
|
+
* - the file is consulted at every trigger and before scheduler execution;
|
|
7
|
+
* - `/evolve pause` sets `paused`, `/evolve resume` sets `enabled=true` and
|
|
8
|
+
* clears the pause, while manual tools and commands remain independent;
|
|
9
|
+
* - a missing or malformed file uses the caller's configured default. A
|
|
10
|
+
* legacy v1 `{paused}` file is readable and preserves its pause state.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { EVOLVE_DIR } from "./store.js";
|
|
16
|
+
const RUNTIME_FILE = "runtime.json";
|
|
17
|
+
/** Full path of the runtime switch file. */
|
|
18
|
+
export function runtimePath(baseDir) {
|
|
19
|
+
return join(baseDir, EVOLVE_DIR, RUNTIME_FILE);
|
|
20
|
+
}
|
|
21
|
+
function state(enabled, paused) {
|
|
22
|
+
return { version: 2, enabled, paused, updatedAt: new Date(0).toISOString() };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Load the runtime switch. Missing, malformed, or legacy content uses the
|
|
26
|
+
* supplied configured default and never throws; this is a trigger-path read.
|
|
27
|
+
*/
|
|
28
|
+
export function loadGateRuntime(baseDir, defaultEnabled = false) {
|
|
29
|
+
const path = runtimePath(baseDir);
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return state(defaultEnabled, false);
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
35
|
+
return state(defaultEnabled, false);
|
|
36
|
+
const record = raw;
|
|
37
|
+
const paused = record["paused"] === true;
|
|
38
|
+
// v1 had no enable bit. Its only meaningful state was pause, so retain
|
|
39
|
+
// the configured default when it is not paused.
|
|
40
|
+
const enabled = typeof record["enabled"] === "boolean" ? record["enabled"] : defaultEnabled;
|
|
41
|
+
return {
|
|
42
|
+
version: 2,
|
|
43
|
+
enabled,
|
|
44
|
+
paused,
|
|
45
|
+
updatedAt: typeof record["updatedAt"] === "string" ? record["updatedAt"] : new Date(0).toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return state(defaultEnabled, false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Persist the runtime switch atomically (temp file + rename). */
|
|
53
|
+
export function saveGateRuntime(baseDir, paused, enabled = true) {
|
|
54
|
+
const path = runtimePath(baseDir);
|
|
55
|
+
mkdirSync(join(baseDir, EVOLVE_DIR), { recursive: true });
|
|
56
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
57
|
+
const payload = { version: 2, enabled, paused, updatedAt: new Date().toISOString() };
|
|
58
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
59
|
+
try {
|
|
60
|
+
renameSync(tempPath, path);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (existsSync(tempPath))
|
|
64
|
+
unlinkSync(tempPath);
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
/** True while the human paused the automatic review gate. */
|
|
69
|
+
export function isGatePaused(baseDir) {
|
|
70
|
+
try {
|
|
71
|
+
return loadGateRuntime(baseDir).paused;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Effective automatic-review state, including the explicit enable bit. */
|
|
78
|
+
export function isGateEnabled(baseDir, defaultEnabled = false) {
|
|
79
|
+
try {
|
|
80
|
+
const current = loadGateRuntime(baseDir, defaultEnabled);
|
|
81
|
+
return current.enabled && !current.paused;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return defaultEnabled;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=runtime.js.map
|
package/lib/service.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { EntrySource, HarnessScope, RefinementProposal, RefinementResult } from "./types.js";
|
|
7
7
|
import { applyRefinementProposal } from "./apply.js";
|
|
8
|
+
import type { HistoryRetention } from "./store.js";
|
|
8
9
|
export interface ApplyContext {
|
|
9
10
|
scope: HarnessScope;
|
|
10
11
|
sessionId?: string;
|
|
@@ -19,12 +20,21 @@ export interface EvolutionHooks {
|
|
|
19
20
|
/** Called after every applied refinement (side-effect boundary: skills sync, etc.). */
|
|
20
21
|
onApplied?: (result: RefinementResult) => void;
|
|
21
22
|
}
|
|
22
|
-
export
|
|
23
|
+
export interface EvolutionEngineOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Storage-hygiene retention (#20): how many snapshots / JSONL tail
|
|
26
|
+
* lines each apply keeps. Resolved with generous defaults — rollback
|
|
27
|
+
* only needs recent snapshots and readers need a working window.
|
|
28
|
+
*/
|
|
29
|
+
historyRetain?: Partial<HistoryRetention>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createEvolutionEngine(baseDir: string, hooks?: EvolutionHooks, opts?: EvolutionEngineOptions): {
|
|
23
32
|
load: (scope: HarnessScope, sessionId: string | undefined) => import("./types.js").HarnessState;
|
|
24
33
|
apply: (scope: HarnessScope, sessionId: string | undefined, proposal: RefinementProposal, context?: ApplyContext) => RefinementResult;
|
|
25
34
|
rollback: (scope: HarnessScope, sessionId: string | undefined, refinementId: string) => RefinementResult;
|
|
26
35
|
history: (scope: HarnessScope, sessionId: string | undefined) => RefinementResult[];
|
|
27
36
|
baseDir: string;
|
|
37
|
+
retention: HistoryRetention;
|
|
28
38
|
};
|
|
29
39
|
export type EvolutionEngine = ReturnType<typeof createEvolutionEngine>;
|
|
30
40
|
//# sourceMappingURL=service.d.ts.map
|
package/lib/service.js
CHANGED
|
@@ -3,9 +3,10 @@ import { applyRefinementProposal } from "./apply.js";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { rollbackProposal } from "./rollback.js";
|
|
5
5
|
import { loadHarnessState, saveHarnessState } from "./state.js";
|
|
6
|
-
import { appendResult, loadResults, snapshotBefore, storePaths } from "./store.js";
|
|
6
|
+
import { appendResult, loadResults, pruneJsonlFile, pruneSnapshots, resolveHistoryRetention, snapshotBefore, storePaths } from "./store.js";
|
|
7
7
|
import { CONFLICT_BLOCK_SCORE, CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry, secretLeakReason } from "./promotion.js";
|
|
8
|
-
export function createEvolutionEngine(baseDir, hooks = {}) {
|
|
8
|
+
export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
|
|
9
|
+
const retention = resolveHistoryRetention(opts.historyRetain);
|
|
9
10
|
/**
|
|
10
11
|
* Load a scope's state. For `project` the `sessionId` parameter carries
|
|
11
12
|
* the project key (see `resolveProjectKey` in project.ts); callers
|
|
@@ -100,6 +101,21 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
|
|
|
100
101
|
}
|
|
101
102
|
saveHarnessState(paths.stateDir, state);
|
|
102
103
|
appendResult(paths, result);
|
|
104
|
+
// Storage hygiene (#20): bound the append-only past at write time —
|
|
105
|
+
// snapshots keep the newest N, the per-store history keeps its tail.
|
|
106
|
+
// Best-effort (never throws): a prune failure must not fail the apply.
|
|
107
|
+
try {
|
|
108
|
+
pruneSnapshots(paths.snapshotsDir, retention.snapshots);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// ignored — the next apply retries
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
pruneJsonlFile(paths.resultsPath, retention.refinements);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// ignored — the next apply retries
|
|
118
|
+
}
|
|
103
119
|
hooks.onApplied?.(result);
|
|
104
120
|
return result;
|
|
105
121
|
}
|
|
@@ -119,6 +135,6 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
|
|
|
119
135
|
function history(scope, sessionId) {
|
|
120
136
|
return loadResults(storePaths(baseDir, scope, sessionId));
|
|
121
137
|
}
|
|
122
|
-
return { load, apply, rollback, history, baseDir };
|
|
138
|
+
return { load, apply, rollback, history, baseDir, retention };
|
|
123
139
|
}
|
|
124
140
|
//# sourceMappingURL=service.js.map
|
package/lib/store.d.ts
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import type { HarnessScope, RefinementResult } from "./types.js";
|
|
2
2
|
export declare const EVOLVE_DIR = "evolve";
|
|
3
|
+
/**
|
|
4
|
+
* Storage-hygiene defaults (#20): history used to grow append-only without
|
|
5
|
+
* bound (dozens of full-state snapshot copies, unbounded JSONL). Retention
|
|
6
|
+
* is count-based and generous — rollback only ever needs recent snapshots
|
|
7
|
+
* and readers (`failures.ts` over reviews) need a working window, not the
|
|
8
|
+
* full past. Tunable via the `historyRetain` plugin config.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_SNAPSHOT_RETAIN = 20;
|
|
11
|
+
export declare const DEFAULT_REFINEMENTS_RETAIN = 500;
|
|
12
|
+
export declare const DEFAULT_REVIEWS_RETAIN = 500;
|
|
13
|
+
export declare const DEFAULT_TOKEN_USAGE_RETAIN = 500;
|
|
14
|
+
/** Resolved retention set: how many snapshots / JSONL tail lines to keep. */
|
|
15
|
+
export interface HistoryRetention {
|
|
16
|
+
/** Full-state snapshots kept per store (oldest pruned first). */
|
|
17
|
+
snapshots: number;
|
|
18
|
+
/** Tail lines kept in each store's refinements.jsonl. */
|
|
19
|
+
refinements: number;
|
|
20
|
+
/** Tail lines kept in the shared reviews.jsonl audit trail. */
|
|
21
|
+
reviews: number;
|
|
22
|
+
/** Tail lines kept in the direct-call token-usage.jsonl ledger. */
|
|
23
|
+
tokenUsage: number;
|
|
24
|
+
}
|
|
25
|
+
/** Defaults applied for absent / non-positive retention fields (fail loud never: clamp, don't throw). */
|
|
26
|
+
export declare function resolveHistoryRetention(raw?: Partial<HistoryRetention>): HistoryRetention;
|
|
3
27
|
export interface StorePaths {
|
|
4
28
|
/** Directory holding harness_state.json. */
|
|
5
29
|
stateDir: string;
|
|
@@ -19,6 +43,20 @@ export declare function storePaths(baseDir: string, scope: HarnessScope, session
|
|
|
19
43
|
export declare function snapshotBefore(paths: StorePaths, refinementId: string): void;
|
|
20
44
|
/** Append an applied result to the store's JSONL history. */
|
|
21
45
|
export declare function appendResult(paths: StorePaths, result: RefinementResult): void;
|
|
46
|
+
/**
|
|
47
|
+
* Prune a store's snapshots to the newest `retain` files (by mtime, oldest
|
|
48
|
+
* first). Best-effort: a missing directory or an unlink failure never breaks
|
|
49
|
+
* the apply path — the next apply retries.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pruneSnapshots(snapshotsDir: string, retain: number): void;
|
|
52
|
+
/**
|
|
53
|
+
* Truncate a JSONL file to its last `retain` non-empty lines. No-op when the
|
|
54
|
+
* file is missing or already within budget — the common case costs one
|
|
55
|
+
* read and no write.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pruneJsonlFile(path: string, retain: number): void;
|
|
58
|
+
/** Reviews audit-trail path shared by the gate, fate, and tool events. */
|
|
59
|
+
export declare function reviewsPath(baseDir: string): string;
|
|
22
60
|
/** Read the applied results history; malformed lines are skipped, never fatal. */
|
|
23
61
|
export declare function loadResults(paths: StorePaths): RefinementResult[];
|
|
24
62
|
//# sourceMappingURL=store.d.ts.map
|
package/lib/store.js
CHANGED
|
@@ -13,11 +13,32 @@
|
|
|
13
13
|
* pre-apply state is copied to `snapshots/<refinementId>.json`. The model has
|
|
14
14
|
* no way to skip it — it runs inside the service, not in a prompt.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { sanitizeProjectKey } from "./project.js";
|
|
19
19
|
import { stateFilePath } from "./state.js";
|
|
20
20
|
export const EVOLVE_DIR = "evolve";
|
|
21
|
+
/**
|
|
22
|
+
* Storage-hygiene defaults (#20): history used to grow append-only without
|
|
23
|
+
* bound (dozens of full-state snapshot copies, unbounded JSONL). Retention
|
|
24
|
+
* is count-based and generous — rollback only ever needs recent snapshots
|
|
25
|
+
* and readers (`failures.ts` over reviews) need a working window, not the
|
|
26
|
+
* full past. Tunable via the `historyRetain` plugin config.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_SNAPSHOT_RETAIN = 20;
|
|
29
|
+
export const DEFAULT_REFINEMENTS_RETAIN = 500;
|
|
30
|
+
export const DEFAULT_REVIEWS_RETAIN = 500;
|
|
31
|
+
export const DEFAULT_TOKEN_USAGE_RETAIN = 500;
|
|
32
|
+
/** Defaults applied for absent / non-positive retention fields (fail loud never: clamp, don't throw). */
|
|
33
|
+
export function resolveHistoryRetention(raw) {
|
|
34
|
+
const pick = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
35
|
+
return {
|
|
36
|
+
snapshots: pick(raw?.snapshots, DEFAULT_SNAPSHOT_RETAIN),
|
|
37
|
+
refinements: pick(raw?.refinements, DEFAULT_REFINEMENTS_RETAIN),
|
|
38
|
+
reviews: pick(raw?.reviews, DEFAULT_REVIEWS_RETAIN),
|
|
39
|
+
tokenUsage: pick(raw?.tokenUsage, DEFAULT_TOKEN_USAGE_RETAIN),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
21
42
|
/**
|
|
22
43
|
* Resolve store paths for a scope. For `project` the `sessionId` parameter
|
|
23
44
|
* carries the project key (see {@link resolveProjectKey} in project.ts) —
|
|
@@ -49,6 +70,72 @@ export function appendResult(paths, result) {
|
|
|
49
70
|
mkdirSync(paths.stateDir, { recursive: true });
|
|
50
71
|
writeFileSync(paths.resultsPath, `${JSON.stringify(result)}\n`, { encoding: "utf8", flag: "a" });
|
|
51
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Prune a store's snapshots to the newest `retain` files (by mtime, oldest
|
|
75
|
+
* first). Best-effort: a missing directory or an unlink failure never breaks
|
|
76
|
+
* the apply path — the next apply retries.
|
|
77
|
+
*/
|
|
78
|
+
export function pruneSnapshots(snapshotsDir, retain) {
|
|
79
|
+
const keep = Math.floor(retain);
|
|
80
|
+
if (!(keep > 0) || !existsSync(snapshotsDir))
|
|
81
|
+
return;
|
|
82
|
+
let files;
|
|
83
|
+
try {
|
|
84
|
+
files = readdirSync(snapshotsDir).filter((f) => f.endsWith(".json"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return; // unreadable directory — leave it for the next attempt
|
|
88
|
+
}
|
|
89
|
+
if (files.length <= keep)
|
|
90
|
+
return;
|
|
91
|
+
const withTime = [];
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
try {
|
|
94
|
+
withTime.push({ file, mtime: statSync(join(snapshotsDir, file)).mtimeMs });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// unstatable file — keep it rather than delete blindly
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
withTime.sort((a, b) => a.mtime - b.mtime);
|
|
101
|
+
for (const victim of withTime.slice(0, Math.max(0, withTime.length - keep))) {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(join(snapshotsDir, victim.file));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// one bad unlink must not stop the sweep or the apply path
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Truncate a JSONL file to its last `retain` non-empty lines. No-op when the
|
|
112
|
+
* file is missing or already within budget — the common case costs one
|
|
113
|
+
* read and no write.
|
|
114
|
+
*/
|
|
115
|
+
export function pruneJsonlFile(path, retain) {
|
|
116
|
+
const keep = Math.floor(retain);
|
|
117
|
+
if (!(keep > 0) || !existsSync(path))
|
|
118
|
+
return;
|
|
119
|
+
let lines;
|
|
120
|
+
try {
|
|
121
|
+
lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return; // unreadable file — leave it for the next attempt
|
|
125
|
+
}
|
|
126
|
+
if (lines.length <= keep)
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(path, `${lines.slice(-keep).join("\n")}\n`, "utf8");
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// truncation failure must not break the write path that called it
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Reviews audit-trail path shared by the gate, fate, and tool events. */
|
|
136
|
+
export function reviewsPath(baseDir) {
|
|
137
|
+
return join(baseDir, EVOLVE_DIR, "reviews.jsonl");
|
|
138
|
+
}
|
|
52
139
|
/** Read the applied results history; malformed lines are skipped, never fatal. */
|
|
53
140
|
export function loadResults(paths) {
|
|
54
141
|
if (!existsSync(paths.resultsPath)) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { TokenUsage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import type { StreamTextObservation, StreamTextUsageObserver } from "./llm-text.js";
|
|
3
|
+
export declare const TOKEN_USAGE_LEDGER_VERSION = 1;
|
|
4
|
+
/** Plugin-owned direct call paths represented in the ledger. */
|
|
5
|
+
export declare const TOKEN_USAGE_PHASES: readonly ["review", "planner", "wrapup", "fate"];
|
|
6
|
+
export type TokenUsagePhase = (typeof TOKEN_USAGE_PHASES)[number];
|
|
7
|
+
/** Whether the provider supplied a usage sample; missing is never treated as zero. */
|
|
8
|
+
export type TokenUsageStatus = "reported" | "missing";
|
|
9
|
+
/** How the exact full-call total was obtained. */
|
|
10
|
+
export type TokenTotalSource = "provider" | "derived" | "unavailable";
|
|
11
|
+
/** One retained direct-call observation. */
|
|
12
|
+
export interface TokenUsageRecord {
|
|
13
|
+
version: typeof TOKEN_USAGE_LEDGER_VERSION;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
phase: TokenUsagePhase;
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
outcome: StreamTextObservation["outcome"];
|
|
20
|
+
usageStatus: TokenUsageStatus;
|
|
21
|
+
/** Absent exactly when {@link usageStatus} is `missing`. */
|
|
22
|
+
usage?: TokenUsage;
|
|
23
|
+
totalSource: TokenTotalSource;
|
|
24
|
+
}
|
|
25
|
+
/** Caller-owned destination and failure hook; phase/provider/model are supplied by the call site. */
|
|
26
|
+
export interface TokenUsageTarget {
|
|
27
|
+
baseDir: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
retain: number;
|
|
30
|
+
onError?: (cause: unknown) => void;
|
|
31
|
+
}
|
|
32
|
+
/** Loaded retained window plus corrupt-line count for honest coverage reporting. */
|
|
33
|
+
export interface LoadedTokenUsage {
|
|
34
|
+
records: TokenUsageRecord[];
|
|
35
|
+
corruptLines: number;
|
|
36
|
+
}
|
|
37
|
+
/** Aggregate provider-reported buckets over a retained ledger window. */
|
|
38
|
+
export interface TokenUsageTotals {
|
|
39
|
+
calls: number;
|
|
40
|
+
reportedCalls: number;
|
|
41
|
+
missingUsageCalls: number;
|
|
42
|
+
uncachedInputTokens: number;
|
|
43
|
+
cacheReadTokens: number;
|
|
44
|
+
cacheWriteTokens: number;
|
|
45
|
+
outputTokens: number;
|
|
46
|
+
reasoningTokens: number;
|
|
47
|
+
totalTokens: number;
|
|
48
|
+
}
|
|
49
|
+
/** Absolute path of the direct-call token ledger. */
|
|
50
|
+
export declare function tokenUsagePath(baseDir: string): string;
|
|
51
|
+
/** Exact full-call total, with the old-peer fallback only when `totalTokens` is absent. */
|
|
52
|
+
export declare function exactTokenTotal(usage: TokenUsage): number;
|
|
53
|
+
/** Build the observer attached to one `streamText` call. Errors are diagnostic-only. */
|
|
54
|
+
export declare function createTokenUsageObserver(target: TokenUsageTarget, phase: TokenUsagePhase, provider: string, model: string): StreamTextUsageObserver;
|
|
55
|
+
/**
|
|
56
|
+
* Append one call and enforce the dedicated tail budget. Append + conditional
|
|
57
|
+
* prune stay in one synchronous block, so concurrent async callers in one DSH
|
|
58
|
+
* process cannot interleave the read/truncate/write. Multiple DSH processes
|
|
59
|
+
* sharing one baseDir are outside the plugin's storage contract.
|
|
60
|
+
*/
|
|
61
|
+
export declare function appendTokenUsage(target: TokenUsageTarget, record: TokenUsageRecord): void;
|
|
62
|
+
/** Load valid records from the retained ledger; malformed lines never break `/evolve usage`. */
|
|
63
|
+
export declare function loadTokenUsage(baseDir: string): LoadedTokenUsage;
|
|
64
|
+
/** Sum exact provider-reported buckets without double-counting reasoning or cache fields. */
|
|
65
|
+
export declare function aggregateTokenUsage(records: readonly TokenUsageRecord[]): TokenUsageTotals;
|
|
66
|
+
/** Render the human-facing direct-call ledger section. */
|
|
67
|
+
export declare function renderTokenUsageReport(ledger: LoadedTokenUsage, retain: number): string[];
|
|
68
|
+
//# sourceMappingURL=token-usage.d.ts.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-reported token accounting for plugin-owned direct LLM calls.
|
|
3
|
+
*
|
|
4
|
+
* The review gate, planner, wrap-up command, and local-fate classifier all use
|
|
5
|
+
* the shared `streamText` helper. These calls do not belong to an Agent turn
|
|
6
|
+
* (they pass no sessionId), so the host session token-meter cannot attribute
|
|
7
|
+
* them. This ledger records the exact `TokenUsage` emitted by the adapter at
|
|
8
|
+
* the one shared stream boundary instead.
|
|
9
|
+
*
|
|
10
|
+
* Storage: `<baseDir>/evolve/token-usage.jsonl`. The file contains route and
|
|
11
|
+
* count metadata only — never prompts, responses, or tool arguments — and is
|
|
12
|
+
* trimmed to its dedicated retention budget after each synchronous append.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { EVOLVE_DIR, pruneJsonlFile } from "./store.js";
|
|
17
|
+
const TOKEN_USAGE_FILE = "token-usage.jsonl";
|
|
18
|
+
export const TOKEN_USAGE_LEDGER_VERSION = 1;
|
|
19
|
+
/** Plugin-owned direct call paths represented in the ledger. */
|
|
20
|
+
export const TOKEN_USAGE_PHASES = ["review", "planner", "wrapup", "fate"];
|
|
21
|
+
/** Absolute path of the direct-call token ledger. */
|
|
22
|
+
export function tokenUsagePath(baseDir) {
|
|
23
|
+
return join(baseDir, EVOLVE_DIR, TOKEN_USAGE_FILE);
|
|
24
|
+
}
|
|
25
|
+
/** Exact full-call total, with the old-peer fallback only when `totalTokens` is absent. */
|
|
26
|
+
export function exactTokenTotal(usage) {
|
|
27
|
+
if (usage.totalTokens !== undefined)
|
|
28
|
+
return usage.totalTokens;
|
|
29
|
+
return usage.inputTokens + usage.outputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
30
|
+
}
|
|
31
|
+
/** Build the observer attached to one `streamText` call. Errors are diagnostic-only. */
|
|
32
|
+
export function createTokenUsageObserver(target, phase, provider, model) {
|
|
33
|
+
return (observation) => {
|
|
34
|
+
try {
|
|
35
|
+
appendTokenUsage(target, {
|
|
36
|
+
version: TOKEN_USAGE_LEDGER_VERSION,
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
sessionId: target.sessionId,
|
|
39
|
+
phase,
|
|
40
|
+
provider,
|
|
41
|
+
model,
|
|
42
|
+
outcome: observation.outcome,
|
|
43
|
+
usageStatus: observation.usage ? "reported" : "missing",
|
|
44
|
+
...(observation.usage ? { usage: observation.usage } : {}),
|
|
45
|
+
totalSource: observation.usage
|
|
46
|
+
? observation.usage.totalTokens !== undefined
|
|
47
|
+
? "provider"
|
|
48
|
+
: "derived"
|
|
49
|
+
: "unavailable",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (cause) {
|
|
53
|
+
target.onError?.(cause);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Append one call and enforce the dedicated tail budget. Append + conditional
|
|
59
|
+
* prune stay in one synchronous block, so concurrent async callers in one DSH
|
|
60
|
+
* process cannot interleave the read/truncate/write. Multiple DSH processes
|
|
61
|
+
* sharing one baseDir are outside the plugin's storage contract.
|
|
62
|
+
*/
|
|
63
|
+
export function appendTokenUsage(target, record) {
|
|
64
|
+
if (!isTokenUsageRecord(record)) {
|
|
65
|
+
throw new Error("evolve: refusing to append an invalid token-usage record");
|
|
66
|
+
}
|
|
67
|
+
const dir = join(target.baseDir, EVOLVE_DIR);
|
|
68
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
69
|
+
writeFileSync(tokenUsagePath(target.baseDir), `${JSON.stringify(record)}\n`, {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
flag: "a",
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
});
|
|
74
|
+
pruneJsonlFile(tokenUsagePath(target.baseDir), target.retain);
|
|
75
|
+
}
|
|
76
|
+
/** Load valid records from the retained ledger; malformed lines never break `/evolve usage`. */
|
|
77
|
+
export function loadTokenUsage(baseDir) {
|
|
78
|
+
const path = tokenUsagePath(baseDir);
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return { records: [], corruptLines: 0 };
|
|
81
|
+
let lines;
|
|
82
|
+
try {
|
|
83
|
+
lines = readFileSync(path, "utf8").split("\n");
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { records: [], corruptLines: 0 };
|
|
87
|
+
}
|
|
88
|
+
const records = [];
|
|
89
|
+
let corruptLines = 0;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
if (line.trim().length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(line);
|
|
95
|
+
if (isTokenUsageRecord(parsed))
|
|
96
|
+
records.push(parsed);
|
|
97
|
+
else
|
|
98
|
+
corruptLines += 1;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
corruptLines += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { records, corruptLines };
|
|
105
|
+
}
|
|
106
|
+
/** Sum exact provider-reported buckets without double-counting reasoning or cache fields. */
|
|
107
|
+
export function aggregateTokenUsage(records) {
|
|
108
|
+
const totals = {
|
|
109
|
+
calls: records.length,
|
|
110
|
+
reportedCalls: 0,
|
|
111
|
+
missingUsageCalls: 0,
|
|
112
|
+
uncachedInputTokens: 0,
|
|
113
|
+
cacheReadTokens: 0,
|
|
114
|
+
cacheWriteTokens: 0,
|
|
115
|
+
outputTokens: 0,
|
|
116
|
+
reasoningTokens: 0,
|
|
117
|
+
totalTokens: 0,
|
|
118
|
+
};
|
|
119
|
+
for (const record of records) {
|
|
120
|
+
if (record.usageStatus === "missing" || !record.usage) {
|
|
121
|
+
totals.missingUsageCalls += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
totals.reportedCalls += 1;
|
|
125
|
+
totals.uncachedInputTokens += record.usage.inputTokens;
|
|
126
|
+
totals.cacheReadTokens += record.usage.cacheReadTokens ?? 0;
|
|
127
|
+
totals.cacheWriteTokens += record.usage.cacheWriteTokens ?? 0;
|
|
128
|
+
totals.outputTokens += record.usage.outputTokens;
|
|
129
|
+
totals.reasoningTokens += record.usage.reasoningTokens ?? 0;
|
|
130
|
+
totals.totalTokens += exactTokenTotal(record.usage);
|
|
131
|
+
}
|
|
132
|
+
return totals;
|
|
133
|
+
}
|
|
134
|
+
/** Render the human-facing direct-call ledger section. */
|
|
135
|
+
export function renderTokenUsageReport(ledger, retain) {
|
|
136
|
+
const { records, corruptLines } = ledger;
|
|
137
|
+
if (records.length === 0) {
|
|
138
|
+
const corrupt = corruptLines > 0 ? ` ${corruptLines} corrupt line(s) ignored.` : "";
|
|
139
|
+
return [
|
|
140
|
+
`direct LLM token usage: no valid calls in the retained tail.${corrupt}`,
|
|
141
|
+
`window: last ${retain} call(s), not a lifetime total.`,
|
|
142
|
+
"scope: review/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.",
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
const totals = aggregateTokenUsage(records);
|
|
146
|
+
const lines = [
|
|
147
|
+
`direct LLM token usage: ${totals.calls} valid calls (${totals.reportedCalls} reported usage, ${totals.missingUsageCalls} missing)${corruptLines > 0 ? `; ${corruptLines} corrupt line(s) ignored` : ""}`,
|
|
148
|
+
` uncached input: ${totals.uncachedInputTokens}`,
|
|
149
|
+
` cache read: ${totals.cacheReadTokens}`,
|
|
150
|
+
` cache write: ${totals.cacheWriteTokens}`,
|
|
151
|
+
` output: ${totals.outputTokens}`,
|
|
152
|
+
` reasoning: ${totals.reasoningTokens}${records.some((record) => record.usageStatus === "reported" && record.usage?.reasoningTokens === undefined) ? " (partial provider reports)" : ""}`,
|
|
153
|
+
` exact total: ${totals.totalTokens} (reported calls only)`,
|
|
154
|
+
`window: last ${retain} call(s), not a lifetime total.`,
|
|
155
|
+
"by phase:",
|
|
156
|
+
];
|
|
157
|
+
for (const phase of TOKEN_USAGE_PHASES) {
|
|
158
|
+
const phaseRecords = records.filter((record) => record.phase === phase);
|
|
159
|
+
if (phaseRecords.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const phaseTotals = aggregateTokenUsage(phaseRecords);
|
|
162
|
+
lines.push(` ${phase.padEnd(7)} ${phaseTotals.calls} calls · ${phaseTotals.totalTokens} tokens`);
|
|
163
|
+
}
|
|
164
|
+
const last = records.at(-1);
|
|
165
|
+
if (last) {
|
|
166
|
+
const lastTotal = last.usage ? exactTokenTotal(last.usage) : "usage unavailable";
|
|
167
|
+
lines.push(`last: ${last.phase} ${last.provider}/${last.model} · ${last.outcome} · ${lastTotal} tokens · ${last.timestamp}`);
|
|
168
|
+
}
|
|
169
|
+
lines.push("scope: review/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.");
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
function isTokenUsageRecord(value) {
|
|
173
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
174
|
+
return false;
|
|
175
|
+
const record = value;
|
|
176
|
+
if (record["version"] !== TOKEN_USAGE_LEDGER_VERSION ||
|
|
177
|
+
typeof record["timestamp"] !== "string" ||
|
|
178
|
+
typeof record["sessionId"] !== "string" ||
|
|
179
|
+
typeof record["provider"] !== "string" ||
|
|
180
|
+
typeof record["model"] !== "string" ||
|
|
181
|
+
typeof record["outcome"] !== "string" ||
|
|
182
|
+
!TOKEN_USAGE_PHASES.includes(record["phase"]) ||
|
|
183
|
+
!["success", "max-tokens", "error", "aborted", "empty"].includes(record["outcome"]) ||
|
|
184
|
+
!["reported", "missing"].includes(record["usageStatus"]) ||
|
|
185
|
+
!["provider", "derived", "unavailable"].includes(record["totalSource"])) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (record["usageStatus"] === "missing") {
|
|
189
|
+
return record["usage"] === undefined && record["totalSource"] === "unavailable";
|
|
190
|
+
}
|
|
191
|
+
if (!isTokenUsage(record["usage"]))
|
|
192
|
+
return false;
|
|
193
|
+
const expectedSource = record["usage"]["totalTokens"] !== undefined ? "provider" : "derived";
|
|
194
|
+
return record["totalSource"] === expectedSource;
|
|
195
|
+
}
|
|
196
|
+
function isCount(value) {
|
|
197
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
198
|
+
}
|
|
199
|
+
function isTokenUsage(value) {
|
|
200
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
201
|
+
return false;
|
|
202
|
+
const usage = value;
|
|
203
|
+
if (!isCount(usage["inputTokens"]) || !isCount(usage["outputTokens"]))
|
|
204
|
+
return false;
|
|
205
|
+
for (const key of ["totalTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]) {
|
|
206
|
+
if (usage[key] !== undefined && !isCount(usage[key]))
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=token-usage.js.map
|