diff --git a/components/adapters/deepseek-harness/README.md b/components/adapters/deepseek-harness/README.md index f38920dd..e2fc608b 100644 --- a/components/adapters/deepseek-harness/README.md +++ b/components/adapters/deepseek-harness/README.md @@ -68,9 +68,43 @@ To cancel before that next request: ``` `/context-cleaner` remains an alias for older profiles. The native DSH command -is the supported interactive entry point. An external CLI TTY picker is not -advertised here because an external process has no safe access to DSH's live -session surface. +is the supported DSH entry point. + +## External CLI safety boundary (GX-01) + +The DSH process owns its live Cordis `SessionStore`; an external process must +never deserialize that store, DSH events, or a model-visible message body. +During a normal `agent/pre-step` and when native analysis runs, this adapter +atomically publishes a **metadata-only** snapshot under +`/cleaner-snapshot/`. The persisted record has schema +`lightrsi.deepseek-harness.cleaner-snapshot/v1`, carries the canonical surface +revision and item digests/counts, and contains no raw prompt or response text. + +An external integration must use the public +`createDshPersistedCleanerControlService({ stateDir })` factory. It accepts +only snapshots that have the matching schema and host ID and are fresh (five +minutes by default). A DSH Host/session that has not published a fresh snapshot, +a malformed file, inaccessible state directory, or expired snapshot is reported +as *unavailable*; the service does not invent a context. A process that exits +just after publishing remains usable only until that bounded freshness window +expires; the next live DSH `agent/pre-step` still validates the plan revision +and is the only place that can call the canonical surface transaction. The +external service can analyze a published snapshot and write a durable schedule +pointer, but it cannot rewrite a DSH surface. + +This adapter exposes the safe factory for the shared CLI product; it does not +register a duplicate product CLI command itself. + +## Same-host interactive-selector limitation (GX-03) + +DeepSeek Harness's current command contract supplies parsed `rawInput`, while +its web terminal block is a settled-output renderer rather than an interactive +raw-key TTY. Consequently this adapter provides the reproducible native text +workflow (`/tokenpilot-clean`, then `--plan ... --select ...`) but **does not +claim an arrow-key/space/enter selector inside DSH**. Implementing that UX +requires a host-supported raw-key terminal capability or a DSH-owned web +selector. The persisted-snapshot boundary above enables the separate CLI/TTY +path without weakening DSH's surface-mutation boundary. ## Verification @@ -82,9 +116,43 @@ corepack pnpm --dir .\components\adapters\deepseek-harness typecheck corepack pnpm --dir .\components\adapters\deepseek-harness test ``` +Current branch validation: `typecheck` passed and `test` passed (107 tests, +including persisted-snapshot restart, freshness, no-raw-text, protected/unknown +selection rejection, and schedule-without-surface-mutation coverage). + +`compatibility:smoke` also passed against the pinned local DSH +`0.1.2-alpha.3` (`dd6322d604e00eec1ba5e0c8541159906a21094a`). It verifies +the packed adapter, profile install/remove, and keyless Web startup. The smoke +deliberately does not create a model-backed Cleaner session or mutate a live +surface, so it is a package/compatibility check rather than evidence of a +production Cleaner rollout. + For the pinned DSH compatibility smoke, pass a checkout of the required DSH revision: ```powershell corepack pnpm --dir .\components\adapters\deepseek-harness compatibility:smoke -- --dsh-checkout="C:\path\to\deepseek-harness" ``` + +### GX-02 real DSH-session verification + +The unit suite intentionally uses small structural session fixtures for fault +injection. For GX-02 acceptance, also run the following command against the +pinned DSH checkout: + +```powershell +corepack pnpm --dir .\components\adapters\deepseek-harness gx02:real-session -- --dsh-checkout="C:\path\to\deepseek-harness" +``` + +It starts an isolated, **real DSH Cordis runtime** with its real `SessionStore`, +`AgentLoop`, `TokenMeter`, and `agent/pre-step` waterfall. It persists A/B +completed and C active task state, verifies that analysis and selection leave +the canonical surface revision unchanged, then sends one ordinary agent request +to perform the selected A cleanup. The JSON evidence records the before/after +revision, replaced and retained source-event IDs, the applied receipt, and the +terminal replay guard; its temporary state directory is always removed. + +The model adapter in this verifier is DSH's deterministic local keyless mock. +That avoids API keys and network calls; it is **not** presented as evidence of +an external production model. The separate compatibility smoke remains the +profile install/remove and Web-startup check. diff --git a/components/adapters/deepseek-harness/package.json b/components/adapters/deepseek-harness/package.json index c5ca9845..a25f9f7c 100644 --- a/components/adapters/deepseek-harness/package.json +++ b/components/adapters/deepseek-harness/package.json @@ -42,6 +42,7 @@ "build": "tsx build.ts", "test": "pnpm run build && node --import tsx --test tests/*.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit", + "gx02:real-session": "node scripts/gx02-real-session-runner.mjs", "compatibility:smoke": "node scripts/compatibility-smoke.mjs", "prepack": "pnpm run build" }, diff --git a/components/adapters/deepseek-harness/scripts/gx02-real-session-runner.mjs b/components/adapters/deepseek-harness/scripts/gx02-real-session-runner.mjs new file mode 100644 index 00000000..d85e8f1e --- /dev/null +++ b/components/adapters/deepseek-harness/scripts/gx02-real-session-runner.mjs @@ -0,0 +1,34 @@ +/** + * Start the GX-02 verifier through the checked-out DSH tsconfig. + * + * DSH's source packages rely on its workspace path aliases. Running the + * verifier through this tiny launcher prevents Node from accidentally mixing + * the adapter's dependency graph with DSH's built-package graph. + */ + +import { access } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const args = process.argv.slice(2) +const checkoutArgument = args.find(value => value.startsWith('--dsh-checkout=')) +if (checkoutArgument === undefined) { + throw new Error('GX-02 requires --dsh-checkout=') +} + +const checkout = resolve(checkoutArgument.slice('--dsh-checkout='.length)) +const tsxCli = join(checkout, 'node_modules', 'tsx', 'dist', 'cli.mjs') +const tsconfig = join(checkout, 'tsconfig.json') +await Promise.all([access(tsxCli), access(tsconfig)]) + +const script = join(dirname(fileURLToPath(import.meta.url)), 'gx02-real-session.ts') +const child = spawn(process.execPath, [tsxCli, '--tsconfig', tsconfig, script, ...args], { + stdio: 'inherit', +}) + +const exitCode = await new Promise(resolveChild => { + child.once('error', () => resolveChild(1)) + child.once('exit', code => resolveChild(code ?? 1)) +}) +process.exitCode = exitCode diff --git a/components/adapters/deepseek-harness/scripts/gx02-real-session.ts b/components/adapters/deepseek-harness/scripts/gx02-real-session.ts new file mode 100644 index 00000000..4b70a1bf --- /dev/null +++ b/components/adapters/deepseek-harness/scripts/gx02-real-session.ts @@ -0,0 +1,348 @@ +/** + * GX-02 runtime acceptance check. + * + * This script deliberately uses a real DSH Cordis Context, SessionStore, + * AgentLoop and `agent/pre-step` waterfall from a checked-out DSH tree. The + * only deterministic substitute is the model provider: the DSH repository's + * keyless CLI mock keeps this check reproducible and avoids sending test + * content or credentials to an external model. + * + * It is not a production-model benchmark. Its evidence is about DSH session + * ownership, command no-op behaviour, the next-request execution boundary, + * canonical surface replacement, and the replay guard. + */ + +import assert from "node:assert/strict"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + applySessionTaskRegistryPatch, + createEmptySessionTaskRegistry, + persistSessionTaskRegistry, +} from "@lightrsi/history"; + +import { + createDshCleanerPreStepState, + registerDshCleanerPreStep, +} from "../src/cleaner-pre-step.js"; +import { executeContextCleanerCommand } from "../src/context-cleaner/commands.js"; +import { readDshCleanerSchedule } from "../src/context-cleaner/scheduler.js"; +import { surfaceRevision } from "../src/context-cleaner/snapshot.js"; +import { normalizeDshConfig } from "../src/config.js"; +import { registerEvictionPreStep } from "../src/eviction-engine.js"; + +type Module = Record; + +function requiredDshCheckout(): string { + const argument = process.argv.find((value) => value.startsWith("--dsh-checkout=")); + if (!argument) { + throw new Error("GX-02 requires --dsh-checkout="); + } + return resolve(argument.slice("--dsh-checkout=".length)); +} + +function sourceUrl(checkout: string, relativePath: string): string { + return pathToFileURL(join(checkout, relativePath)).href; +} + +function planIdFrom(text: string | undefined): string { + const planId = /plan id: ([^\n]+)/u.exec(text ?? "")?.[1]; + assert.ok(planId, "Cleaner analysis must return an immutable plan id"); + return planId; +} + +function assertSuccess(result: { kind: string; text?: string }, label: string): asserts result is { kind: "success"; text: string } { + assert.equal(result.kind, "success", `${label} failed: ${result.text ?? "no diagnostic"}`); + assert.equal(typeof result.text, "string", `${label} did not render command output`); +} + +async function main(): Promise { + const checkout = requiredDshCheckout(); + await access(join(checkout, "packages", "core", "agent-loop", "src", "index.ts")); + + const [cordis, agentLoopModule, sessionModule, llmModule, projectionModule, tokenMeterModule, testkitModule, mockPlugin] = await Promise.all([ + import(sourceUrl(checkout, "vendor/cordis/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/core/agent-loop/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/core/session/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/llm/llm/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/session/session-projection/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/llm/token-meter/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/test-support/agent-loop-testkit/src/index.ts")) as Promise, + import(sourceUrl(checkout, "packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts")) as Promise, + ]); + + const stateRoot = await mkdtemp(join(tmpdir(), "lightrsi-gx02-real-dsh-")); + let evidence: Record | undefined; + try { + const ctx = new cordis.Context(); + await ctx.plugin(projectionModule.default); + await testkitModule.mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: "You are a deterministic GX-02 verification agent." }, + }); + await ctx.plugin(tokenMeterModule.default); + await ctx.plugin(agentLoopModule.default, { agents: [] }); + await ctx.plugin(mockPlugin); + + const config = normalizeDshConfig({ + enabled: true, + stateDir: stateRoot, + // The injected registry fails before the estimator can make a request. + // Keeping both features enabled proves the request-local Cleaner claim, + // rather than a disabled feature flag, suppresses automatic eviction. + taskStateEstimator: { + enabled: true, + baseUrl: "http://127.0.0.1:9", + apiKey: "gx02-no-network", + model: "gx02-no-network", + }, + eviction: { enabled: true }, + }); + const state = createDshCleanerPreStepState(); + let automaticEvictionRegistryReads = 0; + registerEvictionPreStep(ctx, config, { + async load() { + automaticEvictionRegistryReads += 1; + throw new Error("gx02_automatic_eviction_probe"); + }, + async persist() { + throw new Error("gx02_automatic_eviction_probe_persist_unexpected"); + }, + }, { + shouldSkipAutomaticEviction: (payload) => state.wasClaimed(payload), + }); + registerDshCleanerPreStep(ctx, config, state); + + let unexpectedAutomaticPasses = 0; + let scheduledPreStepObserved = false; + let selectedSourceEventSeqs: number[] = []; + let revisionImmediatelyAfterClean: string | undefined; + ctx.on("agent/pre-step", async (payload: object, next: () => Promise) => { + // This observer is intentionally after the real Cleaner and automatic + // eviction handlers. During the scheduled request, a non-claimed + // payload would mean the automatic path was not protected from a second + // rewrite opportunity. + if (!scheduledPreStepObserved) { + scheduledPreStepObserved = true; + if (!state.wasClaimed(payload as never)) unexpectedAutomaticPasses += 1; + } + return next(); + }); + ctx.on("session/event", (session: any, event: any) => { + if (!Array.isArray(event.sourceEventSeqs) + || selectedSourceEventSeqs.length === 0 + || !event.sourceEventSeqs.some((seq: number) => selectedSourceEventSeqs.includes(seq))) return; + const landed = session.events + .filter((candidate: any) => Array.isArray(candidate.sourceEventSeqs)) + .flatMap((candidate: any) => candidate.sourceEventSeqs) + .filter((seq: number) => selectedSourceEventSeqs.includes(seq)); + if (new Set(landed).size === selectedSourceEventSeqs.length) { + // This is the revision after Cleaner replacement, before AgentLoop + // appends the later ordinary request/model response. + revisionImmediatelyAfterClean = surfaceRevision(session); + } + }); + + const sessionId = sessionModule.SessionId("gx02-real-dsh-session"); + const agent = ctx.agentLoop.create(sessionId, { provider: "cli-mock", model: "cli-mock" }); + + const seedTurn = (turn: number, userText: string, assistantText: string) => { + agent.session.append("turn/start", { turn }); + const user = agent.session.append("user/message", { + id: `gx02-user-${turn}`, + role: "user", + content: [{ type: "text", text: userText }], + source: { kind: "user" }, + }, { surfaceOp: "append" }); + const assistant = agent.session.append("assistant/message", { + turn, + step: 1, + message: { + id: `gx02-assistant-${turn}`, + role: "assistant", + content: [{ type: "text", text: assistantText }], + source: { kind: "model" }, + }, + }, { surfaceOp: "append" }); + agent.session.append("turn/end", { turn, reason: { kind: "completed" } }); + return { user: user.seq, assistant: assistant.seq }; + }; + + // A and B are completed; C remains active/protected. Only A is selected, + // so B and C provide independent KEEP_ME checks. + const evict = seedTurn(1, "EVICT_ME: finished historical task input.", "EVICT_ME: finished historical task output."); + const keepCompleted = seedTurn(2, "KEEP_ME: completed but deliberately unselected.", "KEEP_ME: retain this completed task."); + const keepActive = seedTurn(3, "KEEP_ME_ACTIVE: task still in progress.", "KEEP_ME_ACTIVE: working state remains relevant."); + selectedSourceEventSeqs = [evict.user, evict.assistant]; + + const registry = applySessionTaskRegistryPatch(createEmptySessionTaskRegistry(agent.session.id), { + upsertTasks: { + "task-a": { + taskId: "task-a", + title: "Completed task A", + objective: "A completed historical task selected for cleanup.", + lifecycle: "evictable", + evictableReason: "completed_and_moved_on", + completionEvidence: ["Task A was delivered."], + unresolvedQuestions: [], + span: { + firstTurnAbsId: `${agent.session.id}:t1`, + lastTurnAbsId: `${agent.session.id}:t1`, + supportingTurnAbsIds: [`${agent.session.id}:t1`], + lastEstimatorTurnAbsId: `${agent.session.id}:t1`, + }, + }, + "task-b": { + taskId: "task-b", + title: "Completed task B", + objective: "A completed task intentionally retained in the context.", + lifecycle: "evictable", + evictableReason: "completed_and_moved_on", + completionEvidence: ["Task B was delivered."], + unresolvedQuestions: [], + span: { + firstTurnAbsId: `${agent.session.id}:t2`, + lastTurnAbsId: `${agent.session.id}:t2`, + supportingTurnAbsIds: [`${agent.session.id}:t2`], + lastEstimatorTurnAbsId: `${agent.session.id}:t2`, + }, + }, + "task-c": { + taskId: "task-c", + title: "Active task C", + objective: "The active task that must remain protected.", + lifecycle: "active", + currentSubgoal: "Continue the current request.", + completionEvidence: [], + unresolvedQuestions: ["The user has not finished task C."], + span: { + firstTurnAbsId: `${agent.session.id}:t3`, + lastTurnAbsId: `${agent.session.id}:t3`, + supportingTurnAbsIds: [`${agent.session.id}:t3`], + lastEstimatorTurnAbsId: `${agent.session.id}:t3`, + }, + }, + }, + activeTaskIds: ["task-c"], + completedTaskIds: ["task-a", "task-b"], + evictableTaskIds: ["task-a", "task-b"], + upsertTurnToTaskIds: { + [`${agent.session.id}:t1`]: ["task-a"], + [`${agent.session.id}:t2`]: ["task-b"], + [`${agent.session.id}:t3`]: ["task-c"], + }, + }); + await persistSessionTaskRegistry(stateRoot, registry, { expectedVersion: 0 }); + + const commandContext = { commands: { register: () => () => {} } }; + const invoke = (rawInput: string) => ({ + commandId: "tokenpilot-clean", + agent, + rawInput, + signal: new AbortController().signal, + }); + + const beforeRevision = surfaceRevision(agent.session); + const beforeNodes = [...agent.session.surface.nodes]; + const beforeEvents = agent.session.events.length; + + const analysis = await executeContextCleanerCommand(commandContext, config, invoke("")); + assertSuccess(analysis, "read-only analysis"); + assert.match(analysis.text, /\[ \] \| task-a \|/u); + assert.match(analysis.text, /\[ \] \| task-b \|/u); + assert.match(analysis.text, /\[-\] \| task-c \|/u); + assert.equal(surfaceRevision(agent.session), beforeRevision, "analysis changed a real DSH surface"); + assert.equal(agent.session.events.length, beforeEvents, "analysis appended a real DSH event"); + + const planId = planIdFrom(analysis.text); + const scheduled = await executeContextCleanerCommand(commandContext, config, invoke(`--plan ${planId} --select task-a`)); + assertSuccess(scheduled, "explicit selection"); + assert.match(scheduled.text, /status: scheduled/u); + assert.equal(surfaceRevision(agent.session), beforeRevision, "selection changed a real DSH surface"); + assert.deepEqual([...agent.session.surface.nodes], beforeNodes, "selection changed real DSH surface membership"); + + const scheduledStatus = await executeContextCleanerCommand(commandContext, config, invoke(`--status ${planId}`)); + assertSuccess(scheduledStatus, "scheduled status"); + assert.match(scheduledStatus.text, /status: scheduled/u); + + // This is the required ordinary next request. It traverses DSH's actual + // AgentLoop and Cordis pre-step waterfall; it is not a direct invocation + // of the Cleaner handler. + agent.followup(llmModule.createUserMessage({ + content: [{ type: "text", text: "Please continue with the current task." }], + source: { kind: "user" }, + })); + await agent.whenIdle(); + + const afterRequestRevision = surfaceRevision(agent.session); + assert.ok(revisionImmediatelyAfterClean, "the scheduled real-session cleanup did not publish a canonical replacement revision"); + assert.notEqual(revisionImmediatelyAfterClean, beforeRevision, "the scheduled real-session cleanup did not change the surface revision"); + assert.equal(agent.session.surface.nodes.includes(evict.user), false, "selected EVICT_ME user content is still on the surface"); + assert.equal(agent.session.surface.nodes.includes(evict.assistant), false, "selected EVICT_ME assistant content is still on the surface"); + assert.equal(agent.session.surface.nodes.includes(keepCompleted.user), true, "unselected KEEP_ME task was removed"); + assert.equal(agent.session.surface.nodes.includes(keepCompleted.assistant), true, "unselected KEEP_ME task was removed"); + assert.equal(agent.session.surface.nodes.includes(keepActive.user), true, "active KEEP_ME task was removed"); + assert.equal(agent.session.surface.nodes.includes(keepActive.assistant), true, "active KEEP_ME task was removed"); + assert.equal(unexpectedAutomaticPasses, 0, "automatic eviction was not suppressed after Cleaner claimed this pre-step"); + assert.equal(automaticEvictionRegistryReads, 0, + "automatic eviction reached its registry during the Cleaner-owned request"); + const automaticEvictionRegistryReadsDuringCleanerRequest = automaticEvictionRegistryReads; + + const appliedStatus = await executeContextCleanerCommand(commandContext, config, invoke(`--status ${planId}`)); + assertSuccess(appliedStatus, "applied status"); + assert.match(appliedStatus.text, /status: applied/u); + assert.match(appliedStatus.text, new RegExp(`surface: ${beforeRevision} -> ${revisionImmediatelyAfterClean}`, "u")); + + const pointer = await readDshCleanerSchedule({ stateDir: stateRoot, sessionId: agent.session.id }); + assert.equal(pointer.outcome, "terminal", "the local schedule did not become terminal"); + if (pointer.outcome === "terminal") assert.equal(pointer.record.receiptStatus, "applied"); + + const cleanReplacements = () => agent.session.events.filter((event: any) => + Array.isArray(event.sourceEventSeqs) + && (event.sourceEventSeqs.includes(evict.user) || event.sourceEventSeqs.includes(evict.assistant)), + ).length; + const replacementsAfterApply = cleanReplacements(); + assert.equal(replacementsAfterApply, 2, "the selected task did not receive exactly two canonical replacements"); + + // A later ordinary request cannot replay an already terminal selection. + agent.followup(llmModule.createUserMessage({ + content: [{ type: "text", text: "Give a concise follow-up." }], + source: { kind: "user" }, + })); + await agent.whenIdle(); + assert.equal(cleanReplacements(), replacementsAfterApply, "terminal Cleaner plan replayed on a later agent request"); + assert.ok(automaticEvictionRegistryReads > automaticEvictionRegistryReadsDuringCleanerRequest, + "automatic eviction did not resume after the Cleaner request became terminal"); + + evidence = { + status: "pass", + mode: "real DSH runtime with deterministic keyless model adapter", + dshCheckout: checkout, + sessionId: agent.session.id, + planId, + receiptStatus: "applied", + beforeRevision, + revisionImmediatelyAfterClean, + afterRequestRevision, + selectedTaskIds: ["task-a"], + replacedSourceEventSeqs: [evict.user, evict.assistant], + retainedSourceEventSeqs: [keepCompleted.user, keepCompleted.assistant, keepActive.user, keepActive.assistant], + automaticEvictionPassesDuringCleanerRequest: unexpectedAutomaticPasses, + automaticEvictionRegistryReadsDuringCleanerRequest, + automaticEvictionRegistryReadsAfterLaterRequest: automaticEvictionRegistryReads, + replayReplacementCount: replacementsAfterApply, + cleanup: "temporary state root removed after verification", + }; + await ctx.fiber.dispose(); + } finally { + await rm(stateRoot, { recursive: true, force: true }); + } + + // Keep output free of the seeded prompt/response text and credentials; this + // is suitable for attaching to the GX-02 task record. + console.log(JSON.stringify(evidence, null, 2)); +} + +await main(); diff --git a/components/adapters/deepseek-harness/src/cleaner-pre-step.ts b/components/adapters/deepseek-harness/src/cleaner-pre-step.ts index 8fab2cca..43fb00fd 100644 --- a/components/adapters/deepseek-harness/src/cleaner-pre-step.ts +++ b/components/adapters/deepseek-harness/src/cleaner-pre-step.ts @@ -2,8 +2,9 @@ * Context Cleaner request-lifecycle orchestration for DeepSeek Harness. * * This runs before automatic eviction and native compaction. A successful - * Cleaner claim marks the current pre-step so automatic eviction can skip the - * same surface; both systems therefore never rewrite the same request twice. + * Cleaner claim marks the current agent turn so automatic eviction can skip + * every later step in the same request; both systems therefore never rewrite + * the same request twice. */ import { loadSessionTaskRegistry } from "@lightrsi/history"; @@ -24,24 +25,31 @@ import { readDshCleanerSchedule, type DshCleanerClaimedRecord, } from "./context-cleaner/scheduler.js"; -import { surfaceRevision, type DshCleanSnapshotSession } from "./context-cleaner/snapshot.js"; +import { persistDshCleanerSnapshot } from "./context-cleaner/persisted-snapshot.js"; +import { + buildDshCleanSnapshot, + surfaceRevision, + type DshCleanSnapshotSession, +} from "./context-cleaner/snapshot.js"; import type { DshCleanerSessionStore } from "./context-cleaner/session-catalog.js"; import type { CycleSession } from "./eviction-cycle.js"; import type { DshPluginContext, DshPreStepPayload } from "./types.js"; const CLAIM_RECOVERY_AFTER_MS = 30_000; -/** Request-local signal shared with the automatic eviction handler. */ +/** Agent-turn-local signal shared with the automatic eviction handler. */ export type DshCleanerPreStepState = { markClaimed(payload: DshPreStepPayload): void; wasClaimed(payload: DshPreStepPayload): boolean; }; export function createDshCleanerPreStepState(): DshCleanerPreStepState { - const claimed = new WeakSet(); + const claimedTurnByAgent = new WeakMap(); + const turnKey = (payload: DshPreStepPayload) => + `${payload.agent.session.id}\u0000${payload.turn}`; return { - markClaimed(payload) { claimed.add(payload); }, - wasClaimed(payload) { return claimed.has(payload); }, + markClaimed(payload) { claimedTurnByAgent.set(payload.agent, turnKey(payload)); }, + wasClaimed(payload) { return claimedTurnByAgent.get(payload.agent) === turnKey(payload); }, }; } @@ -62,6 +70,24 @@ function sameTaskIds(left: readonly string[], right: readonly string[]): boolean return left.length === right.length && left.every((taskId) => right.includes(taskId)); } +/** + * DSH publishes a metadata-only snapshot while it owns the live session. + * Failures are deliberately non-fatal: a missing/stale snapshot makes an + * external CLI unavailable, but must never interrupt a normal agent turn. + */ +async function publishDshCleanerSnapshot(params: { + stateDir: string; + session: DshCleanSnapshotSession; +}): Promise { + const registry = await loadSessionTaskRegistry(params.stateDir, params.session.id); + const { snapshot } = buildDshCleanSnapshot({ + session: params.session, + registry, + revision: surfaceRevision(params.session), + }); + await persistDshCleanerSnapshot({ stateDir: params.stateDir, snapshot }); +} + /** * The local pointer can legitimately exist while the final shared * `approved -> scheduled` transition is being retried. Never claim that @@ -160,6 +186,10 @@ export function registerDshCleanerPreStep( if (payload.signal.aborted) return next(); const session = payload.agent.session; + await publishDshCleanerSnapshot({ + stateDir, + session: session as unknown as DshCleanSnapshotSession, + }).catch(() => undefined); const scheduled = await readDshCleanerSchedule({ stateDir, sessionId: session.id }); if (scheduled.outcome === "claimed") { await reconcileClaimedSchedule({ @@ -221,6 +251,10 @@ export function registerDshCleanerPreStep( updatedAt: outcome.receipt.updatedAt, claimId: claim.claimId, }); + await publishDshCleanerSnapshot({ + stateDir, + session: session as unknown as DshCleanSnapshotSession, + }).catch(() => undefined); } else if (outcome.outcome === "terminal") { const status = terminalStatus(outcome.receipt.status); if (status) { @@ -248,17 +282,32 @@ export function registerDshCleanerPreStep( } } else if (outcome.outcome === "failed") { if (outcome.surfaceChanged) ctx.tokenMeter.measure(session); - if (outcome.receipt) { - await finalizeDshCleanerSchedule({ + await finalizeDshCleanerSchedule({ + stateDir, + sessionId: session.id, + cleanPlanId: claim.cleanPlanId, + receiptStatus: "failed", + reasons: outcome.receipt?.reasons ?? outcome.reasons, + ...(outcome.receipt ? { updatedAt: outcome.receipt.updatedAt } : {}), + claimId: claim.claimId, + }); + if (outcome.surfaceChanged) { + await publishDshCleanerSnapshot({ stateDir, - sessionId: session.id, - cleanPlanId: claim.cleanPlanId, - receiptStatus: "failed", - reasons: outcome.receipt.reasons, - updatedAt: outcome.receipt.updatedAt, - claimId: claim.claimId, - }); + session: session as unknown as DshCleanSnapshotSession, + }).catch(() => undefined); } + } else if (outcome.outcome === "skipped") { + // A claim has already been persisted. Preserve at-most-once semantics + // even for an unexpected non-terminal prepare outcome. + await finalizeDshCleanerSchedule({ + stateDir, + sessionId: session.id, + cleanPlanId: claim.cleanPlanId, + receiptStatus: "failed", + reasons: outcome.reasons.length > 0 ? outcome.reasons : ["clean_prepare_skipped"], + claimId: claim.claimId, + }); } } catch { if (claim) { diff --git a/components/adapters/deepseek-harness/src/context-cleaner/capabilities.ts b/components/adapters/deepseek-harness/src/context-cleaner/capabilities.ts index 57ac7ae7..f214aa85 100644 --- a/components/adapters/deepseek-harness/src/context-cleaner/capabilities.ts +++ b/components/adapters/deepseek-harness/src/context-cleaner/capabilities.ts @@ -16,6 +16,10 @@ import { finalizeDshCleanerSchedule, scheduleDshCleanerPlan, } from "./scheduler.js"; +import { + listDshCleanerSnapshots, + readDshCleanerSnapshot, +} from "./persisted-snapshot.js"; import { buildDshCleanSnapshot, DSH_HOST_ID, surfaceRevision } from "./snapshot.js"; export type DshCleanerCapabilityParams = { @@ -33,6 +37,74 @@ function validScheduleRequest(request: ContextCleanerScheduleRequest): boolean { && request.selectedTaskIds.every((taskId) => taskId.trim().length > 0); } +function createDshScheduleWriter(stateDir: string): CleanerHostCapabilities["scheduleWriter"] { + return { + async writeSchedule(request) { + if (!validScheduleRequest(request)) { + return { + outcome: "bypassed" as const, + reasons: ["dsh_cleaner_schedule_input_invalid"], + }; + } + return scheduleDshCleanerPlan({ + stateDir, + sessionId: request.sessionId, + cleanPlanId: request.cleanPlanId, + baseRevision: request.baseRevision, + selectedTaskIds: [...request.selectedTaskIds], + scheduledAt: request.scheduledAt, + }); + }, + abortSchedule(request) { + return finalizeDshCleanerSchedule({ + stateDir, + sessionId: request.sessionId, + cleanPlanId: request.cleanPlanId, + receiptStatus: request.receiptStatus, + reasons: [...request.reasons], + updatedAt: request.updatedAt, + }); + }, + }; +} + +/** + * External callers have no live DSH session object to revalidate against. + * Require the last Host-published snapshot to remain fresh and to describe + * exactly the revision being scheduled. The running Host revalidates again at + * pre-step, but this prevents a stopped/expired external process from even + * creating a new pointer from stale metadata. + */ +function createDshPersistedScheduleWriter(params: { + stateDir: string; + maxSnapshotAgeMs?: number; + now?: () => number; +}): CleanerHostCapabilities["scheduleWriter"] { + const writer = createDshScheduleWriter(params.stateDir); + return { + async writeSchedule(request) { + const snapshot = await readDshCleanerSnapshot({ + stateDir: params.stateDir, + sessionId: request.sessionId, + ...(params.maxSnapshotAgeMs === undefined ? {} : { maxAgeMs: params.maxSnapshotAgeMs }), + ...(params.now === undefined ? {} : { now: params.now }), + }); + if (snapshot.outcome !== "ready") { + return { outcome: "bypassed", reasons: [...snapshot.reasons] }; + } + if (snapshot.snapshot.revision !== request.baseRevision) { + return { outcome: "bypassed", reasons: ["dsh_clean_snapshot_revision_mismatch"] }; + } + return writer.writeSchedule(request); + }, + abortSchedule(request) { + // Cancellation is safe (and desirable) even if DSH has stopped since + // scheduling, so it must not require a live/fresh snapshot. + return writer.abortSchedule(request); + }, + }; +} + /** Create the DeepSeek Harness implementation of the frozen Cleaner boundary. */ export function createDshCleanerCapabilities( params: DshCleanerCapabilityParams, @@ -62,33 +134,56 @@ export function createDshCleanerCapabilities( return listDshCleanerSessions(params.sessions); }, }, - scheduleWriter: { - async writeSchedule(request) { - if (!validScheduleRequest(request)) { - return { - outcome: "bypassed" as const, - reasons: ["dsh_cleaner_schedule_input_invalid"], - }; - } - return scheduleDshCleanerPlan({ - stateDir, - sessionId: request.sessionId, - cleanPlanId: request.cleanPlanId, - baseRevision: request.baseRevision, - selectedTaskIds: [...request.selectedTaskIds], - scheduledAt: request.scheduledAt, - }); - }, - abortSchedule(request) { - return finalizeDshCleanerSchedule({ + scheduleWriter: createDshScheduleWriter(stateDir), + }; +} + +/** + * Public, stateDir-only capabilities for the shared CLI. + * + * Unlike `createDshCleanerCapabilities`, this factory has no Cordis session, + * DSH event, or surface reference. Its snapshots have to be published by the + * running DSH adapter first; external callers can therefore analyse and write + * a schedule, but never mutate live context themselves. + */ +export function createDshPersistedCleanerCapabilities(params: { + stateDir: string; + maxSnapshotAgeMs?: number; + now?: () => number; +}): CleanerHostCapabilities { + const stateDir = params.stateDir.trim(); + if (!stateDir) throw new Error("dsh_clean_state_dir_missing"); + + return { + hostId: DSH_HOST_ID, + rewriteMode: "canonical", + snapshotSource: { + hostId: DSH_HOST_ID, + rewriteMode: "canonical", + async readCleanSnapshot(sessionId) { + const snapshot = await readDshCleanerSnapshot({ stateDir, - sessionId: request.sessionId, - cleanPlanId: request.cleanPlanId, - receiptStatus: request.receiptStatus, - reasons: [...request.reasons], - updatedAt: request.updatedAt, + sessionId, + ...(params.maxSnapshotAgeMs === undefined ? {} : { maxAgeMs: params.maxSnapshotAgeMs }), + ...(params.now === undefined ? {} : { now: params.now }), }); + if (snapshot.outcome !== "ready") { + throw new Error(`dsh_clean_snapshot_unavailable:${snapshot.reasons.join(",")}`); + } + return snapshot.snapshot; }, }, + sessionCatalog: { + listSessions: () => listDshCleanerSnapshots({ + stateDir, + ...(params.maxSnapshotAgeMs === undefined ? {} : { maxAgeMs: params.maxSnapshotAgeMs }), + ...(params.now === undefined ? {} : { now: params.now }), + }), + }, + scheduleWriter: createDshPersistedScheduleWriter({ + stateDir, + ...(params.maxSnapshotAgeMs === undefined ? {} : { maxSnapshotAgeMs: params.maxSnapshotAgeMs }), + ...(params.now === undefined ? {} : { now: params.now }), + }), }; } diff --git a/components/adapters/deepseek-harness/src/context-cleaner/commands.ts b/components/adapters/deepseek-harness/src/context-cleaner/commands.ts index 3edc4b71..2aa51e38 100644 --- a/components/adapters/deepseek-harness/src/context-cleaner/commands.ts +++ b/components/adapters/deepseek-harness/src/context-cleaner/commands.ts @@ -27,8 +27,13 @@ import type { import type { TokenPilotDshConfig } from "../config.js"; import type { DshSession, DshTokenMeter } from "../types.js"; import { createDshCleanerCapabilities } from "./capabilities.js"; +import { persistDshCleanerSnapshot } from "./persisted-snapshot.js"; import { cancelDshCleanerSchedule, readDshCleanerSchedule } from "./scheduler.js"; -import type { DshCleanSnapshotSession } from "./snapshot.js"; +import { + buildDshCleanSnapshot, + surfaceRevision, + type DshCleanSnapshotSession, +} from "./snapshot.js"; import type { DshCleanerSessionStore } from "./session-catalog.js"; /** The optional DSH command capability used by the Cleaner. */ @@ -54,6 +59,7 @@ export type ContextCleanerCommandDependencies = { type Runtime = { readonly stateDir: string; readonly sessions: DshCleanerSessionStore; + readonly loadRegistry: (sessionId: string) => Promise | SessionTaskRegistry; readonly control: ContextCleanerControlService; }; @@ -100,6 +106,7 @@ function createRuntime( return { stateDir, sessions, + loadRegistry, control: createContextCleanerControlService({ stateDir, capabilities, @@ -108,6 +115,23 @@ function createRuntime( }), }; } + +/** + * Publish only canonical snapshot metadata to `stateDir` for a trusted + * external consumer. The external process never receives DSH events or the + * model-visible text itself, and it has no route to mutate this live session. + */ +async function publishRuntimeSnapshot(runtime: Runtime, sessionId: string): Promise { + const session = runtime.sessions.get(sessionId); + if (!session) throw new Error("dsh_clean_snapshot_unavailable"); + const registry = await runtime.loadRegistry(sessionId); + const { snapshot } = buildDshCleanSnapshot({ + session, + registry, + revision: surfaceRevision(session), + }); + await persistDshCleanerSnapshot({ stateDir: runtime.stateDir, snapshot }); +} function helpText(): string { return [ "TokenPilot Context Cleaner", @@ -241,6 +265,7 @@ async function analyze( if (!runtime.sessions.get(targetSessionId)) { return { kind: "error", text: "Context Cleaner session is unavailable in this DSH host." }; } + await publishRuntimeSnapshot(runtime, targetSessionId); const plan = await runtime.control.analyze(targetSessionId); const receipt = await runtime.control.readReceipt(plan.planId); return { kind: "success", text: formatPlan(plan, receipt?.fallbackUsed ?? false) }; diff --git a/components/adapters/deepseek-harness/src/context-cleaner/persisted-snapshot.ts b/components/adapters/deepseek-harness/src/context-cleaner/persisted-snapshot.ts new file mode 100644 index 00000000..2fddfb7d --- /dev/null +++ b/components/adapters/deepseek-harness/src/context-cleaner/persisted-snapshot.ts @@ -0,0 +1,286 @@ +/** + * Trusted, read-only Context Cleaner snapshots for external DSH consumers. + * + * DSH's live SessionStore belongs to the Cordis process. A standalone CLI + * must never deserialize that store or reach into its event/surface objects. + * Instead, the running DSH adapter publishes this small, metadata-only + * snapshot to its existing LightRSI stateDir. External consumers can use it + * to analyse and schedule work, but not to rewrite a DSH surface. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { + ContextCleanSnapshot, + ContextCleanTokenCountMode, + ContextCleanerSession, +} from "@lightrsi/cleaner"; +import { + MODEL_CONTEXT_REWRITE_SCHEMA_VERSION, + type ContextItemKind, + type ContextItemRef, +} from "@lightrsi/host-adapter"; + +import { DSH_HOST_ID } from "./snapshot.js"; + +export const DSH_CLEANER_SNAPSHOT_SCHEMA = "lightrsi.deepseek-harness.cleaner-snapshot/v1" as const; +export const DEFAULT_DSH_CLEANER_SNAPSHOT_MAX_AGE_MS = 5 * 60_000; + +export type DshCleanerPersistedSnapshot = { + schema: typeof DSH_CLEANER_SNAPSHOT_SCHEMA; + hostId: typeof DSH_HOST_ID; + sessionId: string; + revision: string; + capturedAt: string; + snapshot: ContextCleanSnapshot; +}; + +export type DshCleanerSnapshotReadResult = + | { outcome: "ready"; snapshot: ContextCleanSnapshot; reasons: [] } + | { outcome: "unavailable"; reasons: string[] }; + +function nonBlank(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function canonicalTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + const ms = Date.parse(value); + return Number.isFinite(ms) && new Date(ms).toISOString() === value; +} + +function safeAge(value: number | undefined): number { + if (value === undefined) return DEFAULT_DSH_CLEANER_SNAPSHOT_MAX_AGE_MS; + if (!Number.isSafeInteger(value) || value < 1_000 || value > 24 * 60 * 60_000) { + throw new Error("dsh_clean_snapshot_max_age_invalid"); + } + return value; +} + +function snapshotDirectory(stateDir: string): string { + return join(stateDir, "cleaner-snapshot"); +} + +function snapshotFileName(sessionId: string): string { + return `${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}.json`; +} + +function snapshotPath(stateDir: string, sessionId: string): string { + return join(snapshotDirectory(stateDir), snapshotFileName(sessionId)); +} + +const CONTEXT_ITEM_KINDS = new Set([ + "system", "developer", "user", "assistant", "reasoning", + "tool_call", "tool_result", "compaction", "unknown", +]); +const TOKEN_COUNT_MODES = new Set([ + "exact", "estimated", "chars_only", +]); + +function optionalNonBlank(value: unknown): value is string | undefined { + return value === undefined || nonBlank(value); +} + +function parseItem(value: unknown): ContextItemRef | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const item = value as Record; + if (!nonBlank(item.stableId) + || !CONTEXT_ITEM_KINDS.has(item.kind as ContextItemKind) + || !optionalNonBlank(item.role) + || !optionalNonBlank(item.callId) + || !optionalNonBlank(item.responseId) + || !nonBlank(item.fingerprint) + || !Number.isSafeInteger(item.chars) + || Number(item.chars) < 0 + || (item.taskIds !== undefined && (!Array.isArray(item.taskIds) + || !item.taskIds.every(nonBlank) + || new Set(item.taskIds).size !== item.taskIds.length))) { + return undefined; + } + + return { + stableId: item.stableId, + kind: item.kind as ContextItemKind, + ...(item.role === undefined ? {} : { role: item.role }), + ...(item.callId === undefined ? {} : { callId: item.callId }), + ...(item.responseId === undefined ? {} : { responseId: item.responseId }), + ...(item.taskIds === undefined ? {} : { taskIds: [...item.taskIds] as string[] }), + fingerprint: item.fingerprint, + chars: Number(item.chars), + }; +} + +function parseItemTokenCounts(value: unknown): Record | undefined { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value as Record); + if (entries.some(([stableId, count]) => + !nonBlank(stableId) || !Number.isSafeInteger(count) || Number(count) < 0)) return undefined; + return Object.fromEntries(entries.map(([stableId, count]) => [stableId, Number(count)])); +} + +function parsePersistedSnapshot(value: unknown): DshCleanerPersistedSnapshot | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + const snapshot = record.snapshot; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return undefined; + const parsed = snapshot as Record; + + const items = Array.isArray(parsed.items) + ? parsed.items.map(parseItem) + : []; + const itemTokenCounts = parseItemTokenCounts(parsed.itemTokenCounts); + + if (record.schema !== DSH_CLEANER_SNAPSHOT_SCHEMA + || record.hostId !== DSH_HOST_ID + || !nonBlank(record.sessionId) + || !nonBlank(record.revision) + || !canonicalTimestamp(record.capturedAt) + || parsed.hostId !== DSH_HOST_ID + || parsed.sessionId !== record.sessionId + || parsed.revision !== record.revision + || parsed.capturedAt !== record.capturedAt + || parsed.schemaVersion !== MODEL_CONTEXT_REWRITE_SCHEMA_VERSION + || !Array.isArray(parsed.items) + || items.some((item) => item === undefined) + || new Set(items.map((item) => item?.stableId)).size !== items.length + || !TOKEN_COUNT_MODES.has(parsed.tokenCountMode as ContextCleanTokenCountMode) + || !nonBlank(parsed.tokenCountMethod) + || !optionalNonBlank(parsed.model) + || itemTokenCounts === undefined) { + return undefined; + } + + const cleanSnapshot: ContextCleanSnapshot = { + schemaVersion: MODEL_CONTEXT_REWRITE_SCHEMA_VERSION, + hostId: DSH_HOST_ID, + sessionId: record.sessionId, + revision: record.revision, + capturedAt: record.capturedAt, + items: items as ContextItemRef[], + ...(parsed.model === undefined ? {} : { model: parsed.model }), + tokenCountMode: parsed.tokenCountMode as ContextCleanTokenCountMode, + tokenCountMethod: parsed.tokenCountMethod, + ...(parsed.itemTokenCounts === undefined ? {} : { itemTokenCounts }), + }; + return { + schema: DSH_CLEANER_SNAPSHOT_SCHEMA, + hostId: DSH_HOST_ID, + sessionId: record.sessionId, + revision: record.revision, + capturedAt: record.capturedAt, + snapshot: cleanSnapshot, + }; +} + +async function writeAtomically(path: string, value: DshCleanerPersistedSnapshot): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value)}\n`, "utf8"); + await rename(temporary, path); +} + +async function readSnapshotFile(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + return undefined; + } + try { + return parsePersistedSnapshot(JSON.parse(raw)); + } catch { + return undefined; + } +} + +function isFresh(snapshot: DshCleanerPersistedSnapshot, maxAgeMs: number, now: () => number): boolean { + const capturedAtMs = Date.parse(snapshot.capturedAt); + const age = now() - capturedAtMs; + // A substantially future timestamp is not a trustworthy host snapshot. + return age >= -5_000 && age <= maxAgeMs; +} + +/** + * Write a DSH-owned snapshot. Callers must supply data already read from the + * live SessionStore; this function itself does not know about DSH events. + */ +export async function persistDshCleanerSnapshot(params: { + stateDir: string; + snapshot: ContextCleanSnapshot; +}): Promise { + const stateDir = params.stateDir.trim(); + const snapshot = params.snapshot; + if (!stateDir || snapshot.hostId !== DSH_HOST_ID || !snapshot.sessionId.trim() + || !snapshot.revision.trim() || !canonicalTimestamp(snapshot.capturedAt)) { + throw new Error("dsh_clean_snapshot_write_input_invalid"); + } + + const record: DshCleanerPersistedSnapshot = { + schema: DSH_CLEANER_SNAPSHOT_SCHEMA, + hostId: DSH_HOST_ID, + sessionId: snapshot.sessionId, + revision: snapshot.revision, + capturedAt: snapshot.capturedAt, + snapshot, + }; + const parsed = parsePersistedSnapshot(record); + if (!parsed) throw new Error("dsh_clean_snapshot_write_invalid"); + await writeAtomically(snapshotPath(stateDir, snapshot.sessionId), parsed); +} + +/** Read one trusted snapshot without accessing DSH's live SessionStore. */ +export async function readDshCleanerSnapshot(params: { + stateDir: string; + sessionId: string; + maxAgeMs?: number; + now?: () => number; +}): Promise { + const stateDir = params.stateDir.trim(); + const sessionId = params.sessionId.trim(); + if (!stateDir || !sessionId) { + return { outcome: "unavailable", reasons: ["dsh_clean_snapshot_identity_invalid"] }; + } + + const maxAgeMs = safeAge(params.maxAgeMs); + const record = await readSnapshotFile(snapshotPath(stateDir, sessionId)); + if (!record) return { outcome: "unavailable", reasons: ["dsh_clean_snapshot_missing_or_invalid"] }; + if (record.sessionId !== sessionId) { + return { outcome: "unavailable", reasons: ["dsh_clean_snapshot_session_mismatch"] }; + } + if (!isFresh(record, maxAgeMs, params.now ?? Date.now)) { + return { outcome: "unavailable", reasons: ["dsh_clean_snapshot_stale"] }; + } + return { outcome: "ready", snapshot: record.snapshot, reasons: [] }; +} + +/** List only fresh, schema-valid snapshots. Invalid files are never trusted. */ +export async function listDshCleanerSnapshots(params: { + stateDir: string; + maxAgeMs?: number; + now?: () => number; +}): Promise { + const stateDir = params.stateDir.trim(); + if (!stateDir) return []; + const maxAgeMs = safeAge(params.maxAgeMs); + let entries: string[]; + try { + entries = await readdir(snapshotDirectory(stateDir)); + } catch { + return []; + } + + const now = params.now ?? Date.now; + const sessions = await Promise.all(entries + .filter((entry) => typeof entry === "string" && /^[a-f0-9]{32}\.json$/u.test(entry)) + .map((entry) => readSnapshotFile(join(snapshotDirectory(stateDir), entry)))); + return sessions + .filter((snapshot): snapshot is DshCleanerPersistedSnapshot => + snapshot !== undefined && isFresh(snapshot, maxAgeMs, now)) + .map((snapshot) => ({ sessionId: snapshot.sessionId, updatedAt: snapshot.capturedAt })) + .sort((left, right) => + String(right.updatedAt).localeCompare(String(left.updatedAt)) + || left.sessionId.localeCompare(right.sessionId)); +} diff --git a/components/adapters/deepseek-harness/src/index.ts b/components/adapters/deepseek-harness/src/index.ts index 082e217a..27cef340 100644 --- a/components/adapters/deepseek-harness/src/index.ts +++ b/components/adapters/deepseek-harness/src/index.ts @@ -23,10 +23,12 @@ import type { DshPluginContext } from "./types.js"; export { createDshCleanerCapabilities, + createDshPersistedCleanerCapabilities, type DshCleanerCapabilityParams, } from "./context-cleaner/capabilities.js"; export { DSH_PRODUCT_HOST_REGISTRATION, + createDshPersistedCleanerControlService, resolveDshStateDir, } from "./product-registration.js"; diff --git a/components/adapters/deepseek-harness/src/product-registration.ts b/components/adapters/deepseek-harness/src/product-registration.ts index 3e252066..4f775e59 100644 --- a/components/adapters/deepseek-harness/src/product-registration.ts +++ b/components/adapters/deepseek-harness/src/product-registration.ts @@ -10,11 +10,49 @@ import { readFile } from "node:fs/promises"; import { readLatestUxEffect } from "@lightrsi/host-adapter"; +import { + createContextCleanerControlPlane, + createContextCleanerControlService, + type ContextCleanerControlService, +} from "@lightrsi/cleaner"; import { defineProductHostRegistration } from "@lightrsi/product-surface"; import { normalizeDshConfig } from "./config.js"; +import { createDshPersistedCleanerCapabilities } from "./context-cleaner/capabilities.js"; -export { createDshCleanerCapabilities, type DshCleanerCapabilityParams } from "./context-cleaner/capabilities.js"; +export { + createDshCleanerCapabilities, + createDshPersistedCleanerCapabilities, + type DshCleanerCapabilityParams, +} from "./context-cleaner/capabilities.js"; + +/** + * Create the external-CLI control service without acquiring any DSH runtime + * object. It reads only fresh metadata snapshots published by DSH into + * `stateDir`; approval writes a schedule pointer and cannot rewrite context. + */ +export function createDshPersistedCleanerControlService(params: { + stateDir: string; + maxSnapshotAgeMs?: number; + now?: () => string; + snapshotNow?: () => number; +}): ContextCleanerControlService { + const stateDir = params.stateDir.trim(); + if (!stateDir) throw new Error("dsh_clean_state_dir_missing"); + return createContextCleanerControlService({ + stateDir, + capabilities: createDshPersistedCleanerCapabilities({ + stateDir, + ...(params.maxSnapshotAgeMs === undefined ? {} : { maxSnapshotAgeMs: params.maxSnapshotAgeMs }), + ...(params.snapshotNow === undefined ? {} : { now: params.snapshotNow }), + }), + controlPlane: createContextCleanerControlPlane({ + stateDir, + ...(params.now === undefined ? {} : { now: params.now }), + }), + ...(params.now === undefined ? {} : { now: params.now }), + }); +} /** Resolve only the LightRSI-owned state directory; never read a DSH session here. */ export function resolveDshStateDir(config: unknown): string | undefined { diff --git a/components/adapters/deepseek-harness/tests/context-cleaner-commands.test.ts b/components/adapters/deepseek-harness/tests/context-cleaner-commands.test.ts index 3a1c7a32..29764a6e 100644 --- a/components/adapters/deepseek-harness/tests/context-cleaner-commands.test.ts +++ b/components/adapters/deepseek-harness/tests/context-cleaner-commands.test.ts @@ -27,6 +27,7 @@ import { readDshCleanerSchedule, scheduleDshCleanerPlan, } from "../src/context-cleaner/scheduler.js"; +import { readDshCleanerSnapshot } from "../src/context-cleaner/persisted-snapshot.js"; import { buildDshCleanSnapshot, surfaceRevision } from "../src/context-cleaner/snapshot.js"; import { normalizeDshConfig } from "../src/config.js"; import * as adapterPlugin from "../src/index.js"; @@ -266,6 +267,13 @@ describe("Context Cleaner DSH command", () => { assert.match(analysis.text ?? "", /\[ \] \| meal-plan \|/u); assert.deepEqual(session.surface.nodes, [2, 3], "analysis must not mutate DSH's surface"); + const published = await readDshCleanerSnapshot({ stateDir, sessionId: session.id }); + assert.equal(published.outcome, "ready", "native analysis publishes the external read-only snapshot"); + if (published.outcome === "ready") { + assert.equal(JSON.stringify(published.snapshot).includes("inexpensive breakfasts"), false, + "the published snapshot must not contain source message text"); + } + const planId = planIdFrom(analysis.text); const scheduled = await executeContextCleanerCommand( context, @@ -293,6 +301,72 @@ describe("Context Cleaner DSH command", () => { } }); + it("rejects duplicate, unknown, and protected selections before a schedule can be written", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-cleaner-")); + try { + const context: ContextCleanerCommandContext = { commands: { register: () => () => {} } }; + const config = configured(stateDir); + const dependencies = { now: () => "2026-09-18T00:00:00.000Z" }; + const session = makeSession("context-cleaner-selection-validation"); + await persistSessionTaskRegistry(stateDir, completedRegistry(session), { expectedVersion: 0 }); + const analysis = await executeContextCleanerCommand(context, config, invocation(session, ""), dependencies); + const planId = planIdFrom(analysis.text); + + const duplicate = await executeContextCleanerCommand( + context, + config, + invocation(session, `--plan ${planId} --select meal-plan,meal-plan`), + dependencies, + ); + assert.equal(duplicate.kind, "error"); + const unknown = await executeContextCleanerCommand( + context, + config, + invocation(session, `--plan ${planId} --select unknown-task`), + dependencies, + ); + assert.equal(unknown.kind, "error"); + assert.match(unknown.text ?? "", /clean_selection_unknown_task/u); + assert.deepEqual(session.surface.nodes, [2, 3]); + assert.notEqual((await readDshCleanerSchedule({ stateDir, sessionId: session.id })).outcome, "ready"); + + const protectedSession = makeSession("context-cleaner-protected-selection"); + const initial = completedRegistry(protectedSession); + const protectedRegistry = applySessionTaskRegistryPatch(initial, { + upsertTasks: { + "meal-plan": { + ...initial.tasks["meal-plan"]!, + lifecycle: "active", + completionEvidence: [], + }, + }, + completedTaskIds: [], + evictableTaskIds: ["meal-plan"], + }); + await persistSessionTaskRegistry(stateDir, protectedRegistry, { expectedVersion: 0 }); + const protectedAnalysis = await executeContextCleanerCommand( + context, + config, + invocation(protectedSession, ""), + dependencies, + ); + if (protectedAnalysis.kind !== "success") assert.fail(protectedAnalysis.text); + assert.match(protectedAnalysis.text ?? "", /\[-\] \| meal-plan \|/u); + const protectedPlanId = planIdFrom(protectedAnalysis.text); + const protectedSelection = await executeContextCleanerCommand( + context, + config, + invocation(protectedSession, `--plan ${protectedPlanId} --select meal-plan`), + dependencies, + ); + assert.equal(protectedSelection.kind, "error"); + assert.match(protectedSelection.text ?? "", /clean_selection_task_protected/u); + assert.deepEqual(protectedSession.surface.nodes, [2, 3]); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + it("executes a scheduled clean exactly once at the next agent/pre-step and records applied evidence", async () => { const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-cleaner-")); try { diff --git a/components/adapters/deepseek-harness/tests/context-cleaner-persisted-snapshot.test.ts b/components/adapters/deepseek-harness/tests/context-cleaner-persisted-snapshot.test.ts new file mode 100644 index 00000000..a5169d9b --- /dev/null +++ b/components/adapters/deepseek-harness/tests/context-cleaner-persisted-snapshot.test.ts @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + applySessionTaskRegistryPatch, + createEmptySessionTaskRegistry, + persistSessionTaskRegistry, +} from "@lightrsi/history"; + +import { + createDshPersistedCleanerCapabilities, +} from "../src/context-cleaner/capabilities.js"; +import { + listDshCleanerSnapshots, + persistDshCleanerSnapshot, + readDshCleanerSnapshot, +} from "../src/context-cleaner/persisted-snapshot.js"; +import { readDshCleanerSchedule } from "../src/context-cleaner/scheduler.js"; +import { buildDshCleanSnapshot, surfaceRevision } from "../src/context-cleaner/snapshot.js"; +import { createDshPersistedCleanerControlService } from "../src/product-registration.js"; +import type { DshLogEventWithMeta, DshSession } from "../src/types.js"; + +const capturedAt = "2026-09-18T00:00:00.000Z"; +const clock = () => Date.parse(capturedAt); + +function makeSession(id = "persisted-cleaner-session"): DshSession { + const events: DshLogEventWithMeta[] = [ + { seq: 1, type: "turn/start", data: { turn: 1 } }, + { + seq: 2, + type: "user/message", + data: { + id: "user-1", + role: "user", + content: [{ type: "text", text: "Make a short breakfast plan." }], + source: { kind: "user" }, + }, + }, + { + seq: 3, + type: "assistant/message", + data: { + turn: 1, + step: 1, + message: { + id: "assistant-1", + role: "assistant", + content: [{ type: "text", text: "Completed plan: oats, milk, and fruit." }], + source: { kind: "model" }, + }, + }, + }, + ]; + return { + id, + events, + surface: { nodes: [2, 3], replaceGeneration: 0 }, + append: () => ({ seq: 4 }), + }; +} + +function completedRegistry(session: DshSession) { + return applySessionTaskRegistryPatch(createEmptySessionTaskRegistry(session.id), { + upsertTasks: { + "completed-plan": { + taskId: "completed-plan", + title: "Breakfast plan", + objective: "Produce a short breakfast plan.", + lifecycle: "completed", + completionEvidence: ["The answer was delivered."], + unresolvedQuestions: [], + span: { + firstTurnAbsId: `${session.id}:t1`, + lastTurnAbsId: `${session.id}:t1`, + supportingTurnAbsIds: [`${session.id}:t1`], + lastEstimatorTurnAbsId: `${session.id}:t1`, + }, + }, + }, + completedTaskIds: ["completed-plan"], + evictableTaskIds: ["completed-plan"], + upsertTurnToTaskIds: { [`${session.id}:t1`]: ["completed-plan"] }, + }); +} + +async function publish(stateDir: string, session: DshSession, snapshotCapturedAt = capturedAt) { + const registry = completedRegistry(session); + await persistSessionTaskRegistry(stateDir, registry, { expectedVersion: 0 }); + const { snapshot } = buildDshCleanSnapshot({ + session, + registry, + revision: surfaceRevision(session), + capturedAt: snapshotCapturedAt, + }); + await persistDshCleanerSnapshot({ stateDir, snapshot }); +} + +describe("DSH Context Cleaner persisted snapshots", () => { + it("lets an external, restarted control service analyze and schedule without a live DSH object", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession(); + await publish(stateDir, session); + const beforeNodes = [...session.surface.nodes]; + + const external = createDshPersistedCleanerControlService({ + stateDir, + maxSnapshotAgeMs: 60_000, + now: () => capturedAt, + snapshotNow: clock, + }); + const plan = await external.analyze(session.id); + assert.equal(plan.sessionId, session.id); + assert.deepEqual(plan.tasks.map((task) => task.taskId), ["completed-plan"]); + assert.deepEqual(session.surface.nodes, beforeNodes, "external analysis cannot touch DSH's live surface"); + + const receipt = await external.approve(plan.planId, ["completed-plan"]); + assert.equal(receipt.status, "scheduled"); + assert.deepEqual(session.surface.nodes, beforeNodes, "external approval may write only the schedule pointer"); + const schedule = await readDshCleanerSchedule({ stateDir, sessionId: session.id }); + assert.equal(schedule.outcome, "ready"); + if (schedule.outcome === "ready") assert.equal(schedule.record.cleanPlanId, plan.planId); + + const stored = await readDshCleanerSnapshot({ + stateDir, + sessionId: session.id, + maxAgeMs: 60_000, + now: clock, + }); + assert.equal(stored.outcome, "ready"); + if (stored.outcome === "ready") { + assert.equal(JSON.stringify(stored.snapshot).includes("oats, milk, and fruit"), false, + "published snapshots are metadata-only and must not persist model-visible text"); + } + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("reports a stale or absent trusted snapshot as unavailable instead of fabricating context", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession("stale-persisted-session"); + await publish(stateDir, session, "2026-09-18T00:00:00.000Z"); + const afterExpiry = () => Date.parse("2026-09-18T00:02:00.000Z"); + const stale = await readDshCleanerSnapshot({ + stateDir, + sessionId: session.id, + maxAgeMs: 1_000, + now: afterExpiry, + }); + assert.equal(stale.outcome, "unavailable"); + if (stale.outcome === "unavailable") assert.deepEqual(stale.reasons, ["dsh_clean_snapshot_stale"]); + + const capabilities = createDshPersistedCleanerCapabilities({ + stateDir, + maxSnapshotAgeMs: 1_000, + now: afterExpiry, + }); + await assert.rejects( + capabilities.snapshotSource.readCleanSnapshot(session.id), + /dsh_clean_snapshot_unavailable:dsh_clean_snapshot_stale/u, + ); + await assert.rejects( + capabilities.snapshotSource.readCleanSnapshot("unknown-session"), + /dsh_clean_snapshot_unavailable:dsh_clean_snapshot_missing_or_invalid/u, + ); + assert.deepEqual(await capabilities.sessionCatalog.listSessions(), []); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("rejects malformed item metadata at the trusted persistence boundary", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession("malformed-item-session"); + const { snapshot } = buildDshCleanSnapshot({ + session, + registry: completedRegistry(session), + revision: surfaceRevision(session), + capturedAt, + }); + snapshot.items[0]!.taskIds = "task-a" as never; + + await assert.rejects( + persistDshCleanerSnapshot({ stateDir, snapshot }), + /dsh_clean_snapshot_write_invalid/u, + ); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("persists and returns only the declared metadata fields", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession("sanitized-metadata-session"); + const { snapshot } = buildDshCleanSnapshot({ + session, + registry: completedRegistry(session), + revision: surfaceRevision(session), + capturedAt, + }); + const sentinel = "DO_NOT_PERSIST_UNKNOWN_DSH_PAYLOAD"; + (snapshot as unknown as Record).adapterMetadata = { rawText: sentinel }; + (snapshot.items[0] as unknown as Record).rawText = sentinel; + + await persistDshCleanerSnapshot({ stateDir, snapshot }); + const files = await readdir(join(stateDir, "cleaner-snapshot")); + assert.equal(files.length, 1); + const raw = await readFile(join(stateDir, "cleaner-snapshot", files[0]!), "utf8"); + assert.equal(raw.includes(sentinel), false, "unknown payload reached the persisted metadata boundary"); + + const stored = await readDshCleanerSnapshot({ + stateDir, + sessionId: session.id, + maxAgeMs: 60_000, + now: clock, + }); + assert.equal(stored.outcome, "ready"); + assert.equal(JSON.stringify(stored).includes(sentinel), false); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("refuses external approval after its trusted snapshot has expired", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession("expired-approval-session"); + await publish(stateDir, session); + const planning = createDshPersistedCleanerControlService({ + stateDir, + maxSnapshotAgeMs: 60_000, + now: () => capturedAt, + snapshotNow: clock, + }); + const plan = await planning.analyze(session.id); + + const expired = createDshPersistedCleanerControlService({ + stateDir, + maxSnapshotAgeMs: 1_000, + now: () => "2026-09-18T00:02:00.000Z", + snapshotNow: () => Date.parse("2026-09-18T00:02:00.000Z"), + }); + await assert.rejects( + expired.approve(plan.planId, ["completed-plan"]), + /clean_schedule_failed:dsh_clean_snapshot_stale/u, + ); + assert.equal( + (await readDshCleanerSchedule({ stateDir, sessionId: session.id })).outcome, + "missing", + "an expired external snapshot must not create a DSH schedule pointer", + ); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("refuses external approval when a fresh Host snapshot has a different revision", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const session = makeSession("revision-approval-session"); + await publish(stateDir, session); + const external = createDshPersistedCleanerControlService({ + stateDir, + maxSnapshotAgeMs: 60_000, + now: () => capturedAt, + snapshotNow: clock, + }); + const plan = await external.analyze(session.id); + + const changedSession = { + ...session, + surface: { ...session.surface, nodes: [2] }, + } as DshSession; + const { snapshot } = buildDshCleanSnapshot({ + session: changedSession, + registry: completedRegistry(changedSession), + revision: surfaceRevision(changedSession), + capturedAt, + }); + await persistDshCleanerSnapshot({ stateDir, snapshot }); + + await assert.rejects( + external.approve(plan.planId, ["completed-plan"]), + /clean_schedule_failed:dsh_clean_snapshot_revision_mismatch/u, + ); + assert.equal( + (await readDshCleanerSchedule({ stateDir, sessionId: session.id })).outcome, + "missing", + ); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("lists only fresh snapshots, so a later external process cannot discover stale sessions", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + try { + const fresh = makeSession("fresh-persisted-session"); + const stale = makeSession("older-persisted-session"); + await publish(stateDir, fresh, "2026-09-18T00:00:00.000Z"); + await publish(stateDir, stale, "2026-09-17T23:00:00.000Z"); + const sessions = await listDshCleanerSnapshots({ + stateDir, + maxAgeMs: 60_000, + now: clock, + }); + assert.deepEqual(sessions.map((entry) => entry.sessionId), [fresh.id]); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("fails closed when the persisted state directory cannot be read", async () => { + const root = await mkdtemp(join(tmpdir(), "lightrsi-dsh-persisted-")); + const stateDir = join(root, "not-a-directory"); + try { + // Use a regular file as stateDir: this is deterministic on every platform + // and exercises the same unavailable-state path as a missing mount/ACL. + await writeFile(stateDir, "not a directory\n", "utf8"); + const capabilities = createDshPersistedCleanerCapabilities({ stateDir }); + + await assert.rejects( + capabilities.snapshotSource.readCleanSnapshot("unavailable-session"), + /dsh_clean_snapshot_unavailable:dsh_clean_snapshot_missing_or_invalid/u, + ); + assert.deepEqual(await capabilities.sessionCatalog.listSessions(), []); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/components/adapters/deepseek-harness/tests/context-cleaner-scheduler.test.ts b/components/adapters/deepseek-harness/tests/context-cleaner-scheduler.test.ts index 9bc06973..115c7ce0 100644 --- a/components/adapters/deepseek-harness/tests/context-cleaner-scheduler.test.ts +++ b/components/adapters/deepseek-harness/tests/context-cleaner-scheduler.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it } from "node:test"; @@ -26,6 +26,11 @@ function scheduleInput(stateDir: string, cleanPlanId = "ctxclean-one") { }; } +function persistedSchedulePath(stateDir: string, sessionId: string): string { + const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32); + return join(stateDir, "cleaner-schedule", `${digest}.json`); +} + describe("DSH Context Cleaner schedule pointer", () => { it("serializes concurrent writers after recovering a stale lock owner", async () => { const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-schedule-")); @@ -131,4 +136,25 @@ describe("DSH Context Cleaner schedule pointer", () => { await rm(stateDir, { recursive: true, force: true }); } }); + + it("fails closed for a corrupt persisted pointer instead of replacing it", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "lightrsi-dsh-schedule-")); + try { + const input = scheduleInput(stateDir); + const path = persistedSchedulePath(stateDir, input.sessionId); + await mkdir(join(stateDir, "cleaner-schedule"), { recursive: true }); + await writeFile(path, "{ not-valid-json\n", "utf8"); + + const read = await readDshCleanerSchedule({ stateDir, sessionId: input.sessionId }); + assert.equal(read.outcome, "bypassed"); + if (read.outcome === "bypassed") assert.deepEqual(read.reasons, ["dsh_cleaner_schedule_malformed"]); + + const schedule = await scheduleDshCleanerPlan(input); + assert.equal(schedule.outcome, "bypassed"); + assert.deepEqual(schedule.reasons, ["dsh_cleaner_schedule_malformed"]); + assert.equal(await readFile(path, "utf8"), "{ not-valid-json\n"); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); }); diff --git a/components/adapters/deepseek-harness/tests/pre-step-order.test.ts b/components/adapters/deepseek-harness/tests/pre-step-order.test.ts index 5d28cf22..52549882 100644 --- a/components/adapters/deepseek-harness/tests/pre-step-order.test.ts +++ b/components/adapters/deepseek-harness/tests/pre-step-order.test.ts @@ -52,6 +52,18 @@ function payload(): DshPreStepPayload { const CONFIG = normalizeDshConfig({ enabled: true, eviction: { enabled: true } }); describe("pre-step listener order (ยง1.3: eviction before compaction)", () => { + it("keeps a Cleaner claim for every pre-step in that agent turn only", () => { + const state = createDshCleanerPreStepState(); + const first = payload(); + state.markClaimed(first); + + assert.equal(state.wasClaimed(first), true); + assert.equal(state.wasClaimed({ ...first, step: first.step + 1 }), true, + "a later model step in the same request must stay mutually exclusive"); + assert.equal(state.wasClaimed({ ...first, turn: first.turn + 1, step: 0 }), false, + "a later request must allow automatic eviction again"); + }); + it("places Cleaner before automatic eviction and native compaction", () => { const { ctx, handlers } = mockWaterfall(); const compaction: Handler = async (_p, next) => next(); diff --git a/components/adapters/deepseek-harness/types/index.d.ts b/components/adapters/deepseek-harness/types/index.d.ts index d7997f37..fac711ed 100644 --- a/components/adapters/deepseek-harness/types/index.d.ts +++ b/components/adapters/deepseek-harness/types/index.d.ts @@ -34,6 +34,169 @@ export declare function createDshCleanerCapabilities( params: DshCleanerCapabilityParams, ): unknown; +/** + * The metadata-only contract exposed to a process outside the DSH Host. + * It intentionally contains no DSH event, message, Cordis, or live-surface + * object. Fields needed by a CLI renderer are kept structural so a packed + * adapter does not require the private workspace Cleaner package for types. + */ +export type DshPersistedContextCleanTask = { + taskId: string; + label: string; + description: string; + summary: string; + lifecycleState: "active" | "unresolved" | "completed" | "aborted" | "unknown"; + itemIds: string[]; + itemDigests: Record; + tokenCount: number | null; + charCount: number; + tokenPercent: number | null; + recommendation: "clean" | "keep" | "protected"; + reasonCodes: string[]; + selectable: boolean; +}; + +export type DshPersistedContextCleanPlan = { + schemaVersion: 1; + planId: string; + hostId: "deepseek-harness"; + sessionId: string; + baseRevision: string; + usedTokens: number | null; + usedChars: number; + protectedTokens: number | null; + protectedChars: number; + unassignedTokens: number | null; + unassignedChars: number; + tokenCountMode: "exact" | "estimated" | "chars_only"; + tokenCountMethod: string; + tasks: DshPersistedContextCleanTask[]; + createdAt: string; +}; + +export type DshPersistedContextCleanReceipt = { + schemaVersion: 1; + planId: string; + hostId: "deepseek-harness"; + sessionId: string; + status: "analyzed" | "approved" | "scheduled" | "applied" | "stale" | "cancelled" | "failed"; + selectedTaskIds: string[]; + estimatedSavedTokens: number | null; + estimatedSavedChars: number; + tokenCountMode: "exact" | "estimated" | "chars_only"; + deferredTaskIds: string[]; + reasons: string[]; + updatedAt: string; + fallbackUsed: boolean; + appliedSavedTokens?: number | null; + appliedSavedChars?: number; + evidence?: { + previousRevision?: string; + nextRevision?: string; + operationIds?: string[]; + itemIds?: string[]; + eventIds?: string[]; + }; +}; + +/** Safe external control surface: it can analyze and schedule, never rewrite DSH. */ +export interface DshPersistedCleanerControlService { + analyze(sessionId: string): Promise; + readPlan(planId: string): Promise; + approve(planId: string, selectedTaskIds: readonly string[]): Promise; + readReceipt(planId: string): Promise; + cancel(planId: string): Promise; +} + +/** + * Create a stateDir-only external control service. DSH must have published a + * fresh snapshot first. The returned object has no path to a live surface. + */ +export declare function createDshPersistedCleanerControlService(params: { + stateDir: string; + maxSnapshotAgeMs?: number; + now?: () => string; + snapshotNow?: () => number; +}): DshPersistedCleanerControlService; + +/** + * Structural metadata returned to an external CLI. This deliberately omits + * raw prompt/response text and every DSH/Cordis runtime object. + */ +export type DshPersistedContextItem = { + stableId: string; + kind: string; + role: string; + callId?: string; + responseId?: string; + taskIds?: string[]; + fingerprint: string; + chars: number; +}; + +export type DshPersistedContextCleanSnapshot = { + schemaVersion: number; + hostId: "deepseek-harness"; + sessionId: string; + revision: string; + items: DshPersistedContextItem[]; + capturedAt: string; + model?: string; + tokenCountMode: "exact" | "estimated" | "chars_only"; + tokenCountMethod: string; + itemTokenCounts?: Record; +}; + +export type DshPersistedCleanerScheduleRequest = { + sessionId: string; + cleanPlanId: string; + baseRevision: string; + selectedTaskIds: string[]; + scheduledAt: string; +}; + +export type DshPersistedCleanerScheduleResult = { + outcome: "stored" | "unchanged" | "transitioned" | "missing" | "conflict" | "bypassed"; + reasons: string[]; +}; + +/** + * StateDir-only capabilities suitable for a separately running CLI. The + * schedule writer records only a Host-owned pointer; it cannot mutate a DSH + * surface. The regular DSH pre-step performs the eventual live rewrite. + */ +export interface DshPersistedCleanerCapabilities { + readonly hostId: "deepseek-harness"; + readonly rewriteMode: "canonical"; + readonly snapshotSource: { + readonly hostId: "deepseek-harness"; + readonly rewriteMode: "canonical"; + readCleanSnapshot(sessionId: string): Promise; + }; + readonly sessionCatalog: { + listSessions(): Promise>; + }; + readonly scheduleWriter: { + writeSchedule( + request: DshPersistedCleanerScheduleRequest, + ): Promise; + abortSchedule( + request: DshPersistedCleanerScheduleRequest & { + receiptStatus: "stale" | "cancelled" | "failed"; + reasons: string[]; + updatedAt: string; + }, + ): Promise; + }; +} + +/** Public stateDir-only capability factory used by shared CLI composition. */ +export declare function createDshPersistedCleanerCapabilities(params: { + stateDir: string; + maxSnapshotAgeMs?: number; + now?: () => number; +}): DshPersistedCleanerCapabilities; + /** Resolve a LightRSI-owned state directory without reading DSH session data. */ export declare function resolveDshStateDir(config: unknown): string | undefined; /** Metadata consumed by LightRSI product/CLI composition. */