dsh-continual-evolve 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Prefix-cache-aware planning input routing (Route A/B).
3
+ *
4
+ * The planner and the review gate re-send session context on every call as
5
+ * flat text. On providers with prompt caching that context is re-billed at
6
+ * full price each time. Route A instead prepends session-derived messages —
7
+ * the same evidence in structured form, eligible for cache-read pricing —
8
+ * and drops the redundant flat trajectory block. Route B keeps the legacy
9
+ * flat-text input byte-for-byte.
10
+ *
11
+ * Adapted from dsh-continual-harness (Route A/B + cache-detect): this port
12
+ * deliberately does NOT reproduce the host loop's byte-identical cache key
13
+ * (no deriveMessages/tools/sessionId plumbing on this seam), so a hit needs
14
+ * a provider that keys on the message-prefix content. Sources are stamped
15
+ * honestly and every reduction is documented below.
16
+ */
17
+ import { createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
18
+ /** Default session-prefix budget for one Route A input, in characters. */
19
+ export const DEFAULT_PREFIX_MAX_CHARS = 12000;
20
+ /**
21
+ * Resolve per-call overrides against the defaults.
22
+ *
23
+ * @param opts - sparse per-call overrides.
24
+ * @returns the effective mode (default "auto") and char budget.
25
+ * @throws When maxChars is present but not a positive safe integer (fail loud on misconfiguration).
26
+ */
27
+ export function resolvePrefixCache(opts) {
28
+ const mode = opts?.mode ?? "auto";
29
+ const maxChars = opts?.maxChars ?? DEFAULT_PREFIX_MAX_CHARS;
30
+ if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
31
+ throw new Error(`evolve: prefix-cache maxChars must be a positive integer, got ${String(opts?.maxChars)}`);
32
+ }
33
+ return { mode, maxChars };
34
+ }
35
+ /**
36
+ * True when any assistant message in this session reported cache-read
37
+ * tokens — evidence the provider can serve a warm prefix.
38
+ *
39
+ * @param events - session log rows, newest last.
40
+ * @returns whether cache evidence exists (false on an empty/unreadable log).
41
+ */
42
+ export function hasCacheEvidence(events) {
43
+ for (const raw of events) {
44
+ if (typeof raw !== "object" || raw === null)
45
+ continue;
46
+ const event = raw;
47
+ if (event.type !== "assistant/message")
48
+ continue;
49
+ const read = event.data?.usage?.cacheReadTokens;
50
+ if (typeof read === "number" && read > 0)
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Pick the planning input route for one call.
57
+ *
58
+ * @param events - session log rows, newest last.
59
+ * @param mode - routing mode (default "auto").
60
+ * @returns "A" when the session prefix should carry the context, else "B".
61
+ */
62
+ export function detectPlannerRoute(events, mode) {
63
+ const resolved = mode ?? "auto";
64
+ if (resolved === "session")
65
+ return "A";
66
+ if (resolved === "off")
67
+ return "B";
68
+ return hasCacheEvidence(events) ? "A" : "B";
69
+ }
70
+ /**
71
+ * Extract one message's text without depending on the dsh-llm ContentBlock
72
+ * type: string blocks pass through, object blocks contribute their `text`
73
+ * field when present. Non-text blocks (tool calls, images) are dropped.
74
+ */
75
+ function blockText(content) {
76
+ if (typeof content === "string")
77
+ return content;
78
+ if (!Array.isArray(content))
79
+ return "";
80
+ const parts = [];
81
+ for (const block of content) {
82
+ if (typeof block === "string") {
83
+ parts.push(block);
84
+ }
85
+ else if (block !== null && typeof block === "object" && "text" in block && typeof block.text === "string") {
86
+ parts.push(block.text);
87
+ }
88
+ }
89
+ return parts.filter((text) => text.length > 0).join(" ");
90
+ }
91
+ /**
92
+ * Rebuild a tail-biased session prefix from the event log.
93
+ *
94
+ * Inclusion mirrors the trajectory grounding (only direct human `user`
95
+ * speech plus the model's own assistant text), so injected plugin context
96
+ * and tool results never echo back into the planning input. Whole messages
97
+ * are dropped oldest-first over budget; a single over-budget message keeps
98
+ * its tail.
99
+ *
100
+ * @param events - session log rows, newest last.
101
+ * @param opts - provider/model stamps the assistant messages (the calling
102
+ * agent's own route); maxChars caps the prefix (default {@link DEFAULT_PREFIX_MAX_CHARS}).
103
+ * @returns the prefix messages, oldest first ([] when nothing qualifies —
104
+ * callers fall back to Route B).
105
+ * @throws When assistant text exists but provider/model is missing (an
106
+ * assistant message cannot be honestly stamped without its route).
107
+ */
108
+ export function buildPrefixMessages(events, opts) {
109
+ const maxChars = opts.maxChars ?? DEFAULT_PREFIX_MAX_CHARS;
110
+ if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
111
+ throw new Error(`evolve: prefix-cache maxChars must be a positive integer, got ${String(opts.maxChars)}`);
112
+ }
113
+ const pairs = [];
114
+ for (let i = events.length - 1; i >= 0; i -= 1) {
115
+ const raw = events[i];
116
+ if (typeof raw !== "object" || raw === null)
117
+ continue;
118
+ const event = raw;
119
+ const isUser = event.type === "user/message";
120
+ const isAssistant = event.type === "assistant/message";
121
+ if (!isUser && !isAssistant)
122
+ continue;
123
+ if (isUser) {
124
+ const kind = event.data?.source?.kind;
125
+ if (kind !== undefined && kind !== "user")
126
+ continue;
127
+ }
128
+ const text = blockText(event.data?.content).trim();
129
+ if (text.length === 0)
130
+ continue;
131
+ pairs.push({ role: isUser ? "user" : "assistant", text });
132
+ }
133
+ if (pairs.length === 0)
134
+ return [];
135
+ // Collected newest-first; restore conversation order before budgeting.
136
+ pairs.reverse();
137
+ // Tail-biased budget: drop oldest whole messages over budget; a lone
138
+ // over-budget message keeps its tail.
139
+ let total = pairs.reduce((sum, pair) => sum + pair.text.length, 0);
140
+ while (pairs.length > 1 && total > maxChars) {
141
+ const dropped = pairs.shift();
142
+ total -= dropped?.text.length ?? 0;
143
+ }
144
+ const oldest = pairs[0];
145
+ if (oldest !== undefined && total > maxChars) {
146
+ oldest.text = oldest.text.slice(-maxChars);
147
+ }
148
+ const messages = [];
149
+ for (const pair of pairs) {
150
+ if (pair.role === "user") {
151
+ messages.push(createUserMessage({
152
+ source: { kind: "user" },
153
+ content: [{ type: "text", text: pair.text }],
154
+ }));
155
+ }
156
+ else {
157
+ if (!opts.provider || !opts.model) {
158
+ throw new Error("evolve: prefix-cache needs the agent provider/model to stamp assistant messages");
159
+ }
160
+ messages.push(createAssistantMessage({
161
+ source: { provider: opts.provider, model: opts.model },
162
+ content: [{ type: "text", text: pair.text }],
163
+ }));
164
+ }
165
+ }
166
+ return messages;
167
+ }
168
+ //# sourceMappingURL=prefix-cache.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stable project key for a workspace. `workspaceIdentity` (when set)
3
+ * replaces the path as the hash source so renamed checkouts of the same
4
+ * logical project keep one store.
5
+ */
6
+ export declare function resolveProjectKey(workspacePath: string, workspaceIdentity?: string): string;
7
+ /** Defense for externally supplied keys (explicit opts): single safe segment. */
8
+ export declare function sanitizeProjectKey(key: string): string;
9
+ /** Minimal agent shape the project-key derivation needs (duck-typed). */
10
+ export interface ProjectKeyAgentLike {
11
+ session?: {
12
+ header?: {
13
+ cwd?: unknown;
14
+ meta?: {
15
+ cwd?: unknown;
16
+ };
17
+ };
18
+ };
19
+ }
20
+ /**
21
+ * Derive the project key from an assembling agent's session cwd. Returns
22
+ * undefined when no validated absolute cwd is present — the caller then
23
+ * skips the project layer rather than failing the whole assembly.
24
+ */
25
+ export declare function projectKeyOf(agent: unknown, opts?: {
26
+ workspaceIdentity?: string;
27
+ }): string | undefined;
28
+ //# sourceMappingURL=project.d.ts.map
package/lib/project.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Project scope identity: the third harness layer between session-local
3
+ * staging and the cross-project global store.
4
+ *
5
+ * A project is identified by the session's validated absolute `cwd`
6
+ * (stable slug + sha256, mirroring ZCode's `project-root.ts`), so every
7
+ * session opened in the same workspace shares one project store while
8
+ * different checkouts never collide. All helpers are pure and never throw —
9
+ * a missing cwd simply means "no project layer" (the caller degrades to
10
+ * global+local, the same shape as an empty project store).
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { basename, isAbsolute, resolve } from "node:path";
14
+ /**
15
+ * Stable project key for a workspace. `workspaceIdentity` (when set)
16
+ * replaces the path as the hash source so renamed checkouts of the same
17
+ * logical project keep one store.
18
+ */
19
+ export function resolveProjectKey(workspacePath, workspaceIdentity) {
20
+ const identity = workspaceIdentity?.trim();
21
+ const normalized = resolve(workspacePath);
22
+ const keySource = identity && identity.length > 0 ? identity : process.platform === "win32" ? normalized.toLowerCase() : normalized;
23
+ const hash = createHash("sha256").update(keySource).digest("hex").slice(0, 16);
24
+ const slug = identity && identity.length > 0 ? "project" : sanitizeSlug(basename(normalized) || "project");
25
+ return `${slug}-${hash}`;
26
+ }
27
+ function sanitizeSlug(value) {
28
+ const slug = value
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9._-]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 48);
33
+ return slug.length > 0 ? slug : "project";
34
+ }
35
+ /** Defense for externally supplied keys (explicit opts): single safe segment. */
36
+ export function sanitizeProjectKey(key) {
37
+ const cleaned = key
38
+ .trim()
39
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
40
+ .replace(/^-+|-+$/g, "")
41
+ .slice(0, 64);
42
+ return cleaned.length > 0 ? cleaned : "project";
43
+ }
44
+ /**
45
+ * Derive the project key from an assembling agent's session cwd. Returns
46
+ * undefined when no validated absolute cwd is present — the caller then
47
+ * skips the project layer rather than failing the whole assembly.
48
+ */
49
+ export function projectKeyOf(agent, opts) {
50
+ try {
51
+ const header = agent?.session?.header;
52
+ const raw = header?.cwd ?? header?.meta?.cwd;
53
+ if (typeof raw !== "string" || raw.trim().length === 0) {
54
+ return undefined;
55
+ }
56
+ const cwd = raw.trim();
57
+ if (!isAbsolute(cwd)) {
58
+ return undefined;
59
+ }
60
+ return resolveProjectKey(cwd, opts?.workspaceIdentity);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ //# sourceMappingURL=project.js.map
package/lib/review.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  import type { Context } from "@deepseek-ai/cordis";
9
9
  import type { Agent } from "@deepseek-ai/dsh-agent";
10
10
  import type { HarnessState, RefinementResult } from "./types.js";
11
+ import { type PrefixCacheOptions } from "./prefix-cache.js";
12
+ import { type TokenUsageTarget } from "./token-usage.js";
11
13
  export interface AutoRefineReview {
12
14
  shouldRefine: boolean;
13
15
  rationale: string;
@@ -30,6 +32,15 @@ export interface ReviewOptions {
30
32
  /** Gap C1: optional provider/model override for the review gate (cheaper model). */
31
33
  overrideProvider?: string;
32
34
  overrideModel?: string;
35
+ /**
36
+ * Prefix-cache routing for the gate input. Route A prepends a
37
+ * session-derived message prefix and drops the flat `<conversation>`
38
+ * block (same source, structured form); Route B keeps it. Absent →
39
+ * auto-detect with the default budget.
40
+ */
41
+ prefixCache?: PrefixCacheOptions;
42
+ /** Direct-call token ledger destination; the review phase is fixed by this module. */
43
+ tokenUsage?: TokenUsageTarget;
33
44
  }
34
45
  export declare const AUTO_REVIEW_SYSTEM_PROMPT = "You are the automatic /evolve review gate.\n\nDecide whether this checkpoint should run /evolve. Auto /evolve writes local\nharness state by default, so approve when the trajectory contains evidence\nuseful to this session's future turns: a repeated failure, a reusable tactic,\na repeated delegation role, a durable fact or preference, a user correction\nthat should persist, or a narrow behavioral policy.\n\nThe current harness state below includes GLOBAL entries (scope=global) plus\nthis session's local entries (scope=local). When a topic is already covered\nby a global entry, do NOT approve a local duplicate of it \u2014 decline and say\nin the rationale that the topic is already covered globally.\n\nReject one-off noise, unsupported hypotheses, transient tool outputs, and\nrequests that carry no reusable content.\n\nStale local entries (superseded, long-unused, obsolete facts) are a valid\nrefine target: approve with instructions naming the entry ids, and tell the\nplanner to archive them (archive hides from injection, data stays restorable)\nrather than delete.\n\nSkill-related trajectories (the evidence concerns creating or improving a\nskill entry) are judged against the DSH skill quality standard (skill-audit\ndimensions: frontmatter routing, the 7 structural features, paragraph\nskeleton, no duplication of the official 11 skills or covered skills).\nApprove only when the trajectory shows a REAL trigger scenario and the\nresulting skill would meet the standard; otherwise decline and say in the\nrationale what must improve \u2014 drafting follows skill-creator, and the\nplanner receives the standard as its <skill_quality_standard> block.\n\nRepeated multi-step workflows (session start/end routines, recurring\nwrap-up or handoff procedures) are a valid refine target: approve with\ninstructions telling the planner to propose a guidance skill (kind=skill,\nskill_kind=guidance \u2014 a SKILL.md document, no python reference). Only\npropose when the same workflow recurs in the trajectory \u2014 never for\none-off flows. Auto-created skills are always offered to the user for a\ndecision before they land; the gate never writes a skill silently.\n\nReturn JSON only:\n{\n \"shouldRefine\": true|false,\n \"rationale\": \"short reason\",\n \"instructions\": \"optional concise instructions for /evolve if shouldRefine is true\"\n}";
35
46
  /** Parse the gate's JSON reply. */
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 = 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
- `<conversation>\n${options.trajectory}\n</conversation>`,
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
- ].join("\n\n");
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
  }
@@ -0,0 +1,24 @@
1
+ export interface GateRuntime {
2
+ version: 1;
3
+ /** True while the human paused the automatic review gate. */
4
+ paused: boolean;
5
+ /** ISO timestamp of the last pause/resume write. */
6
+ updatedAt: string;
7
+ }
8
+ /** Full path of the runtime switch file. */
9
+ export declare function runtimePath(baseDir: string): string;
10
+ /**
11
+ * Load the runtime switch; fail open to "running" on any unreadable or
12
+ * malformed content (called on every gate trigger, so it must never throw
13
+ * for a bad file).
14
+ */
15
+ export declare function loadGateRuntime(baseDir: string): GateRuntime;
16
+ /**
17
+ * Persist the pause switch atomically (temp file + rename). Never throws
18
+ * for a bad file — callers (pause/resume commands) surface real IO errors
19
+ * through the command's own error path instead.
20
+ */
21
+ export declare function saveGateRuntime(baseDir: string, paused: boolean): string;
22
+ /** True while the human paused the automatic review gate (fail-open). */
23
+ export declare function isGatePaused(baseDir: string): boolean;
24
+ //# sourceMappingURL=runtime.d.ts.map
package/lib/runtime.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Runtime gate switch (#21 P2): a tiny human-owned file that pauses the
3
+ * automatic review gate without touching the plugin config or restarting.
4
+ *
5
+ * - file: `<baseDir>/evolve/runtime.json` (`{version, paused, updatedAt}`)
6
+ * - `/evolve pause` sets paused, `/evolve resume` clears it, `/evolve
7
+ * status` shows it alongside the static patch config.
8
+ * - the gate consults it on every trigger (turn-interval AND compaction)
9
+ * and stays dormant while paused — manual tools/commands keep working.
10
+ * - fail-open: a missing or corrupt file means "running". Pausing is the
11
+ * exceptional state; a broken file must never silently wedge the gate
12
+ * shut (the next save rewrites it cleanly).
13
+ */
14
+ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { readFileSync } from "node:fs";
16
+ import { randomUUID } from "node:crypto";
17
+ import { join } from "node:path";
18
+ import { EVOLVE_DIR } from "./store.js";
19
+ const RUNTIME_FILE = "runtime.json";
20
+ /** Full path of the runtime switch file. */
21
+ export function runtimePath(baseDir) {
22
+ return join(baseDir, EVOLVE_DIR, RUNTIME_FILE);
23
+ }
24
+ function running() {
25
+ return { version: 1, paused: false, updatedAt: new Date(0).toISOString() };
26
+ }
27
+ /**
28
+ * Load the runtime switch; fail open to "running" on any unreadable or
29
+ * malformed content (called on every gate trigger, so it must never throw
30
+ * for a bad file).
31
+ */
32
+ export function loadGateRuntime(baseDir) {
33
+ const path = runtimePath(baseDir);
34
+ if (!existsSync(path)) {
35
+ return running();
36
+ }
37
+ try {
38
+ const raw = JSON.parse(readFileSync(path, "utf8"));
39
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
40
+ return running();
41
+ }
42
+ const record = raw;
43
+ return {
44
+ version: 1,
45
+ paused: record["paused"] === true,
46
+ updatedAt: typeof record["updatedAt"] === "string" ? record["updatedAt"] : new Date(0).toISOString(),
47
+ };
48
+ }
49
+ catch {
50
+ return running();
51
+ }
52
+ }
53
+ /**
54
+ * Persist the pause switch atomically (temp file + rename). Never throws
55
+ * for a bad file — callers (pause/resume commands) surface real IO errors
56
+ * through the command's own error path instead.
57
+ */
58
+ export function saveGateRuntime(baseDir, paused) {
59
+ const path = runtimePath(baseDir);
60
+ mkdirSync(join(baseDir, EVOLVE_DIR), { recursive: true });
61
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
62
+ const payload = { version: 1, paused, updatedAt: new Date().toISOString() };
63
+ writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
64
+ try {
65
+ renameSync(tempPath, path);
66
+ }
67
+ finally {
68
+ if (existsSync(tempPath)) {
69
+ unlinkSync(tempPath);
70
+ }
71
+ }
72
+ return path;
73
+ }
74
+ /** True while the human paused the automatic review gate (fail-open). */
75
+ export function isGatePaused(baseDir) {
76
+ try {
77
+ return loadGateRuntime(baseDir).paused;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ //# 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 declare function createEvolutionEngine(baseDir: string, hooks?: EvolutionHooks): {
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,15 @@ 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);
10
+ /**
11
+ * Load a scope's state. For `project` the `sessionId` parameter carries
12
+ * the project key (see `resolveProjectKey` in project.ts); callers
13
+ * derive it with `projectKeyOf(agent)` and pass it through unchanged.
14
+ */
9
15
  function load(scope, sessionId) {
10
16
  return loadHarnessState(storePaths(baseDir, scope, sessionId).stateDir, scope);
11
17
  }
@@ -95,6 +101,21 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
95
101
  }
96
102
  saveHarnessState(paths.stateDir, state);
97
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
+ }
98
119
  hooks.onApplied?.(result);
