one-code-extension 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ledger extension, the request side of the record-once design
3
+ * (working-docs/features/lifecycle-grid-hunt/design.md sections 2 and 5): the
4
+ * request is a pure function of the branch. Every request is recorded on the
5
+ * branch as it went on the wire (`ledger/wire.ts`, the payload side), and the
6
+ * next request inside the same epoch takes every byte above its append point
7
+ * from that record instead of from a rebuild.
8
+ *
9
+ * Its `context` hook runs right after kept-thinking, before anything injects:
10
+ *
11
+ * - it folds the branch (lib/ledger/fold.ts, through the shared memo in
12
+ * lib/ledger/fold-memo.ts) and decides whether this request
13
+ * opens an epoch (lib/ledger/plan.ts planRequest);
14
+ * - inside an epoch it cuts the messages the last request covered, so pi and
15
+ * every later `context` hook see only the tail, and the payload side puts
16
+ * the recorded list in front of what pi renders for it;
17
+ * - it publishes the plan on LEDGER_PLAN_CHANNEL for the renderer
18
+ * (system-reminder) and the payload side.
19
+ *
20
+ * Resume, `/reload`, `/fork` and `/tree` need no restore code: each folds a
21
+ * different branch. pi keeps a `/tree` move only in memory, so `session_tree`
22
+ * appends a `one-code:branch` record, which makes the selected leaf the one a
23
+ * reopened file resumes on.
24
+ */
25
+
26
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { sessionIdentity } from "../lib/anthropic-auth.ts";
28
+ import { foldSession } from "../lib/ledger/fold-memo.ts";
29
+ import { LEDGER_PLAN_CHANNEL, planRequest } from "../lib/ledger/plan.ts";
30
+ import { makeRecord, RECORD } from "../lib/ledger/records.ts";
31
+ import { extensionVersion } from "../lib/package-version.ts";
32
+
33
+ export default function ledgerExtension(pi: ExtensionAPI) {
34
+ const by = extensionVersion();
35
+
36
+ pi.on("session_tree", (event) => {
37
+ pi.appendEntry(RECORD.branch, makeRecord("branch", by, { fromLeafId: event.oldLeafId ?? "" }));
38
+ });
39
+
40
+ pi.on("context", (event, ctx) => {
41
+ const fold = foldSession(ctx.sessionManager, sessionIdentity(pi, ctx.model));
42
+ const { plan, tailStart } = planRequest(fold, event.messages as Array<{ role: string; timestamp?: number }>);
43
+ pi.events.emit(LEDGER_PLAN_CHANNEL, plan);
44
+ return tailStart > 0 ? { messages: event.messages.slice(tailStart) } : undefined;
45
+ });
46
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ledger-wire extension, the payload side of the ledger (extensions/ledger/index.ts
3
+ * has the request side and the design). Its `before_provider_request` hook
4
+ * runs after every hook that shapes the tail (system-reminder's layout,
5
+ * tool-search, context-management) and before cache-marks:
6
+ *
7
+ * 1. Inside an epoch it splices: the base request's recorded list, then pi's
8
+ * rendering of the tail, under the recorded system prompt and tools, with
9
+ * tools added mid-epoch appended (lib/ledger/wire.ts spliceRequest).
10
+ * 2. It settles the layout (lib/ledger/layout.ts: no system message stranded
11
+ * before a user message) and validates the request (lib/ledger/validate.ts): no reference to an
12
+ * undeclared tool, no system message stranded before a user message. A
13
+ * problem is a bug; it is dropped and reported, so it costs one block,
14
+ * never a 400. ONE_CODE_LEDGER_STRICT=1 sends it unrepaired instead, for
15
+ * the live probes to see.
16
+ * 3. It records the request as a delta against the fold's base (the request
17
+ * the fold rebuilds it on), with the pending items it carried and what its
18
+ * new blocks told the model, and publishes it (LEDGER_SENT_CHANNEL) for
19
+ * the side calls that replay it (compaction's summarizer, btw, recap,
20
+ * forks).
21
+ *
22
+ * A pi cache warm never records or publishes: it is not a request the model
23
+ * answers. pi sends it through the same hooks with the warm cap, under the
24
+ * plan of the request it warms, in a provider call of its own, so it resends
25
+ * that request as it went out (with the warm's cap), or after a `/reload` (no
26
+ * plan here: pi's context is the tail the old instance cut) the branch's last
27
+ * request. Every other payload follows a context pass: pi's retry runs one
28
+ * too, and a second payload in the call a request went out in is pi-ai's
29
+ * strict-tools fallback, a real re-send. The cap alone cannot tell them
30
+ * apart: pi-ai clamps a request's cap to the window's room, down to 1.
31
+ */
32
+
33
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
+ import { sessionIdentity } from "../lib/anthropic-auth.ts";
35
+ import { notifyOrPrint } from "../lib/headless-output.ts";
36
+ import { foldSession } from "../lib/ledger/fold-memo.ts";
37
+ import { LEDGER_PLAN_CHANNEL, LEDGER_SENT_CHANNEL, type LedgerSent, type RequestPlan } from "../lib/ledger/plan.ts";
38
+ import { makeRecord, RECORD, type ToldFact } from "../lib/ledger/records.ts";
39
+ import { deliveredAfterRepair, repairRequest, validateRequest } from "../lib/ledger/validate.ts";
40
+ import { settlePayload } from "../lib/ledger/layout.ts";
41
+ import { appendPoint, encodeRequest, hasWarmCap, listKey, type RecordedRequest, recordedBody, recordedRequest, samePrefix, spliceRequest, type WirePayload, withBranchToolOrder, withRequestFields } from "../lib/ledger/wire.ts";
42
+ import { type AffinityModel, affinityHeaders } from "../lib/request-replay.ts";
43
+ import { extensionVersion } from "../lib/package-version.ts";
44
+
45
+ export default function ledgerWireExtension(pi: ExtensionAPI) {
46
+ const by = extensionVersion();
47
+ /** The plan of the last `context` pass; a warm re-runs only this hook and reuses it. */
48
+ let plan: RequestPlan | undefined;
49
+ /**
50
+ * The provider call the hook runs in: pi resolves a call's headers once
51
+ * (`prepareRequest`), before its first payload, so `before_provider_headers`
52
+ * opens each call, a warm's included.
53
+ */
54
+ let call = 0;
55
+ /** The call a request under `plan` went out in: a payload with the warm cap in a later call is a warm, in the same call pi-ai's re-send. */
56
+ let sentIn: number | undefined;
57
+ /** What this hook recorded under `plan`: the request, and the hashes it stored, so a re-send stores only new bodies. */
58
+ let recorded: { request: RecordedRequest; hashes: Set<string> } | undefined;
59
+ /** The last session request this instance sent, as it left this hook: what a warm resends. */
60
+ let lastSent: WirePayload | undefined;
61
+ /** Problems already reported this session, so a recurring bug is one notice. */
62
+ const reported = new Set<string>();
63
+
64
+ pi.events.on(LEDGER_PLAN_CHANNEL, (data) => {
65
+ plan = data as RequestPlan;
66
+ sentIn = undefined;
67
+ recorded = undefined;
68
+ });
69
+ pi.on("session_start", () => {
70
+ plan = undefined;
71
+ sentIn = undefined;
72
+ recorded = undefined;
73
+ lastSent = undefined;
74
+ reported.clear();
75
+ });
76
+
77
+ /** The session the plan's request is routed for: the one that opened the epoch, recorded with its base. */
78
+ const routingOf = (current: RequestPlan | undefined, own: string) => (current && !current.epoch ? (current.base?.routing ?? own) : own);
79
+
80
+ // A /fork's first request extends its parent's record, and the parent's cache
81
+ // lives on the host the parent's session id routed to: a gateway such as
82
+ // OpenRouter picks it by the session-affinity headers, which pi-ai builds
83
+ // from the fork's own id (R3-M22). Not on Codex, where pi-ai sets its
84
+ // session-id header after every hook (findings §75). With no plan in this
85
+ // instance (a warm after a /reload) the call resends the branch's last
86
+ // request, so it goes where that request was routed.
87
+ pi.on("before_provider_headers", (event, ctx) => {
88
+ call++;
89
+ const own = ctx.sessionManager.getSessionId();
90
+ const identity = plan ? undefined : sessionIdentity(pi, ctx.model);
91
+ const routing = plan ? routingOf(plan, own) : ((identity && foldSession(ctx.sessionManager, identity).lastRequest?.routing) ?? own);
92
+ if (routing === own) return;
93
+ Object.assign(event.headers, affinityHeaders(ctx.model as AffinityModel | undefined, routing));
94
+ });
95
+
96
+ pi.on("before_provider_request", (event, ctx) => {
97
+ const model = ctx.model;
98
+ const identity = sessionIdentity(pi, model);
99
+ if (!model || !identity) return undefined;
100
+ const api = model.api;
101
+ const live = event.payload as WirePayload;
102
+ const current = plan;
103
+
104
+ // A cache warm: the request it warms, as sent, under the warm's cap. A
105
+ // payload in the call the request went out in is pi-ai sending it again.
106
+ if ((!current || (sentIn !== undefined && sentIn !== call)) && hasWarmCap(live)) {
107
+ if (lastSent) return withRequestFields(lastSent, live);
108
+ const base = foldSession(ctx.sessionManager, identity).lastRequest;
109
+ return base ? recordedBody(base, live, api) : undefined;
110
+ }
111
+ // No context pass in this instance and not a warm: leave pi's request alone.
112
+ if (!current) return undefined;
113
+
114
+ let payload = live;
115
+ const base = current.epoch ? undefined : current.base;
116
+ const end = base ? appendPoint(base.list, api) : 0;
117
+ if (base) payload = spliceRequest(base, payload, api);
118
+ // An epoch renders pi's tools again, in the order the branch first declared them.
119
+ else payload = withBranchToolOrder(payload, current.fold.toolOrder, api);
120
+ const spliced = payload;
121
+ // The final layout pass: no system message stranded before a user message (lib/ledger/layout.ts).
122
+ payload = settlePayload(payload, api, listKey(api), end);
123
+ let delivered: { consumed: string[]; facts: ToldFact[] } = { consumed: current.consumed, facts: current.carried.flatMap((block) => block.facts) };
124
+
125
+ const problems = validateRequest(payload, api);
126
+ if (problems.length > 0) {
127
+ const fresh = problems.map((problem) => JSON.stringify(problem)).filter((key) => !reported.has(key));
128
+ for (const key of fresh) reported.add(key);
129
+ if (fresh.length > 0) notifyOrPrint(ctx, `One Code: the request had ${problems.length} problem(s) the ledger ${process.env.ONE_CODE_LEDGER_STRICT === "1" ? "left in place" : "removed"}: ${fresh.join(", ")}`, "warning");
130
+ if (process.env.ONE_CODE_LEDGER_STRICT !== "1") {
131
+ payload = repairRequest(payload, api, problems);
132
+ delivered = deliveredAfterRepair(payload, api, delivered, current.fold.pending, new Set(current.base?.hashes ?? []), current.carried);
133
+ }
134
+ }
135
+
136
+ // The recorded prefix is known, unless the layout pass or a repair moved its elements.
137
+ const request = recordedRequest(payload, identity, base && samePrefix(spliced, payload, listKey(api), end) ? { base, end } : undefined);
138
+ lastSent = payload;
139
+ sentIn = call;
140
+ // The same request again under this plan (pi-ai re-sending an identical body) records nothing new.
141
+ if (recorded && encodeRequest(recorded.request, request, { has: () => true }) === undefined) return payload === live ? undefined : payload;
142
+ const known = { has: (hash: string) => current.fold.knownHashes.has(hash) || recorded?.hashes.has(hash) === true };
143
+ const delta = encodeRequest(current.fold.lastRequest, request, known);
144
+ if (delta) {
145
+ if (current.epoch) pi.appendEntry(RECORD.epoch, makeRecord("epoch", by, { cause: current.cause ?? "first" }));
146
+ const routing = routingOf(current, ctx.sessionManager.getSessionId());
147
+ pi.appendEntry(RECORD.request, makeRecord("request", by, { ...delta, covers: current.covers, consumed: delivered.consumed, routing }));
148
+ if (delivered.facts.length > 0) pi.appendEntry(RECORD.told, makeRecord("told", by, { facts: delivered.facts }));
149
+ recorded = { request, hashes: new Set([...(recorded?.hashes ?? []), ...Object.keys(delta.blobs)]) };
150
+ pi.events.emit(LEDGER_SENT_CHANNEL, { payload, model, sessionId: ctx.sessionManager.getSessionId(), plan: current } satisfies LedgerSent);
151
+ }
152
+ return payload === live ? undefined : payload;
153
+ });
154
+ }
@@ -38,10 +38,26 @@ export interface AgentCronResult {
38
38
  details?: Record<string, unknown>;
39
39
  }
40
40
 
41
+ /**
42
+ * The conversation a fire lands in: its branch to read, and entries to
43
+ * append to it. A no-prompt `/loop` sentinel records there what it already
44
+ * sent, so the record follows that conversation (its `/tree`, its resume,
45
+ * its own compaction), not the session that owns the job store.
46
+ */
47
+ export interface FireConversation {
48
+ getBranch(): readonly unknown[];
49
+ appendEntry(customType: string, data?: unknown): void;
50
+ }
51
+
41
52
  export interface AgentCronFire {
42
53
  agentId: string;
43
54
  jobId: string;
44
- prompt: string;
55
+ /**
56
+ * The text to send, resolved against the agent's own conversation (a
57
+ * sentinel reads and records its delivery there; undefined resolves as a
58
+ * conversation that was sent nothing). Call it once, for the send.
59
+ */
60
+ text(conversation: FireConversation | undefined): string;
45
61
  /** Set by the subagents extension when a live agent received it. */
46
62
  delivered?: boolean;
47
63
  }
@@ -114,8 +114,9 @@ export interface AgentLoaderOptions {
114
114
  * listener). A cached loader therefore broke the second child of the same
115
115
  * agent type, every SendMessage resume, every workflow agent after the first,
116
116
  * and any sibling still running when one was disposed
117
- * (LIFECYCLE-REVIEW-2026-09-06 H3, measured). A build costs ~3 ms once the
118
- * extension modules are in jiti's cache, so there is nothing to save.
117
+ * (LIFECYCLE-REVIEW-2026-09-06 H3, measured). A build costs ~3 ms once pi's
118
+ * factory cache holds the extensions for that directory (jiti itself runs
119
+ * with `moduleCache: false`, findings §8), so there is nothing to save.
119
120
  */
120
121
  export async function buildAgentLoader(options: AgentLoaderOptions): Promise<DefaultResourceLoader> {
121
122
  const loader = new DefaultResourceLoader({
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The login a request goes out under, and the rest of a request's identity
3
+ * (lib/ledger): every input that makes pi-ai build a differently shaped
4
+ * request. An Anthropic OAuth login (a Claude subscription) carries pi's
5
+ * identity betas and Claude Code's tool names; Sign in with ChatGPT drops
6
+ * OpenAI's per-message `configuration_update` items and the cache fields
7
+ * (pi-ai openai-responses.js isChatGPTSignIn); thinking turned off drops
8
+ * pi-ai's per-message effort markers on a model that takes them. A change
9
+ * of any of them opens an epoch, so recorded history is never replayed in a
10
+ * shape pi would not build.
11
+ */
12
+
13
+ import { readFileSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import type { RequestIdentity } from "./ledger/records.ts";
17
+
18
+ /** mtime cache: this runs on every provider request, the hottest path there is. */
19
+ let authCache: { mtimeMs: number; oauth: ReadonlySet<string> } | undefined;
20
+
21
+ /** The providers pi's auth.json holds an OAuth login for. */
22
+ function oauthProviders(): ReadonlySet<string> {
23
+ // getAgentDir() honours PI_CODING_AGENT_DIR, so an isolated one-code app
24
+ // reads its own auth.json rather than ~/.pi's.
25
+ const path = join(getAgentDir(), "auth.json");
26
+ try {
27
+ const mtimeMs = statSync(path).mtimeMs;
28
+ const cached = authCache;
29
+ if (cached && cached.mtimeMs === mtimeMs) return cached.oauth;
30
+ const auth = JSON.parse(readFileSync(path, "utf8")) as Record<string, { type?: unknown; refresh?: unknown } | undefined>;
31
+ const oauth = new Set(Object.entries(auth ?? {}).flatMap(([provider, entry]) => (entry?.type === "oauth" || typeof entry?.refresh === "string" ? [provider] : [])));
32
+ authCache = { mtimeMs, oauth };
33
+ return oauth;
34
+ } catch {
35
+ authCache = undefined;
36
+ return new Set();
37
+ }
38
+ }
39
+
40
+ /** True when pi's auth.json holds an OAuth login for Anthropic. */
41
+ export function isAnthropicOAuth(): boolean {
42
+ return oauthProviders().has("anthropic");
43
+ }
44
+
45
+ /** OpenAI's own endpoint, where pi-ai treats a login that is not an `sk-` key as Sign in with ChatGPT. */
46
+ const OPENAI_API = "https://api.openai.com/v1";
47
+
48
+ /**
49
+ * The auth kind a request to `provider` goes out with: "oauth" for an
50
+ * Anthropic subscription login, "chatgpt" for Sign in with ChatGPT on
51
+ * OpenAI's own endpoint, else "key".
52
+ */
53
+ export function authKind(provider: string | undefined, baseUrl?: string): "oauth" | "chatgpt" | "key" {
54
+ if (provider === "anthropic") return isAnthropicOAuth() ? "oauth" : "key";
55
+ if (provider === "openai" && baseUrl === OPENAI_API && oauthProviders().has("openai")) return "chatgpt";
56
+ return "key";
57
+ }
58
+
59
+ interface IdentityModel {
60
+ api?: string;
61
+ provider?: string;
62
+ id?: string;
63
+ baseUrl?: string;
64
+ reasoning?: boolean;
65
+ thinkingLevelMap?: Record<string, unknown>;
66
+ compat?: unknown;
67
+ }
68
+
69
+ /**
70
+ * Whether pi-ai puts its per-message effort markers in the request (an
71
+ * effort directive before each earlier reply, a Responses
72
+ * `configuration_update`): only on a model that takes them, and not while
73
+ * thinking is off there (pi-ai anthropic-messages.js effortMessages).
74
+ * Undefined on a model without them, or when the level is unknown.
75
+ */
76
+ function effortMarkers(model: IdentityModel, thinkingLevel: string | undefined): boolean | undefined {
77
+ if ((model.compat as { supportsMidConvoEffort?: boolean } | undefined)?.supportsMidConvoEffort !== true || thinkingLevel === undefined) return undefined;
78
+ return !(thinkingLevel === "off" && model.reasoning === true && model.thinkingLevelMap?.off !== null);
79
+ }
80
+
81
+ /**
82
+ * Who a request to `model` goes to and how pi-ai shapes it, as the ledger
83
+ * records it; a change opens an epoch. `thinkingLevel` is the session's
84
+ * (`pi.getThinkingLevel()`). Undefined without a full model.
85
+ */
86
+ export function requestIdentity(model: IdentityModel | undefined, thinkingLevel?: string): RequestIdentity | undefined {
87
+ if (!model?.api || !model.provider || !model.id) return undefined;
88
+ const markers = effortMarkers(model, thinkingLevel);
89
+ return { api: model.api, provider: model.provider, model: model.id, auth: authKind(model.provider, model.baseUrl), ...(markers !== undefined ? { effortMarkers: markers } : {}) };
90
+ }
91
+
92
+ /**
93
+ * The identity of a request this session sends to `model` now, under the
94
+ * session's thinking level (undefined where the API lacks it: a test double).
95
+ */
96
+ export function sessionIdentity(pi: { getThinkingLevel?: () => string }, model: IdentityModel | undefined): RequestIdentity | undefined {
97
+ return requestIdentity(model, typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined);
98
+ }
@@ -14,9 +14,19 @@
14
14
  * either: its tools are shared in from the parent as customTools
15
15
  * (lib/mcp-share.ts `watchMcpTools`), not reconnected.
16
16
  *
17
- * Order mirrors the package's load order: the reminder and deferral sinks
18
- * (system-reminder, tool-search) first, before anything emitting on their
19
- * channels (the bus does not replay). A caller that loads these must set the
17
+ * Order mirrors the package's load order: kept-thinking first (the first
18
+ * `context` hook, before anything reads the messages), the ledger's request
19
+ * side next (it cuts the request to its tail before anything injects), then
20
+ * the reminder and deferral sinks (system-reminder, tool-search), before
21
+ * anything emitting on their channels (the bus does not replay), then
22
+ * context-management and compaction as in the main session (a child's requests
23
+ * carry the same thinking edit, and a fork that crosses its threshold is
24
+ * summarized by replaying its own last request, not by pi's uncached default
25
+ * summarizer), then the ledger's payload side, after every hook that shapes
26
+ * the tail and before cache-marks. A child has its own event bus (pi's
27
+ * resource loader makes one per loader), so its captures never reach the
28
+ * parent's side calls. A fork's file is a copy of the parent's, records included, so
29
+ * its first request extends the parent's last one and reads its cache. A caller that loads these must set the
20
30
  * loader's `noContextFiles`, because claude-context injects `# claudeMd` itself.
21
31
  */
22
32
 
@@ -34,7 +44,7 @@ const EXTENSIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
34
44
  // (mode "print"), so `run_in_background` runs blocking there and nothing a
35
45
  // child starts outlives it (Claude Code kills an agent's shells as it exits).
36
46
  export const CHILD_EXTENSIONS = [
37
- "system-reminder", "tool-search", "claude-context", "tool-call-corruption/child",
47
+ "kept-thinking", "ledger", "system-reminder", "tool-search", "context-management", "compaction", "ledger/wire", "cache-marks", "claude-context", "tool-call-corruption/child",
38
48
  "file-tracker", "search-tools", "skill", "bash/child", "powershell/child", "web", "web-fetch", "notebook",
39
49
  ];
40
50
 
@@ -1,51 +1,65 @@
1
- /** Comparisons for the historical context stack. No pi imports or live-state reads. */
2
- import { createHash } from "node:crypto";
3
- import { buildClaudeMdBlock, buildContextBlock, type ContextFile } from "./claude-context.ts";
4
- import type { ReminderEntry } from "./reminders.ts";
1
+ /**
2
+ * What claude-context's message-1 blocks tell, as ledger facts, and the notice
3
+ * for a resumed session whose workspace changed since (pure). The blocks are
4
+ * live state; an epoch records their fingerprints with its request, so the
5
+ * first request of a resumed process compares them with the live files and git
6
+ * status, and says what changed once. The historical blocks stay as sent.
7
+ */
8
+ import { buildClaudeMdBlock, type ContextFile } from "./claude-context.ts";
9
+ import type { ToldFact } from "./ledger/records.ts";
10
+ import { contentHash } from "./ledger/wire.ts";
11
+ import { toldBlockFact } from "./reminders.ts";
5
12
 
6
- export const CONTEXT_FACT_KEYS = ["claude-context", "one-code-context", "claude-context-context", "claude-context-date"] as const;
7
- export const RESUME_FACTS_KEY = "context-resume-changes";
8
- export const DATE_CHANGE_KEY = "claude-context-date-change";
13
+ /** The told keys of the fingerprints (whole-text block facts) and of the compaction flag (a mode). */
14
+ export const FACT_KEYS = {
15
+ instructions: "context-facts:instructions",
16
+ memory: "context-facts:memory",
17
+ git: "context-facts:git",
18
+ date: "context-facts:date",
19
+ /** On when the epoch's blocks were taken at a compaction, not at the session's start. */
20
+ compaction: "context-facts:compaction",
21
+ } as const;
9
22
 
10
- export interface ContextFactsBaseline {
23
+ /** A told block's hash (`toldBlockFact`): for a string or null it is the sha256 of its JSON. */
24
+ const fingerprint = contentHash;
25
+
26
+ /** Fingerprints of only what the model was shown, not unshown file tails or filesystem mtimes. */
27
+ export interface ContextFingerprints {
11
28
  instructions: string;
12
29
  memory: string;
13
- git: string;
14
- /** A compaction starts a new snapshot epoch, not a new conversation. */
15
- atCompaction: boolean;
16
- /**
17
- * The directory the facts were read in: the session's cwd, or the entered
18
- * worktree at a compaction. A resume compares in the same one. Absent in
19
- * older snapshots.
20
- */
21
- cwd?: string;
22
30
  }
23
31
 
24
- const fingerprint = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("hex");
25
-
26
- /** Hash only what the model was shown, not unshown file tails or filesystem mtimes. */
27
- export function contextFactsBaseline(input: {
28
- contextFiles: ContextFile[];
29
- memoryIndex: { path: string; content: string } | null;
30
- gitStatus: string | null;
31
- atCompaction: boolean;
32
- cwd?: string;
33
- }): ContextFactsBaseline {
32
+ export function contextFingerprints(input: { contextFiles: ContextFile[]; memoryIndex: { path: string; content: string } | null }): ContextFingerprints {
34
33
  return {
35
34
  instructions: fingerprint(buildClaudeMdBlock({ contextFiles: input.contextFiles })),
36
35
  memory: fingerprint(buildClaudeMdBlock({ contextFiles: [], memoryIndex: input.memoryIndex })),
37
- git: fingerprint(input.gitStatus),
38
- atCompaction: input.atCompaction,
39
- ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
40
36
  };
41
37
  }
42
38
 
43
- export function storedFactsBaseline(value: unknown): ContextFactsBaseline | undefined {
44
- if (!value || typeof value !== "object") return undefined;
45
- const v = value as Record<string, unknown>;
46
- if (![v.instructions, v.memory, v.git].every((part) => typeof part === "string" && /^[a-f0-9]{64}$/.test(part)) || typeof v.atCompaction !== "boolean") return undefined;
47
- const { cwd, ...facts } = v;
48
- return { ...facts, ...(typeof cwd === "string" ? { cwd } : {}) } as unknown as ContextFactsBaseline;
39
+ export const gitFingerprint = (gitStatus: string | null): string => fingerprint(gitStatus);
40
+
41
+ /** The live workspace's fingerprints for the resumed-session check: the instructions, the memory index and, when a git snapshot is taken, the git status. */
42
+ export type LiveFingerprints = ContextFingerprints & { git?: string };
43
+
44
+ export function liveFingerprints(files: Parameters<typeof contextFingerprints>[0], gitStatus: string | null, withGit: boolean): LiveFingerprints {
45
+ return { ...contextFingerprints(files), ...(withGit ? { git: gitFingerprint(gitStatus) } : {}) };
46
+ }
47
+ export const dateFingerprint = (date: string): string => fingerprint(date);
48
+
49
+ /** The facts the instructions block tells. */
50
+ export function instructionFacts(prints: ContextFingerprints): ToldFact[] {
51
+ return [
52
+ { kind: "block", key: FACT_KEYS.instructions, hash: prints.instructions },
53
+ { kind: "block", key: FACT_KEYS.memory, hash: prints.memory },
54
+ ];
55
+ }
56
+
57
+ /** The facts the context block tells: its git snapshot, and whether a compaction took it. */
58
+ export function contextBlockFacts(gitStatus: string | null, atCompaction: boolean): ToldFact[] {
59
+ return [
60
+ toldBlockFact(FACT_KEYS.git, gitStatus),
61
+ { kind: "mode", key: FACT_KEYS.compaction, on: atCompaction },
62
+ ];
49
63
  }
50
64
 
51
65
  /** A refreshed git snapshot must not describe itself as the conversation's initial status. */
@@ -53,26 +67,31 @@ export function compactionGitStatus(text: string | null): string | null {
53
67
  return text?.replace("This is the git status at the start of the conversation.", "This is the git status at the latest compaction.") ?? null;
54
68
  }
55
69
 
56
- /** One short factual notice; the historical blocks themselves remain byte-identical. */
57
- export function resumeFactsNotice(before: ContextFactsBaseline, current: ContextFactsBaseline): string | undefined {
70
+ /**
71
+ * One short factual notice when the live workspace differs from what the
72
+ * epoch's blocks told, and the facts that bring the told state up to date.
73
+ * Undefined when nothing the epoch told has changed, or the epoch told none
74
+ * of it (its blocks predate the facts).
75
+ */
76
+ export function resumeFactsNotice(
77
+ told: ReadonlyMap<string, string>,
78
+ atCompaction: boolean,
79
+ live: ContextFingerprints & { git?: string },
80
+ ): { text: string; facts: ToldFact[] } | undefined {
58
81
  const changed: string[] = [];
59
- if (before.git !== current.git) changed.push("git status changed");
60
- if (before.memory !== current.memory) changed.push("the memory index changed");
61
- if (before.instructions !== current.instructions) changed.push("CLAUDE.md-family instructions changed");
82
+ const facts: ToldFact[] = [];
83
+ const compare = (key: string, hash: string | undefined, what: string) => {
84
+ const before = told.get(key);
85
+ if (hash === undefined || before === undefined || before === hash) return;
86
+ changed.push(what);
87
+ facts.push({ kind: "block", key, hash });
88
+ };
89
+ compare(FACT_KEYS.git, live.git, "git status changed");
90
+ compare(FACT_KEYS.memory, live.memory, "the memory index changed");
91
+ compare(FACT_KEYS.instructions, live.instructions, "CLAUDE.md-family instructions changed");
62
92
  if (changed.length === 0) return undefined;
63
- return `Since the saved context snapshot, ${changed.join("; ")}. The earlier context block reflects ${before.atCompaction ? "the latest compaction" : "the session's start"}, not the current workspace. Read the current files or run git status if needed.`;
64
- }
65
-
66
- /** Old snapshots lack separate fingerprints: do not invent which half of # claudeMd changed. */
67
- export function legacyResumeFactsNotice(stack: readonly ReminderEntry[], live: {
68
- instructions: string | null;
69
- email: string | null;
70
- gitStatus: string | null;
71
- }): string | undefined {
72
- const changed: string[] = [];
73
- const previousContext = stack.find((entry) => entry.key === "claude-context-context")?.text ?? null;
74
- const gitPart = (text: string | null) => text?.match(/# gitStatus\n[\s\S]*?(?=\n\nOne Code attached this context automatically;|$)/)?.[0] ?? null;
75
- if (gitPart(previousContext) !== gitPart(buildContextBlock(live))) changed.push("git status changed");
76
- if ((stack.find((entry) => entry.key === "claude-context")?.text ?? null) !== live.instructions) changed.push("CLAUDE.md-family instructions or the memory index changed");
77
- return changed.length ? `Since the saved context snapshot, ${changed.join("; ")}. The earlier context block reflects the session's start, not the current workspace. Read the current files or run git status if needed.` : undefined;
93
+ return {
94
+ text: `Since the saved context snapshot, ${changed.join("; ")}. The earlier context block reflects ${atCompaction ? "the latest compaction" : "the session's start"}, not the current workspace. Read the current files or run git status if needed.`,
95
+ facts,
96
+ };
78
97
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The whole conversation each request covers, as pi built it, for compaction's
3
+ * summarizer. (btw's and recap's standalone fallbacks read the current branch
4
+ * at call time instead: lib/side-call.ts `branchMessages`.) Inside an epoch
5
+ * the ledger (extensions/ledger) cuts a request to its tail before every later
6
+ * `context` hook, so the full list comes with the request's plan instead.
7
+ */
8
+
9
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { LEDGER_PLAN_CHANNEL, type RequestPlan } from "./ledger/plan.ts";
12
+
13
+ /** Call `onRequest` at each request's `context` pass with every message the request covers. */
14
+ export function followCoveredMessages(pi: ExtensionAPI, onRequest: (messages: AgentMessage[], ctx: ExtensionContext) => void): void {
15
+ let planned: readonly AgentMessage[] | undefined;
16
+ pi.events.on(LEDGER_PLAN_CHANNEL, (data) => {
17
+ planned = (data as RequestPlan).messages as readonly AgentMessage[];
18
+ });
19
+ pi.on("context", (event, ctx) => {
20
+ onRequest([...(planned ?? event.messages)], ctx);
21
+ planned = undefined;
22
+ });
23
+ }