diff --git a/README.md b/README.md index f9114a0..6309760 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,30 @@ install tarball, verify it with reproducible: `npm ci && npm run release:build` at the tagged commit yields the same bytes (see [Releases](#releases)). +## Choose what memory can retain + +The default keeps bounded task summaries and explicitly saved memory. It does +not automatically collect exact source excerpts. Summaries and explicit writes +can still contain code; this is not a guarantee that code never enters memory. + +For work where exact specifications or code must survive a fresh session, you +can opt in before launching the host: + +```bash +export CROSSHEAD_CAPTURE_SOURCE_EVIDENCE=1 +codex # or launch Claude Code from this shell +``` + +This permits bounded excerpts of supported workspace file reads to enter memory. +They can be stored, synced through the configured service, and sent to a later +coding-model session. Filters exclude some sensitive paths and credential-like +text, but cannot guarantee that every secret is removed. Use this option only +for repositories whose text you intend to disclose through those paths. + +To disable automatic excerpt capture for later turns, launch the host with +`CROSSHEAD_CAPTURE_SOURCE_EVIDENCE=0`. This does not delete existing memory. +Verify a concrete rule in a fresh session after choosing either setting. + ## What runs locally and what is sent - Each agent has its own Nostr keypair, created locally. The private key stays diff --git a/docs/explicit-remember.md b/docs/explicit-remember.md new file mode 100644 index 0000000..97a33d9 --- /dev/null +++ b/docs/explicit-remember.md @@ -0,0 +1,9 @@ +# Explicit remember and hot continuity + +An explicit adapter `remember --content` writes the supplied text into bounded hot continuity, in addition to the existing warm-memory promotion request. A fresh session can receive that checkpoint without a successful hosted warm write. Normal workspace isolation, context bounds, history eviction and memory-mode policy still apply; this is not an unlimited or permanent archive. + +The command already sends the explicitly supplied content to the runtime. This change also makes it eligible for ordinary hot-context delivery and configured hot sync. It does not enable automatic source-file excerpt collection. `CROSSHEAD_CAPTURE_SOURCE_EVIDENCE` stays opt-in and unchanged. Automatic excerpt collection would retain text from workspace reads beyond the agent's chosen summary; changing that default would require explaining that privacy tradeoff first. + +The November 2026 benchmark additionally pointed its adapter wrappers at library modules instead of CLI bridge entry points. Those wrappers silently exited zero, so an attempted explicit command never emitted a lifecycle event. That harness defect is separate from this product defect: the product fix preserves content after a valid event reaches the runtime; it does not explain a missing event. + +Regression coverage exercises local-only and normal hot-sync profiles, fresh sessions after restart, Codex-to-Claude delivery, source capture disabled, and isolation from a different workspace. These deterministic tests do not measure model task completion or comparative performance. diff --git a/package-lock.json b/package-lock.json index f991751..c00f095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.9", + "version": "0.5.0-alpha.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.9", + "version": "0.5.0-alpha.10", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", diff --git a/package.json b/package.json index da5c7e6..e8477a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.9", + "version": "0.5.0-alpha.10", "description": "Crosshead local runtime: persistent memory for Claude Code, Codex, Pi, Hermes and OpenCode. CLI, daemon, host adapters and hooks.", "license": "Apache-2.0", "private": true, diff --git a/src/protocol/hot-sync.ts b/src/protocol/hot-sync.ts index 6456cd8..8889094 100644 --- a/src/protocol/hot-sync.ts +++ b/src/protocol/hot-sync.ts @@ -14,6 +14,7 @@ export type HotSyncCheckpoint = { occurredAt: string; goal: string; summary: string; + origin?: "remember"; sessionId?: string | null; turnId?: string | null; evidence?: unknown; @@ -59,7 +60,8 @@ export function validHotSyncDocument(value: unknown): value is HotSyncDocument { Number.isFinite(Date.parse(c.occurredAt)) && (c.sequence === undefined || hotSyncCounter(c.sequence)) && (c.supersedes === undefined || Array.isArray(c.supersedes) && c.supersedes.length <= 256 && c.supersedes.every(id => typeof id === "string")) && (c.conflictsWith === undefined || Array.isArray(c.conflictsWith) && c.conflictsWith.length <= 256 && c.conflictsWith.every(id => typeof id === "string")) && - (c.conflictOf === undefined || typeof c.conflictOf === "string")) && + (c.conflictOf === undefined || typeof c.conflictOf === "string") && + (c.origin === undefined || c.origin === "remember")) && new Set(d.checkpoints.map(c => c.id)).size === d.checkpoints.length; } /** Conditional cache identity includes the authenticated owner and document boundary. */ diff --git a/src/runtime/hot-continuity.ts b/src/runtime/hot-continuity.ts index 05d821f..aa23cb5 100644 --- a/src/runtime/hot-continuity.ts +++ b/src/runtime/hot-continuity.ts @@ -16,6 +16,7 @@ export type HotContinuityCheckpoint = { turnId: string | null; goal: string; goalTruncated?: boolean; + origin?: "remember"; summary: string; occurredAt: string; evidence?: TaskEvidence; @@ -48,6 +49,7 @@ export const readHotCheckpoints = (value: unknown): HotContinuityCheckpoint[] => runtime: text(item.runtime), workspaceId: text(item.workspaceId) || null, sessionId: text(item.sessionId) || null, turnId: text(item.turnId) || null, goal: text(item.goal).slice(0, HOT_REQUEST_LIMIT), + ...(item.origin === "remember" ? { origin: "remember" as const } : {}), ...(item.goalTruncated === true || text(item.goal).length > HOT_REQUEST_LIMIT ? { goalTruncated: true } : {}), summary: captureHotFinding(text(item.summary)), occurredAt: text(item.occurredAt), @@ -60,7 +62,9 @@ export const updateHotCheckpoints = ( ): HotContinuityCheckpoint[] => { const sameTurn = (entry: HotContinuityCheckpoint): boolean => entry.runtime === next.runtime && entry.workspaceId === next.workspaceId && - (next.turnId ? entry.turnId === next.turnId && entry.sessionId === next.sessionId : + (entry.origin === "remember" || next.origin === "remember" ? + entry.origin === next.origin && Boolean(next.turnId) && entry.turnId === next.turnId && entry.sessionId === next.sessionId : + next.turnId ? entry.turnId === next.turnId && entry.sessionId === next.sessionId : Boolean(next.sessionId && entry.sessionId === next.sessionId && entry.goal === next.goal)); const old = readHotCheckpoints(previous); const prior = old.slice().reverse().find(entry => sameTurn(entry) || ( @@ -119,7 +123,9 @@ export const selectHotCheckpoints = ( // Request identity is exact: case and whitespace can be contract literals. const groups = new Map(); for (const entry of candidates.reverse().sort((a, b) => timestamp(b.occurredAt) - timestamp(a.occurredAt))) { - const identity = entry.goal || JSON.stringify([entry.runtime, entry.sessionId, entry.turnId, entry.occurredAt, entry.summary]); + const identity = entry.origin === "remember" + ? JSON.stringify(["remember", entry.runtime, entry.sessionId, entry.turnId]) + : entry.goal || JSON.stringify([entry.runtime, entry.sessionId, entry.turnId, entry.occurredAt, entry.summary]); const key = JSON.stringify([entry.workspaceId, identity]); groups.set(key, [...(groups.get(key) ?? []), entry]); } @@ -162,7 +168,7 @@ export const renderHotCheckpoints = (entries: SelectedHotCheckpoint[], maxLength const guidance = "Prior findings. Follow current instructions; reconcile conflicting sources. Observation time alone does not establish authority."; remaining -= guidance.length + 1; for (const [index, entry] of entries.entries()) { - const heading = `Hot memory findings (${entry.runtime}):\n`; + const heading = `${entry.origin === "remember" ? "Explicitly saved memory" : "Hot memory findings"} (${entry.runtime}):\n`; // Every selected task gets room; one large history must not silently erase // another selected amendment or task. Unused space flows to later entries. const available = Math.floor(remaining / (entries.length - index)) - heading.length - 1; diff --git a/src/runtime/hot-sync-manager.ts b/src/runtime/hot-sync-manager.ts index bb1ebc6..a8561e7 100644 --- a/src/runtime/hot-sync-manager.ts +++ b/src/runtime/hot-sync-manager.ts @@ -153,7 +153,7 @@ export class HotSyncManager { const workspaceId = this.workspace(raw), scope = workspaceHotScope(runtime, workspaceId)!; this.active.add(scope); // Identical imports on two devices must have identical signed provenance. - this.replica(scope).capture({ id: `legacy:${digest}`, deviceId: `legacy:${digest}`, runtime, workspaceId, occurredAt: String(checkpoint.occurredAt), goal: String(checkpoint.goal).slice(0, 2000), summary: String(checkpoint.summary).slice(0, 6000), evidence: normalizeTaskEvidence(checkpoint.evidence) ?? undefined }); + this.replica(scope).capture({ id: `legacy:${digest}`, deviceId: `legacy:${digest}`, runtime, workspaceId, occurredAt: String(checkpoint.occurredAt), goal: String(checkpoint.goal).slice(0, 2000), summary: String(checkpoint.summary).slice(0, 6000), ...(checkpoint.origin === "remember" ? { origin: "remember" as const, turnId: typeof checkpoint.turnId === "string" ? checkpoint.turnId : `legacy:${digest}` } : {}), evidence: normalizeTaskEvidence(checkpoint.evidence) ?? undefined }); this.persist(); } /** Import a durable pre-sync queue snapshot before allowing any new upload. diff --git a/src/runtime/lifecycle-engine.ts b/src/runtime/lifecycle-engine.ts index 0948fc2..eb4f1a4 100644 --- a/src/runtime/lifecycle-engine.ts +++ b/src/runtime/lifecycle-engine.ts @@ -894,7 +894,13 @@ const buildHotState = ( const baseHot = event.workspaceId && currentWorkspaceId !== event.workspaceId ? {} : currentHot; const payload = event.payload; const continuityOutcome = buildRuntimeContinuityOutcome(event); + // An explicit write is already user-selected memory. Preserve its text in + // bounded hot continuity as well as proposing warm storage. Source-excerpt + // capture remains a separate opt-in operation. + const explicitMemory = event.type === "operator.command" && payload.command === "remember" + ? asString(payload.content) : null; const summary = + explicitMemory ?? continuityOutcome?.summary ?? asString(payload.summary) ?? asString(payload.result) ?? @@ -970,17 +976,19 @@ const buildHotState = ( }; } - if (event.type === "turn.completed" || event.type === "turn.failed" || event.type === "session.ended" || event.type === "compact.started") { + if (explicitMemory || event.type === "turn.completed" || event.type === "turn.failed" || event.type === "session.ended" || event.type === "compact.started") { const transcript = Array.isArray(payload.transcript) ? payload.transcript : []; const assistant = transcript.slice().reverse().map(asRecord).find((item) => item?.role === "assistant"); - const finding = asString(payload.turnSummary) ?? asString(payload.turn_summary) ?? asString(payload.summary) ?? asString(assistant?.content) ?? summary; + const finding = explicitMemory ?? asString(payload.turnSummary) ?? asString(payload.turn_summary) ?? asString(payload.summary) ?? asString(assistant?.content) ?? summary; // Lifecycle acknowledgments describe transport, not findings. They must not // replace a completed turn's substantive checkpoint. const evidence = normalizeTaskEvidence(payload.taskEvidence); - if (evidence?.sources.length || (event.type !== "compact.started" && !/^(?:(?:Codex|Claude Code)\s+)?(?:session (?:started|ended)|(?:completed|failed) a hooked turn)[.!\s]/i.test(finding))) { + if (explicitMemory || evidence?.sources.length || (event.type !== "compact.started" && !/^(?:(?:Codex|Claude Code)\s+)?(?:session (?:started|ended)|(?:completed|failed) a hooked turn)[.!\s]/i.test(finding))) { nextState.continuityCheckpoints = updateHotCheckpoints(baseHot.continuityCheckpoints, { runtime: event.runtime, workspaceId: event.workspaceId, sessionId: event.sessionId, - turnId: event.turnId, goal: continuityOutcome?.goal ?? asString(payload.goal) ?? "", + turnId: explicitMemory ? event.eventId : event.turnId, + goal: continuityOutcome?.goal ?? asString(payload.goal) ?? "", + ...(explicitMemory ? { origin: "remember" as const } : {}), summary: captureHotFinding(finding), occurredAt: event.occurredAt, ...(evidence ? { evidence } : {}), }); @@ -1969,8 +1977,8 @@ export class RuntimeLifecycleManager { const hot=buildHotState(current,event); const checkpoints=Array.isArray(hot.continuityCheckpoints)?hot.continuityCheckpoints:[]; const last=checkpoints[checkpoints.length-1] as Record|undefined; - if(last && hot.continuityCheckpoints !== current.continuityCheckpoints && ["turn.completed","turn.failed","session.ended","compact.started"].includes(event.type)) { - manager.capture(scope,{id:event.eventId,runtime:event.runtime,workspaceId:event.workspaceId ?? "",occurredAt:event.occurredAt,goal:String(last.goal ?? ""),summary:String(last.summary ?? ""),sessionId:event.sessionId,turnId:event.turnId,...(Array.isArray(event.payload.supersedes) ? {supersedes:event.payload.supersedes.filter((v):v is string=>typeof v === "string")} : {}),...(Array.isArray(event.payload.conflictsWith) ? {conflictsWith:event.payload.conflictsWith.filter((v):v is string=>typeof v === "string")} : {}),...(last.evidence ? {evidence:last.evidence} : {})}); + if(last && hot.continuityCheckpoints !== current.continuityCheckpoints) { + manager.capture(scope,{id:event.eventId,runtime:event.runtime,workspaceId:event.workspaceId ?? "",occurredAt:event.occurredAt,goal:String(last.goal ?? ""),summary:String(last.summary ?? ""),sessionId:event.sessionId,turnId:typeof last.turnId === "string" ? last.turnId : event.turnId,...(last.origin === "remember" ? {origin:"remember" as const} : {}),...(Array.isArray(event.payload.supersedes) ? {supersedes:event.payload.supersedes.filter((v):v is string=>typeof v === "string")} : {}),...(Array.isArray(event.payload.conflictsWith) ? {conflictsWith:event.payload.conflictsWith.filter((v):v is string=>typeof v === "string")} : {}),...(last.evidence ? {evidence:last.evidence} : {})}); actions.push(this.recordAction({eventId:event.eventId,profileName,type:"hot_write",status:"completed",mode:profile.memoryMode,reason:"Saved hot memory on this machine; cloud sync pending",queueItemId:null,details:{type:event.type,storage:"local"}})); } } else { diff --git a/tests/runtime/remember-continuity.test.ts b/tests/runtime/remember-continuity.test.ts new file mode 100644 index 0000000..2007677 --- /dev/null +++ b/tests/runtime/remember-continuity.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; +import { describe, expect, it, vi } from "vitest"; +import { RuntimeLifecycleManager } from "../../src/runtime/lifecycle-engine"; +import { stopHotSyncManagers } from "../../src/runtime/hot-sync-manager"; +import { buildContinuityGovernorState } from "../../src/runtime/continuity-governor"; +import { buildDefaultContinuityPolicy } from "../../src/runtime/continuity-policy"; +import { renderHotCheckpoints, selectHotCheckpoints, updateHotCheckpoints } from "../../src/runtime/hot-continuity"; +import { HOT_SYNC_VERSION, validHotSyncDocument } from "../../src/protocol/hot-sync"; + +describe("explicit remember continuity", () => { + it.each([true, false])("retains explicitly supplied requirements after restart (local-only=%s)", async localOnly => { + const state = mkdtempSync(path.join(tmpdir(), "xhd-remember-")); + const key = generateSecretKey(); + const npub = nip19.npubEncode(getPublicKey(key)), nsec = nip19.nsecEncode(key); + vi.stubEnv("CROSSHEAD_HOT_SYNC_ENABLED", "true"); + vi.stubEnv("CROSSHEAD_CAPTURE_SOURCE_EVIDENCE", "0"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const make = () => new RuntimeLifecycleManager({ + paths: { state }, hotWriteDebounceMs: 0, + loadDefaultProfileName: () => "default", + loadProfile: () => ({ name: "default", apiBase: localOnly ? "http://127.0.0.1:0" : "https://crosshead.sh", npub, + memoryMode: "assist", features: { coldSync: false, autoWarmPromotion: false } }), + loadSecret: () => ({ nsec, session: null }), saveSecret: () => {}, + loadHealth: () => ({ queueDepth: 0, lastSuccessfulSyncAt: null, lastError: null }), saveHealth: () => {}, + loadContinuityGovernorState: () => buildContinuityGovernorState(buildDefaultContinuityPolicy(), []), writeLog: () => {}, + }); + const content = "Canonical-code: trim ASCII space at ends only; collapse underscore runs to one hyphen; lowercase ASCII A-Z only. Preserve internal spaces and non-ASCII letters. Empty becomes unknown. Output is a STRING."; + let manager = make(); + try { + await manager.handleEvent({ eventId: "remember", runtime: "codex", type: "operator.command", workspaceId: "repo:/fixture", sessionId: "investigate", + payload: { command: "remember", content, entryType: "fact" } }); + const second = "Codex session ended unexpectedly; preserve the receipt rule: never replay accepted payments."; + await manager.handleEvent({ eventId: "remember-second", runtime: "codex", type: "operator.command", workspaceId: "repo:/fixture", sessionId: "investigate", + payload: { command: "remember", content: second, entryType: "fact" } }); + await manager.handleEvent({ eventId: "completed", runtime: "codex", type: "turn.completed", workspaceId: "repo:/fixture", sessionId: "investigate", + payload: { summary: "Display labels replace underscores with spaces and preserve case." } }); + await manager.handleEvent({ eventId: "ended", runtime: "codex", type: "session.ended", workspaceId: "repo:/fixture", sessionId: "investigate", + payload: { summary: "Codex session ended." } }); + await manager.flushDueItems(true); + const events = readFileSync(path.join(state, "lifecycle-events.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(events.find(event => event.type === "operator.command").payload.content).toBe(content); + await manager.stop(); + await stopHotSyncManagers(state); + manager = make(); + for (const runtime of ["codex", "claude-code"]) { + const result = await manager.handleEvent({ eventId: `fresh-${runtime}`, runtime, type: "turn.started", workspaceId: "repo:/fixture", sessionId: `fresh-${runtime}`, + payload: { goal: "Implement the canonical-code rule." } }); + expect(JSON.stringify(result.contextPack)).toContain(content); + expect(JSON.stringify(result.contextPack)).toContain(second); + } + const foreign = await manager.handleEvent({ eventId: "foreign", runtime: "codex", type: "turn.started", workspaceId: "repo:/other", payload: { goal: "Continue" } }); + expect(JSON.stringify(foreign.contextPack)).not.toContain(content); + } finally { + await manager.stop(); + await stopHotSyncManagers(state); + vi.unstubAllEnvs(); vi.unstubAllGlobals(); + rmSync(state, { recursive: true, force: true }); + } + }); + + it("delivers a memory that fits the context budget once, without treating it as a task request", () => { + const lines = Array.from({ length: 65 }, (_, i) => `Requirement ${i}: input value ${i} maps to result ${i + 1}.`); + const summary = lines.join("\n") + "\nEmpty input returns a string."; + const checkpoint = { runtime: "codex", workspaceId: "repo:/fixture", sessionId: "source", turnId: "remember-event", origin: "remember" as const, + goal: "", summary, occurredAt: "2026-11-09T01:00:00Z" }; + const stored = updateHotCheckpoints([], checkpoint); + const rendered = renderHotCheckpoints(selectHotCheckpoints({ continuityCheckpoints: stored }, "Implement fixture", "repo:/fixture")); + for (const line of lines) expect(rendered).toContain(line); + expect(rendered).toContain("Explicitly saved memory"); + expect(rendered).not.toContain("Prior task request"); + expect(rendered.length).toBeLessThanOrEqual(4200); + const document = { version: HOT_SYNC_VERSION, namespace: "default", scope: "codex", epoch: 0, + checkpoints: [{ ...checkpoint, id: "remember-event", deviceId: "device" }] }; + expect(validHotSyncDocument(document)).toBe(true); + expect(validHotSyncDocument({ ...document, checkpoints: [{ ...document.checkpoints[0], origin: "arbitrary" }] })).toBe(false); + }); +});