99
120
  return result;
100
121
  }
@@ -114,6 +135,6 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
114
135
  function history(scope, sessionId) {
115
136
  return loadResults(storePaths(baseDir, scope, sessionId));
116
137
  }
117
- return { load, apply, rollback, history, baseDir };
138
+ return { load, apply, rollback, history, baseDir, retention };
118
139
  }
119
140
  //# sourceMappingURL=service.js.map
package/lib/state.d.ts CHANGED
@@ -7,11 +7,15 @@ export declare function stateFilePath(stateDir: string): string;
7
7
  */
8
8
  export declare function loadHarnessState(stateDir: string, scope?: HarnessScope): HarnessState;
9
9
  /**
10
- * Merge global and local state into the view the model sees. Local entries
11
- * win over same-id global entries; a colliding local id is prefixed
12
- * `local:` so both remain addressable.
10
+ * Merge global, project, and local state into the view the model sees.
11
+ * Precedence is global < project < local: a colliding project id is
12
+ * prefixed `project:` so both remain addressable, and a colliding local id
13
+ * is prefixed `local:` (local always wins — it is the session's live
14
+ * staging copy). Called with two args the project layer is simply absent.
13
15
  */
14
- export declare function mergeHarnessStates(globalState: HarnessState, localState: HarnessState | undefined): HarnessState;
16
+ export declare function mergeHarnessStates(globalState: HarnessState, localState?: HarnessState | undefined, opts?: {
17
+ projectState?: HarnessState | undefined;
18
+ }): HarnessState;
15
19
  /**
16
20
  * Persist state atomically: write to a temp file, fsync-free but rename-based,
17
21
  * preserving the mode of an existing file (defaults to 0o600 for new files).
package/lib/state.js CHANGED
@@ -20,7 +20,7 @@ export function stateFilePath(stateDir) {
20
20
  return join(stateDir, "harness_state.json");
21
21
  }
22
22
  function normalizeScope(value, fallback) {
23
- return value === "global" || value === "local" ? value : fallback;
23
+ return value === "global" || value === "project" || value === "local" ? value : fallback;
24
24
  }
25
25
  function objectRecord(value) {
26
26
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -95,24 +95,31 @@ export function loadHarnessState(stateDir, scope = "global") {
95
95
  return state;
96
96
  }
97
97
  /**
98
- * Merge global and local state into the view the model sees. Local entries
99
- * win over same-id global entries; a colliding local id is prefixed
100
- * `local:` so both remain addressable.
98
+ * Merge global, project, and local state into the view the model sees.
99
+ * Precedence is global < project < local: a colliding project id is
100
+ * prefixed `project:` so both remain addressable, and a colliding local id
101
+ * is prefixed `local:` (local always wins — it is the session's live
102
+ * staging copy). Called with two args the project layer is simply absent.
101
103
  */
