Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/explicit-remember.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/protocol/hot-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type HotSyncCheckpoint = {
occurredAt: string;
goal: string;
summary: string;
origin?: "remember";
sessionId?: string | null;
turnId?: string | null;
evidence?: unknown;
Expand Down Expand Up @@ -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. */
Expand Down
12 changes: 9 additions & 3 deletions src/runtime/hot-continuity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type HotContinuityCheckpoint = {
turnId: string | null;
goal: string;
goalTruncated?: boolean;
origin?: "remember";
summary: string;
occurredAt: string;
evidence?: TaskEvidence;
Expand Down Expand Up @@ -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),
Expand All @@ -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) || (
Expand Down Expand Up @@ -119,7 +123,9 @@ export const selectHotCheckpoints = (
// Request identity is exact: case and whitespace can be contract literals.
const groups = new Map<string, HotContinuityCheckpoint[]>();
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]);
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/hot-sync-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 14 additions & 6 deletions src/runtime/lifecycle-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ??
Expand Down Expand Up @@ -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 } : {}),
});
Expand Down Expand Up @@ -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<string,unknown>|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 {
Expand Down
80 changes: 80 additions & 0 deletions tests/runtime/remember-continuity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading