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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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
|
-
|
|
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
|
|
118
|
-
*
|
|
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:
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
export const
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|