102
- export function mergeHarnessStates(globalState, localState) {
104
+ export function mergeHarnessStates(globalState, localState, opts) {
103
105
  const merged = emptyHarnessState();
104
- merged.schema = Math.max(globalState.schema, localState?.schema ?? 1);
106
+ merged.schema = Math.max(globalState.schema, opts?.projectState?.schema ?? 1, localState?.schema ?? 1);
105
107
  for (const kind of Object.keys(merged.entries)) {
106
108
  for (const [id, entry] of Object.entries(globalState.entries[kind])) {
107
109
  merged.entries[kind][id] = { ...entry, scope: "global" };
108
110
  }
111
+ for (const [id, entry] of Object.entries(opts?.projectState?.entries[kind] ?? {})) {
112
+ const scoped = { ...entry, scope: "project" };
113
+ const mergedId = merged.entries[kind][id] ? `project:${id}` : id;
114
+ merged.entries[kind][mergedId] = scoped;
115
+ }
109
116
  for (const [id, entry] of Object.entries(localState?.entries[kind] ?? {})) {
110
117
  const scoped = { ...entry, scope: "local" };
111
118
  const mergedId = merged.entries[kind][id] ? `local:${id}` : id;
112
119
  merged.entries[kind][mergedId] = scoped;
113
120
  }
114
121
  }
115
- merged.refinements = [...globalState.refinements, ...(localState?.refinements ?? [])];
122
+ merged.refinements = [...globalState.refinements, ...(opts?.projectState?.refinements ?? []), ...(localState?.refinements ?? [])];
116
123
  return merged;
117
124
  }
118
125
  /**
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;
@@ -8,11 +32,31 @@ export interface StorePaths {
8
32
  /** JSONL path for applied refinement results. */
9
33
  resultsPath: string;
10
34
  }
35
+ /**
36
+ * Resolve store paths for a scope. For `project` the `sessionId` parameter
37
+ * carries the project key (see {@link resolveProjectKey} in project.ts) —
38
+ * it is sanitized to a single path segment so an externally supplied key
39
+ * can never traverse out of `evolve/projects/`.
40
+ */
11
41
  export declare function storePaths(baseDir: string, scope: HarnessScope, sessionId?: string): StorePaths;
12
42
  /** Snapshot the current state file before a mutation, if one exists. */
13
43
  export declare function snapshotBefore(paths: StorePaths, refinementId: string): void;
14
44
  /** Append an applied result to the store's JSONL history. */
15
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;
16
60
  /** Read the applied results history; malformed lines are skipped, never fatal. */
17
61
  export declare function loadResults(paths: StorePaths): RefinementResult[];
18
62
  //# sourceMappingURL=store.d.ts.map