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
|
@@ -1,90 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* System
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* System reminders: the steering blocks One Code adds to a request, rendered
|
|
3
|
+
* once and then kept as sent (working-docs/features/lifecycle-grid-hunt/design.md
|
|
4
|
+
* section 5). The ledger (extensions/ledger) sends through pi only the
|
|
5
|
+
* messages after the last request's coverage, the tail, and takes everything
|
|
6
|
+
* above it from that request's record. So a reminder is rendered only onto the
|
|
7
|
+
* tail, rides the request that first carried it, and stays in the record
|
|
8
|
+
* byte for byte; nothing here is restored or re-rendered later.
|
|
6
9
|
*
|
|
7
10
|
* Cross-extension contract: emit on the event bus channel
|
|
8
|
-
* `one-code:system-reminder` with `{ text,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* `one-code:system-reminder` with `{ text, placement? }`. The live
|
|
12
|
+
* state lives in the system-reminder extension; do not import one from here
|
|
13
|
+
* (jiti gives every extension its own module instance, so a shared export
|
|
14
|
+
* steers nothing).
|
|
12
15
|
*
|
|
13
|
-
*
|
|
16
|
+
* Four placements, chosen by what kind of fact the reminder is:
|
|
14
17
|
*
|
|
15
|
-
* - `first-prepend
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* - `user-prepend` — LOCAL-COMMAND BREADCRUMBS (`/clear`, `/model`, a panel
|
|
44
|
-
* command): what Claude Code shows the model when the user ran a slash
|
|
45
|
-
* command. Bare text blocks placed BEFORE the user's text on the next user
|
|
46
|
-
* message that opens a request (after the `first-prepend` stack on message
|
|
47
|
-
* one), then pinned there like a delivered one-shot. They never ride a tool
|
|
48
|
-
* result — a command run mid-turn waits for the next prompt. The caveat
|
|
49
|
-
* block is queued `once` per pending run (a second command joins the first
|
|
50
|
-
* caveat); the command and stdout blocks are never collapsed.
|
|
51
|
-
* `lib/local-command.ts` builds the blocks.
|
|
18
|
+
* - `first-prepend`: SESSION CONTEXT (environment, deferred tools, agent
|
|
19
|
+
* catalog, MCP instructions, skills, `# claudeMd`). Live state, kept current
|
|
20
|
+
* by its owner; rendered only on a request that opens an epoch, in front of
|
|
21
|
+
* the first user message and ordered by `order`: Claude Code's first-message
|
|
22
|
+
* stack. Inside an epoch message 1 is the record, so a change is not
|
|
23
|
+
* rendered; its owner announces the difference at render time
|
|
24
|
+
* (RENDER_CHANNEL: live minus what the epoch told).
|
|
25
|
+
* - `sticky-append`: SESSION STATE that switches on and off (auto mode, plan
|
|
26
|
+
* mode, a worktree session, ultracode). Appended to every user message of
|
|
27
|
+
* the tail stamped at or after the state switched on (`since`). Switched on
|
|
28
|
+
* mid-turn, it first rides the switching call's result (`toolCallId`), or
|
|
29
|
+
* the first result after the switch, never the already-sent turn opener.
|
|
30
|
+
* Removing it closes it: later messages stop carrying it, and the ones
|
|
31
|
+
* already sent keep it in the record.
|
|
32
|
+
* - `last-append`: ONE-SHOT steering about what just happened (a deferred
|
|
33
|
+
* tool miss, a file changed under the model, a mode change). Persisted when
|
|
34
|
+
* emitted as a `one-code:pending` entry, so it survives `/reload` and quit
|
|
35
|
+
* and is gone with an abandoned `/tree` branch; delivered on the tail of the
|
|
36
|
+
* next request (the call's own result when `toolCallId` names one in the
|
|
37
|
+
* tail, the prompt's own message when `promptTimestamp` names one, else
|
|
38
|
+
* the trailing tool result, else the last user message), and
|
|
39
|
+
* recorded as consumed by that request.
|
|
40
|
+
* - `user-prepend`: LOCAL-COMMAND BREADCRUMBS (`/clear`, `/model`, a panel
|
|
41
|
+
* command): pending like a one-shot, but placed only before the text of a
|
|
42
|
+
* prompt that opens a request; mid-turn it waits. The caveat block is
|
|
43
|
+
* queued `once` per run (a second command joins the first caveat); the
|
|
44
|
+
* command and stdout blocks are never collapsed. `lib/local-command.ts`
|
|
45
|
+
* builds them.
|
|
52
46
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* `pi.extensions`, like `context-budget`) or that emits from `tool_call` gets
|
|
58
|
-
* its block written into the result. Emitting from a later `tool_result`
|
|
59
|
-
* handler, or from `tool_execution_end` (pi runs it AFTER the `tool_result`
|
|
60
|
-
* hooks, findings §3), lands the block on the same result but as a pin. Same
|
|
61
|
-
* wording on the wire, so this only matters for `--resume` and for ordering
|
|
62
|
-
* after the `<total_tokens>` line.
|
|
63
|
-
*
|
|
64
|
-
* A compaction summary counts as a user message for anchoring (pi renders it as
|
|
65
|
-
* one), so the context stack survives a compaction that left no user turn. So
|
|
66
|
-
* does a `custom` harness message (task notification, wakeup, hook context):
|
|
67
|
-
* pi sends it as a user message, and a turn it opens has no other user turn.
|
|
68
|
-
*
|
|
69
|
-
* Retry safety falls out of this: pi re-runs the `context` transform per LLM
|
|
70
|
-
* attempt, and both a persisted and a pinned one-shot are present on every
|
|
71
|
-
* attempt by construction.
|
|
47
|
+
* A compaction summary counts as a user message for the stack (pi renders it
|
|
48
|
+
* as one), and so does a `custom` harness message (task notification, wakeup,
|
|
49
|
+
* hook context): pi sends it as a user message, and a turn it opens has no
|
|
50
|
+
* other user turn.
|
|
72
51
|
*/
|
|
73
52
|
|
|
74
53
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
75
54
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
55
|
+
import type { BlockFact, FileVersion, ToldFact } from "./ledger/records.ts";
|
|
56
|
+
import { contentHash } from "./ledger/wire.ts";
|
|
57
|
+
import type { MovedBlock } from "./system-role.ts";
|
|
76
58
|
|
|
77
59
|
export const REMINDER_CHANNEL = "one-code:system-reminder";
|
|
78
60
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Emitted by the system-reminder extension once per request, while it renders
|
|
63
|
+
* the tail (a `RenderRequest`). An owner of a capability block subscribes and
|
|
64
|
+
* adds the blocks this request needs from live state and what the epoch has
|
|
65
|
+
* told: on a request inside an epoch, the names live now that the epoch has
|
|
66
|
+
* not told yet. Computed when the request goes out, never when a change is
|
|
67
|
+
* queued, so a capability that came and went before the request is never
|
|
68
|
+
* announced.
|
|
69
|
+
*/
|
|
70
|
+
export const RENDER_CHANNEL = "one-code:render";
|
|
81
71
|
|
|
82
72
|
/** See the module header for which placement fits which kind of reminder. */
|
|
83
73
|
export type ReminderPlacement = "first-prepend" | "sticky-append" | "last-append" | "user-prepend";
|
|
84
74
|
|
|
85
|
-
/** Where a delivered one-shot stays: a tool result (by call id) or a user turn (by timestamp). */
|
|
86
|
-
export type PinAnchor = { kind: "toolResult"; toolCallId: string } | { kind: "user"; timestamp: number };
|
|
87
|
-
|
|
88
75
|
/**
|
|
89
76
|
* `order` values for the `first-prepend` context stack, matching Claude Code's
|
|
90
77
|
* fixed sequence on the first user message: environment → model line → deferred
|
|
@@ -133,533 +120,337 @@ export function movesToSystemRole(order: number): boolean {
|
|
|
133
120
|
return order < CONTEXT_ORDER.claudeMd || order === CONTEXT_ORDER.date;
|
|
134
121
|
}
|
|
135
122
|
|
|
136
|
-
/** A drained reminder with everything the injector needs to place it. */
|
|
137
|
-
export interface ReminderEntry {
|
|
138
|
-
/** Present in session snapshots; not rendered into the request. */
|
|
139
|
-
key?: string;
|
|
140
|
-
text: string;
|
|
141
|
-
placement: ReminderPlacement;
|
|
142
|
-
order: number;
|
|
143
|
-
/**
|
|
144
|
-
* Literal text appended AFTER the closing `</system-reminder>` tag. Claude
|
|
145
|
-
* Code's `# claudeMd` block ends `</system-reminder>\n\n` on the wire; this
|
|
146
|
-
* reproduces that byte-for-byte. Defaults to "".
|
|
147
|
-
*/
|
|
148
|
-
suffix?: string;
|
|
149
|
-
/**
|
|
150
|
-
* `sticky-append` only: when the state switched on. Every user message
|
|
151
|
-
* stamped at or after it carries the block while open. Closing freezes the
|
|
152
|
-
* actual carriers in `userPins`; legacy snapshots use `until` as the end.
|
|
153
|
-
*/
|
|
154
|
-
since?: number;
|
|
155
|
-
/** `sticky-append` only: exclusive end of a closed lifetime; absent while active. */
|
|
156
|
-
until?: number;
|
|
157
|
-
/** Actual user carriers of the last request; frozen on close, so queued input cannot acquire old state. */
|
|
158
|
-
userPins?: number[];
|
|
159
|
-
/** Pending sticky activation belongs to this call, not another result in its parallel batch. */
|
|
160
|
-
toolCallId?: string;
|
|
161
|
-
/**
|
|
162
|
-
* Legacy `sticky-append` anchor from older snapshots. Honored to keep their
|
|
163
|
-
* bytes stable on resume; new lifetimes use null, never an earlier opener.
|
|
164
|
-
*/
|
|
165
|
-
opener?: number | null;
|
|
166
|
-
/**
|
|
167
|
-
* `sticky-append` only: its first mid-turn tool result, or the tail of a
|
|
168
|
-
* request without any user carrier (overflow compaction). Once chosen it
|
|
169
|
-
* stays on that message, even after later user turns arrive or state closes.
|
|
170
|
-
*/
|
|
171
|
-
tailPin?: PinAnchor;
|
|
172
|
-
/** Set on a pinned one-shot: the exact message it rides on every request. */
|
|
173
|
-
pin?: PinAnchor;
|
|
174
|
-
/**
|
|
175
|
-
* Emit `text` as-is instead of inside a `<system-reminder>` frame — for the
|
|
176
|
-
* few Claude Code blocks that ride bare, like `<total_tokens>` after a tool
|
|
177
|
-
* result. Defaults to false.
|
|
178
|
-
*/
|
|
179
|
-
raw?: boolean;
|
|
180
|
-
/**
|
|
181
|
-
* `first-prepend` only: sent only inside the mid-conversation system
|
|
182
|
-
* message, never on the user message of a model without one (Claude Code's
|
|
183
|
-
* auto-mode note).
|
|
184
|
-
*/
|
|
185
|
-
systemRoleOnly?: boolean;
|
|
186
|
-
/**
|
|
187
|
-
* `sticky-append` only: never on the message that carries the context
|
|
188
|
-
* stack, which already says the same (the first prompt's `<total_tokens>`).
|
|
189
|
-
*/
|
|
190
|
-
skipStackCarrier?: boolean;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
123
|
export interface ReminderPayload {
|
|
194
124
|
text?: string;
|
|
195
|
-
|
|
196
|
-
/** Key so a reminder can be replaced (next-turn) or replaced/removed (every-turn). */
|
|
125
|
+
/** Key so a reminder can be replaced (a pending one-shot, a live block) or removed (a live block). */
|
|
197
126
|
key?: string;
|
|
198
|
-
/**
|
|
127
|
+
/** Remove the live block or close the sticky state under `key`. */
|
|
199
128
|
remove?: boolean;
|
|
200
129
|
/** Where in the message stack this reminder lands. Defaults to `last-append`. */
|
|
201
130
|
placement?: ReminderPlacement;
|
|
202
131
|
/** Sort key among `first-prepend` reminders (Claude Code order). Defaults to 0. */
|
|
203
132
|
order?: number;
|
|
204
|
-
/** Literal text appended after the closing `</system-reminder>` tag. Defaults to "". */
|
|
205
|
-
suffix?: string;
|
|
206
133
|
/** Emit the text bare, with no `<system-reminder>` frame. */
|
|
207
134
|
raw?: boolean;
|
|
208
|
-
/** `first-prepend` only: sent only inside the mid-conversation system message
|
|
135
|
+
/** `first-prepend` only: sent only inside the mid-conversation system message, never on a user message. */
|
|
209
136
|
systemRoleOnly?: boolean;
|
|
210
|
-
/** `sticky-append` only: never on the message that carries the context stack
|
|
137
|
+
/** `sticky-append` only: never on the message that carries the context stack, which already says the same. */
|
|
211
138
|
skipStackCarrier?: boolean;
|
|
212
|
-
/** `user-prepend` only:
|
|
139
|
+
/** `user-prepend` only: placed once per request, however many are pending (the shared caveat). */
|
|
213
140
|
once?: boolean;
|
|
141
|
+
/** `user-prepend` only: on a request that does not open with a prompt (mid-turn, an overflow retry), the tail carries it instead. */
|
|
142
|
+
orTail?: boolean;
|
|
143
|
+
/** A one-shot dropped, undelivered, at a later compaction (a post-compaction restore). */
|
|
144
|
+
untilCompaction?: boolean;
|
|
145
|
+
/** A one-shot only: the files whose whole content it shows, recorded on its pending entry (`PendingRecordData.observations`). */
|
|
146
|
+
observations?: FileVersion[];
|
|
147
|
+
/** A one-shot only: the changed file versions it names, recorded on its pending entry (`PendingRecordData.notified`). */
|
|
148
|
+
notified?: FileVersion[];
|
|
214
149
|
/**
|
|
215
|
-
* `sticky-append` only:
|
|
216
|
-
* `0` puts it on every user message
|
|
217
|
-
*
|
|
150
|
+
* `sticky-append` only: the state is on from this timestamp instead of now.
|
|
151
|
+
* `0` puts it on every user message (the `<total_tokens>` line rides every
|
|
152
|
+
* user message in Claude Code).
|
|
218
153
|
*/
|
|
219
154
|
since?: number;
|
|
220
155
|
/**
|
|
221
|
-
* The tool call whose result this reminder belongs to.
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* countdown to another call in a parallel batch.
|
|
156
|
+
* The tool call whose result this reminder belongs to. A one-shot rides that
|
|
157
|
+
* result when the request carries it; a sticky state rides it first. Both
|
|
158
|
+
* avoid attaching a switch or countdown to another call in a parallel batch.
|
|
225
159
|
*/
|
|
226
160
|
toolCallId?: string;
|
|
161
|
+
/**
|
|
162
|
+
* A one-shot only: the timestamp of the user message it belongs to (a
|
|
163
|
+
* prompt's hook context). It rides that message when the request carries
|
|
164
|
+
* it, so a prompt whose request failed keeps its own context when the
|
|
165
|
+
* next prompt resends it.
|
|
166
|
+
*/
|
|
167
|
+
promptTimestamp?: number;
|
|
168
|
+
/** `first-prepend` only: what the block tells the model, recorded when an epoch sends it. */
|
|
169
|
+
facts?: ToldFact[];
|
|
227
170
|
}
|
|
228
171
|
|
|
229
|
-
|
|
230
|
-
|
|
172
|
+
/** The fact that the whole-text block `key` told `value` (a text, or a snapshot such as a git status). */
|
|
173
|
+
export function toldBlockFact(key: string, value: unknown): BlockFact {
|
|
174
|
+
return { kind: "block", key, hash: contentHash(value) };
|
|
231
175
|
}
|
|
232
176
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
key
|
|
236
|
-
|
|
237
|
-
order
|
|
238
|
-
suffix?: string;
|
|
177
|
+
/** A live `first-prepend` block. */
|
|
178
|
+
export interface StackEntry {
|
|
179
|
+
key: string;
|
|
180
|
+
text: string;
|
|
181
|
+
order: number;
|
|
239
182
|
raw?: boolean;
|
|
240
183
|
systemRoleOnly?: boolean;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
since?: number;
|
|
244
|
-
/** `user-prepend` only: skip when the same text is already pending. */
|
|
245
|
-
once?: boolean;
|
|
246
|
-
/** The switching tool result, or the hook that takes a one-shot (see ReminderPayload). */
|
|
247
|
-
toolCallId?: string;
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
export class ReminderQueue {
|
|
251
|
-
private nextTurn: StoredReminder[] = [];
|
|
252
|
-
/** Delivered one-shots, each fixed to the message it first rode (see `pin`). */
|
|
253
|
-
private pinned: StoredReminder[] = [];
|
|
254
|
-
private everyTurn = new Map<string, StoredReminder>();
|
|
255
|
-
/** Open and closed lifetimes in original insertion order, independent of live keys. */
|
|
256
|
-
private sticky: StoredReminder[] = [];
|
|
257
|
-
private readonly now: () => number;
|
|
258
|
-
/** Only restored keys are protected; fresh sessions retain their existing emitter behavior. */
|
|
259
|
-
private restoredKeys = new Set<string>();
|
|
260
|
-
private pendingCapabilities = new Set<string>();
|
|
261
|
-
private changes = 0;
|
|
262
|
-
|
|
263
|
-
constructor(now: () => number = Date.now) {
|
|
264
|
-
this.now = now;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Moves on every change to what the queue holds (a drain only when it
|
|
269
|
-
* re-anchored, pinned or dropped something), so the owner can skip
|
|
270
|
-
* serializing a snapshot that cannot have changed.
|
|
271
|
-
*/
|
|
272
|
-
get version(): number {
|
|
273
|
-
return this.changes;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
enqueue(text: string, opts?: EnqueueOptions): void {
|
|
277
|
-
if (!text.trim()) return;
|
|
278
|
-
const placement = opts?.placement ?? "last-append";
|
|
279
|
-
if (placement === "first-prepend" && opts?.key) {
|
|
280
|
-
if (this.restoredKeys.has(opts.key)) return;
|
|
281
|
-
this.pendingCapabilities.delete(opts.key);
|
|
282
|
-
}
|
|
283
|
-
const entry: StoredReminder = {
|
|
284
|
-
text,
|
|
285
|
-
placement,
|
|
286
|
-
order: opts?.order ?? 0,
|
|
287
|
-
suffix: opts?.suffix,
|
|
288
|
-
key: opts?.key,
|
|
289
|
-
raw: opts?.raw,
|
|
290
|
-
toolCallId: opts?.toolCallId,
|
|
291
|
-
};
|
|
292
|
-
if (opts?.systemRoleOnly) entry.systemRoleOnly = true;
|
|
293
|
-
if (opts?.skipStackCarrier) entry.skipStackCarrier = true;
|
|
294
|
-
const key = opts?.key ?? text;
|
|
295
|
-
const previous = this.everyTurn.get(key);
|
|
296
|
-
if (placement === "sticky-append") {
|
|
297
|
-
// Re-emitting an unchanged standing fact keeps both its anchors and its
|
|
298
|
-
// position among other blocks. A changed fact opens a new lifetime.
|
|
299
|
-
const same = previous?.placement === placement && previous.text === text &&
|
|
300
|
-
previous.suffix === entry.suffix && previous.raw === entry.raw &&
|
|
301
|
-
previous.skipStackCarrier === entry.skipStackCarrier && previous.order === entry.order ? previous : undefined;
|
|
302
|
-
entry.since = opts?.since ?? same?.since ?? this.now();
|
|
303
|
-
entry.userPins = [];
|
|
304
|
-
if (same && same.since === entry.since && opts?.scope === "every-turn") return;
|
|
305
|
-
}
|
|
306
|
-
this.changes++;
|
|
307
|
-
if (opts?.scope === "every-turn") {
|
|
308
|
-
if (previous?.placement === "sticky-append") this.remove(key);
|
|
309
|
-
this.everyTurn.set(key, entry);
|
|
310
|
-
if (placement === "sticky-append") {
|
|
311
|
-
entry.key = key;
|
|
312
|
-
this.sticky.push(entry);
|
|
313
|
-
}
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
// A breadcrumb run shares one caveat block (`once`): the same text pending
|
|
317
|
-
// twice would show the model two identical blocks in a row. Only the
|
|
318
|
-
// announcer's caveat asks for this — two commands with the same (empty)
|
|
319
|
-
// stdout are two commands, and both stay.
|
|
320
|
-
if (placement === "user-prepend" && opts?.once && this.nextTurn.some((r) => r.placement === placement && r.text === text)) return;
|
|
321
|
-
// A keyed next-turn reminder replaces its predecessor, so a rapidly
|
|
322
|
-
// re-emitted state change (cycling permission modes) announces only
|
|
323
|
-
// where it settled.
|
|
324
|
-
if (opts?.key) this.nextTurn = this.nextTurn.filter((r) => r.key !== opts.key);
|
|
325
|
-
this.nextTurn.push(entry);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
remove(key: string): void {
|
|
329
|
-
this.changes++;
|
|
330
|
-
if (!this.restoredKeys.has(key)) {
|
|
331
|
-
const entry = this.everyTurn.get(key);
|
|
332
|
-
if (entry?.placement === "sticky-append") entry.until = this.now();
|
|
333
|
-
this.everyTurn.delete(key);
|
|
334
|
-
}
|
|
335
|
-
this.pendingCapabilities.delete(key);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Seed before startup emitters. Earlier hooks (context-budget) may already have emitted. */
|
|
339
|
-
restore(stack: readonly ReminderEntry[], sticky: readonly ReminderEntry[], frozenKeys: ReadonlySet<string>, liveKeys: ReadonlySet<string> = new Set(), pinned: readonly ReminderEntry[] = []): void {
|
|
340
|
-
this.changes++;
|
|
341
|
-
const early = [...this.everyTurn.values()];
|
|
342
|
-
this.everyTurn.clear();
|
|
343
|
-
this.sticky = [];
|
|
344
|
-
this.pinned = structuredClone([...pinned]);
|
|
345
|
-
this.restoredKeys = new Set(frozenKeys);
|
|
346
|
-
this.pendingCapabilities = new Set(stack.flatMap((entry) => entry.key && liveKeys.has(entry.key) ? [entry.key] : []));
|
|
347
|
-
for (const source of [...stack, ...sticky]) {
|
|
348
|
-
const entry = structuredClone(source);
|
|
349
|
-
if (entry.placement === "sticky-append") this.sticky.push(entry);
|
|
350
|
-
if (entry.until === undefined) this.everyTurn.set(entry.key ?? entry.text, entry);
|
|
351
|
-
}
|
|
352
|
-
for (const entry of early) this.enqueue(entry.text, { ...entry, scope: "every-turn" });
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* A session start with no snapshot (`/clear` after a resume): the previous
|
|
357
|
-
* session's resume locks must not keep the new session's emitters out.
|
|
358
|
-
*/
|
|
359
|
-
releaseRestore(): void {
|
|
360
|
-
this.restoredKeys.clear();
|
|
361
|
-
this.pendingCapabilities.clear();
|
|
362
|
-
}
|
|
184
|
+
facts?: ToldFact[];
|
|
185
|
+
}
|
|
363
186
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
187
|
+
/** A live `sticky-append` state. */
|
|
188
|
+
export interface StickyEntry {
|
|
189
|
+
key: string;
|
|
190
|
+
text: string;
|
|
191
|
+
raw?: boolean;
|
|
192
|
+
/** When it switched on: user messages stamped at or after it carry it. */
|
|
193
|
+
since: number;
|
|
194
|
+
toolCallId?: string;
|
|
195
|
+
skipStackCarrier?: boolean;
|
|
196
|
+
}
|
|
370
197
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
this.changes++;
|
|
374
|
-
const replaced = new Set(keys);
|
|
375
|
-
for (const key of replaced) this.everyTurn.delete(key);
|
|
376
|
-
this.nextTurn = this.nextTurn.filter((entry) => !entry.key || !replaced.has(entry.key));
|
|
377
|
-
for (const entry of entries) {
|
|
378
|
-
if (entry.key && replaced.has(entry.key) && entry.placement === "first-prepend") {
|
|
379
|
-
this.everyTurn.set(entry.key, structuredClone(entry));
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
198
|
+
/** A sticky state as a render places it: `delivered` once an answered request in the epoch carried it (its `stickyFact` is told), so a tool-result pin is only for its first delivery. */
|
|
199
|
+
export type StickyToPlace = StickyEntry & { delivered: boolean };
|
|
383
200
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const restored: StoredReminder[] = structuredClone([...snapshot.sticky]);
|
|
398
|
-
const now = this.now();
|
|
399
|
-
for (const entry of restored) {
|
|
400
|
-
if (entry.until !== undefined) continue;
|
|
401
|
-
const live = this.sticky.find((current) => current.until === undefined && same(current, entry));
|
|
402
|
-
if (live?.key !== undefined && this.everyTurn.get(live.key) === live) this.everyTurn.set(live.key, entry);
|
|
403
|
-
else entry.until = now;
|
|
404
|
-
}
|
|
405
|
-
this.sticky = [...restored, ...this.sticky.filter((current) => !restored.some((entry) => same(entry, current)))];
|
|
406
|
-
// The branch's carriers keep their closed copy of a lifetime still live here.
|
|
407
|
-
for (const [key, live] of this.everyTurn) {
|
|
408
|
-
if (live.placement !== "sticky-append" || live.until !== undefined || this.sticky.includes(live)) continue;
|
|
409
|
-
const { toolCallId: _call, tailPin: _pin, opener: _opener, ...state } = live;
|
|
410
|
-
const reopened: StoredReminder = { ...state, key, since: now, userPins: [] };
|
|
411
|
-
this.everyTurn.set(key, reopened);
|
|
412
|
-
this.sticky.push(reopened);
|
|
413
|
-
}
|
|
414
|
-
const pinned: StoredReminder[] = structuredClone([...(snapshot.pinned ?? [])]);
|
|
415
|
-
const known = new Set(pinned.map((entry) => JSON.stringify(strip(entry))));
|
|
416
|
-
this.pinned = [...pinned, ...this.pinned.filter((entry) => !known.has(JSON.stringify(strip(entry))))];
|
|
417
|
-
this.changes++;
|
|
418
|
-
}
|
|
201
|
+
/** A one-shot to place on this request: a pending entry, or a block an owner rendered for it. */
|
|
202
|
+
export interface OneShot {
|
|
203
|
+
/** The pending entry's id; absent on a render-time block. */
|
|
204
|
+
id?: string;
|
|
205
|
+
text: string;
|
|
206
|
+
raw?: boolean;
|
|
207
|
+
placement: "last-append" | "user-prepend";
|
|
208
|
+
toolCallId?: string;
|
|
209
|
+
promptTimestamp?: number;
|
|
210
|
+
once?: boolean;
|
|
211
|
+
orTail?: boolean;
|
|
212
|
+
facts?: ToldFact[];
|
|
213
|
+
}
|
|
419
214
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
215
|
+
/** What a capability owner adds on RENDER_CHANNEL. */
|
|
216
|
+
export interface RenderedBlock {
|
|
217
|
+
text: string;
|
|
218
|
+
/** What the block tells the model, recorded with the request that carries it. */
|
|
219
|
+
facts?: ToldFact[];
|
|
220
|
+
}
|
|
427
221
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
222
|
+
/** The RENDER_CHANNEL payload. */
|
|
223
|
+
export interface RenderRequest {
|
|
224
|
+
/** True when the request opens an epoch: the live stack is rendered, so owners add nothing. */
|
|
225
|
+
epoch: boolean;
|
|
226
|
+
/** What the epoch has told the model so far, by fact kind. */
|
|
227
|
+
told: ReadonlyMap<string, ReadonlySet<string>>;
|
|
228
|
+
/** Modes the epoch has told, by key: on or off as last told. */
|
|
229
|
+
modes: ReadonlyMap<string, boolean>;
|
|
230
|
+
/** Whole-text blocks the epoch has told, by key: the content hash of the text last told (`toldBlockFact`). */
|
|
231
|
+
toldBlocks: ReadonlyMap<string, string>;
|
|
232
|
+
/** The request's model. */
|
|
233
|
+
model: { provider?: string; api?: string; id?: string; compat?: unknown } | undefined;
|
|
234
|
+
/** Blocks for the tail of this request. */
|
|
235
|
+
blocks: RenderedBlock[];
|
|
236
|
+
}
|
|
436
237
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
238
|
+
/**
|
|
239
|
+
* The live reminder state: the `first-prepend` stack and the open sticky
|
|
240
|
+
* states, as their owners last emitted them. One-shots are not held here:
|
|
241
|
+
* they are persisted as pending entries when emitted.
|
|
242
|
+
*/
|
|
243
|
+
export class LiveReminders {
|
|
244
|
+
private stack = new Map<string, StackEntry>();
|
|
245
|
+
/** Open states in the order they switched on. */
|
|
246
|
+
private sticky = new Map<string, StickyEntry>();
|
|
247
|
+
private readonly now: () => number;
|
|
441
248
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
* `tool_result` hook, which writes them into the stored result. A one-shot
|
|
445
|
-
* bound to another call's result stays queued for that result's hook. Other
|
|
446
|
-
* placements stay queued for the next request.
|
|
447
|
-
*/
|
|
448
|
-
takeOneShots(toolCallId?: string): ReminderEntry[] {
|
|
449
|
-
const { matching, rest } = partition(
|
|
450
|
-
this.nextTurn,
|
|
451
|
-
(r) => r.placement === "last-append" && (r.toolCallId === undefined || r.toolCallId === toolCallId),
|
|
452
|
-
);
|
|
453
|
-
this.nextTurn = rest;
|
|
454
|
-
if (matching.length > 0) this.changes++;
|
|
455
|
-
return matching.map(strip);
|
|
249
|
+
constructor(now: () => number = Date.now) {
|
|
250
|
+
this.now = now;
|
|
456
251
|
}
|
|
457
252
|
|
|
458
253
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* unchanged. Called by the owner at `context` time, after it has decided the
|
|
462
|
-
* tail: `last-append` goes on the tail (`tailAnchor`), `user-prepend` only
|
|
463
|
-
* on a request that ends in a user-like message (`openingUserAnchor`) —
|
|
464
|
-
* mid-turn, breadcrumbs stay queued.
|
|
254
|
+
* Apply one reminder payload. Returns "one-shot" for a `last-append` or
|
|
255
|
+
* `user-prepend` reminder, which the caller persists as a pending entry.
|
|
465
256
|
*/
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
257
|
+
apply(payload: ReminderPayload): "live" | "one-shot" | "ignored" {
|
|
258
|
+
const placement = payload.placement ?? "last-append";
|
|
259
|
+
if (payload.remove) {
|
|
260
|
+
if (!payload.key) return "ignored";
|
|
261
|
+
this.stack.delete(payload.key);
|
|
262
|
+
this.sticky.delete(payload.key);
|
|
263
|
+
return "live";
|
|
473
264
|
}
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
* forget the step. Nothing else is ever evicted: a pin is one small object,
|
|
486
|
-
* and removing a pin whose message is still in context would change that
|
|
487
|
-
* message and re-cache everything after it — the old "oldest past 400" cap
|
|
488
|
-
* did exactly that (CACHE-REVIEW-2026-09-04 M3).
|
|
489
|
-
*/
|
|
490
|
-
drain(messages: AgentMessage[]): ReminderEntry[] {
|
|
491
|
-
const locate = pinLocator(messages);
|
|
492
|
-
const stackCarrier = messages.findIndex(isUserLike);
|
|
493
|
-
let changed = false;
|
|
494
|
-
for (const entry of this.sticky) {
|
|
495
|
-
if (entry.until !== undefined) continue;
|
|
496
|
-
if (entry.opener === undefined) {
|
|
497
|
-
entry.opener = null;
|
|
498
|
-
changed = true;
|
|
499
|
-
}
|
|
500
|
-
// Only live states may find a new carrier after compaction. A closed
|
|
501
|
-
// state's missing pin must never move to the summary or a later turn.
|
|
502
|
-
const tailPin = entry.tailPin;
|
|
503
|
-
if (entry.tailPin && locate(entry.tailPin) === -1) entry.tailPin = undefined;
|
|
504
|
-
if (entry.tailPin === undefined) entry.tailPin = firstStickyPin(messages, entry);
|
|
505
|
-
if (!samePin(tailPin, entry.tailPin)) changed = true;
|
|
506
|
-
// Timestamps alone cannot close a lifetime: a steer typed while it was
|
|
507
|
-
// active can remain queued until after the switch. Keep only messages
|
|
508
|
-
// that actually rode the block, including across a startup-time close.
|
|
509
|
-
const userPins = messages.flatMap((m, index) => carriesSticky(m, entry) &&
|
|
510
|
-
(!entry.skipStackCarrier || index !== stackCarrier) ? [m.timestamp] : []);
|
|
511
|
-
// The same array stays while its carriers do, so its lookup set (`carrierSet`) is reused.
|
|
512
|
-
if (!entry.userPins || entry.userPins.length !== userPins.length || entry.userPins.some((stamp, i) => stamp !== userPins[i])) {
|
|
513
|
-
entry.userPins = userPins;
|
|
514
|
-
changed = true;
|
|
515
|
-
}
|
|
516
|
-
if ((entry.tailPin || entry.userPins.length > 0) && entry.toolCallId !== undefined) {
|
|
517
|
-
delete entry.toolCallId;
|
|
518
|
-
changed = true;
|
|
519
|
-
}
|
|
265
|
+
const text = payload.text;
|
|
266
|
+
if (typeof text !== "string" || !text.trim()) return "ignored";
|
|
267
|
+
if (placement === "last-append" || placement === "user-prepend") return "one-shot";
|
|
268
|
+
const key = payload.key ?? text;
|
|
269
|
+
if (placement === "first-prepend") {
|
|
270
|
+
const entry: StackEntry = { key, text, order: payload.order ?? 0 };
|
|
271
|
+
if (payload.raw) entry.raw = true;
|
|
272
|
+
if (payload.systemRoleOnly) entry.systemRoleOnly = true;
|
|
273
|
+
if (payload.facts) entry.facts = payload.facts;
|
|
274
|
+
this.stack.set(key, entry);
|
|
275
|
+
return "live";
|
|
520
276
|
}
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (
|
|
526
|
-
const {
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
277
|
+
const previous = this.sticky.get(key);
|
|
278
|
+
const same = previous && previous.text === text && !!previous.raw === !!payload.raw && !!previous.skipStackCarrier === !!payload.skipStackCarrier;
|
|
279
|
+
// Re-emitting an unchanged standing state keeps its start and delivery; a
|
|
280
|
+
// changed one is a new state from now.
|
|
281
|
+
if (same && (payload.since === undefined || payload.since === previous.since)) return "live";
|
|
282
|
+
const entry: StickyEntry = { key, text, since: payload.since ?? this.now() };
|
|
283
|
+
if (payload.raw) entry.raw = true;
|
|
284
|
+
if (payload.skipStackCarrier) entry.skipStackCarrier = true;
|
|
285
|
+
if (payload.toolCallId) entry.toolCallId = payload.toolCallId;
|
|
286
|
+
this.sticky.delete(key);
|
|
287
|
+
this.sticky.set(key, entry);
|
|
288
|
+
return "live";
|
|
531
289
|
}
|
|
532
290
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
291
|
+
/** The stack in Claude Code's order (`order`, then emission order). */
|
|
292
|
+
stackEntries(): StackEntry[] {
|
|
293
|
+
return [...this.stack.values()].map((entry, i) => ({ entry, i })).sort((a, b) => a.entry.order - b.entry.order || a.i - b.i).map((x) => x.entry);
|
|
536
294
|
}
|
|
537
295
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
return this.nextTurn.some((r) => r.placement === placement);
|
|
296
|
+
stickyEntries(): StickyEntry[] {
|
|
297
|
+
return [...this.sticky.values()];
|
|
541
298
|
}
|
|
299
|
+
}
|
|
542
300
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
301
|
+
/** The told fact of a sticky state's first delivery: it follows the branch, and a failed request's is revoked. */
|
|
302
|
+
export function stickyFact(entry: Pick<StickyEntry, "key" | "text" | "since">): BlockFact {
|
|
303
|
+
return toldBlockFact(`sticky:${entry.key}`, { since: entry.since, text: entry.text });
|
|
547
304
|
}
|
|
548
305
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
306
|
+
export interface RenderInput {
|
|
307
|
+
/** The request opens an epoch: the stack goes on the first user message. */
|
|
308
|
+
epoch: boolean;
|
|
309
|
+
/** The model takes a mid-conversation system message (`systemRoleOnly` blocks render, `moved` is filled). */
|
|
310
|
+
layout: boolean;
|
|
311
|
+
stack: readonly StackEntry[];
|
|
312
|
+
sticky: readonly StickyToPlace[];
|
|
313
|
+
oneShots: readonly OneShot[];
|
|
555
314
|
}
|
|
556
315
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (r.tailPin) entry.tailPin = r.tailPin;
|
|
568
|
-
if (r.pin !== undefined) entry.pin = r.pin;
|
|
569
|
-
return entry;
|
|
316
|
+
export interface RenderResult {
|
|
317
|
+
messages: AgentMessage[];
|
|
318
|
+
/** The stack blocks that move to the mid-conversation system message (epoch and layout only). */
|
|
319
|
+
moved: MovedBlock[];
|
|
320
|
+
/** True when the stack was placed. */
|
|
321
|
+
stackPlaced: boolean;
|
|
322
|
+
/** The sticky states this request carried. */
|
|
323
|
+
stickyPlaced: string[];
|
|
324
|
+
/** The one-shots this request carried. */
|
|
325
|
+
placed: OneShot[];
|
|
570
326
|
}
|
|
571
327
|
|
|
328
|
+
type ContentBlock = TextContent | ImageContent;
|
|
329
|
+
|
|
572
330
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
* message count on the per-request path.
|
|
331
|
+
* The request's messages (its tail, or all of them on an epoch) with the
|
|
332
|
+
* reminders placed, and what was placed. The input is not mutated. A one-shot
|
|
333
|
+
* that finds no anchor is not placed and stays pending.
|
|
577
334
|
*/
|
|
578
|
-
function
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
335
|
+
export function renderReminders(messages: readonly AgentMessage[], input: RenderInput): RenderResult {
|
|
336
|
+
const before = new Map<number, TextContent[]>();
|
|
337
|
+
const after = new Map<number, TextContent[]>();
|
|
338
|
+
const push = (map: Map<number, TextContent[]>, index: number, blocks: TextContent[]) => {
|
|
339
|
+
if (index < 0 || blocks.length === 0) return;
|
|
340
|
+
map.set(index, [...(map.get(index) ?? []), ...blocks]);
|
|
341
|
+
};
|
|
342
|
+
const firstUser = messages.findIndex(isUserLike);
|
|
343
|
+
const lastUser = messages.findLastIndex(isUserLike);
|
|
344
|
+
const last = messages.length - 1;
|
|
345
|
+
// Mid-turn, the request ends in the tool results the model is about to read:
|
|
346
|
+
// a one-shot belongs there. Otherwise on the last user turn.
|
|
347
|
+
const tail = last >= 0 && messages[last].role === "toolResult" ? last : lastUser;
|
|
348
|
+
const stackAt = firstUser === -1 ? tail : firstUser;
|
|
349
|
+
|
|
350
|
+
let stackPlaced = false;
|
|
351
|
+
let moved: MovedBlock[] = [];
|
|
352
|
+
if (input.epoch && stackAt !== -1) {
|
|
353
|
+
const stack = input.stack.filter((entry) => input.layout || !entry.systemRoleOnly);
|
|
354
|
+
push(before, stackAt, stack.map(block));
|
|
355
|
+
stackPlaced = stack.length > 0;
|
|
356
|
+
if (input.layout) {
|
|
357
|
+
moved = stack.filter((entry) => movesToSystemRole(entry.order)).map((entry) => ({ framed: framedReminderText(entry), inner: entry.text }));
|
|
588
358
|
}
|
|
589
|
-
}
|
|
590
|
-
return (pin) => (pin.kind === "toolResult" ? byToolCall.get(pin.toolCallId) : byTimestamp.get(pin.timestamp)) ?? -1;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function samePin(a: PinAnchor | undefined, b: PinAnchor | undefined): boolean {
|
|
594
|
-
if (!a || !b) return a === b;
|
|
595
|
-
return a.kind === "toolResult" ? b.kind === "toolResult" && a.toolCallId === b.toolCallId : b.kind === "user" && a.timestamp === b.timestamp;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
/** Each carrier list's lookup set, built once per array: a check runs for every message against every lifetime. */
|
|
599
|
-
const carrierSets = new WeakMap<readonly number[], Set<number>>();
|
|
600
|
-
|
|
601
|
-
function carrierSet(userPins: readonly number[]): Set<number> {
|
|
602
|
-
let set = carrierSets.get(userPins);
|
|
603
|
-
if (!set) carrierSets.set(userPins, (set = new Set(userPins)));
|
|
604
|
-
return set;
|
|
605
|
-
}
|
|
359
|
+
}
|
|
606
360
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
361
|
+
const stickyPlaced: string[] = [];
|
|
362
|
+
for (const state of input.sticky) {
|
|
363
|
+
const carriers: number[] = [];
|
|
364
|
+
messages.forEach((message, index) => {
|
|
365
|
+
if (!isStickyCarrier(message) || stamp(message) < state.since) return;
|
|
366
|
+
if (state.skipStackCarrier && input.epoch && index === firstUser) return;
|
|
367
|
+
carriers.push(index);
|
|
368
|
+
});
|
|
369
|
+
// The pin is for a state switched on mid-turn; one on from the start
|
|
370
|
+
// (`since: 0`, the constant budget line) rides user messages only.
|
|
371
|
+
if (!state.delivered && state.since > 0) {
|
|
372
|
+
const first = firstResultAfter(messages, state);
|
|
373
|
+
if (first !== -1 && !carriers.includes(first)) carriers.unshift(first);
|
|
374
|
+
}
|
|
375
|
+
if (carriers.length === 0) continue;
|
|
376
|
+
stickyPlaced.push(state.key);
|
|
377
|
+
for (const index of carriers.sort((a, b) => a - b)) push(after, index, [block(state)]);
|
|
378
|
+
}
|
|
615
379
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
380
|
+
const placed: OneShot[] = [];
|
|
381
|
+
const opening = last >= 0 && isUserLike(messages[last]) ? last : -1;
|
|
382
|
+
const placedOnce = new Set<string>();
|
|
383
|
+
for (const shot of input.oneShots) {
|
|
384
|
+
if (shot.placement === "user-prepend" && (opening !== -1 || !shot.orTail)) {
|
|
385
|
+
if (opening === -1) continue;
|
|
386
|
+
placed.push(shot);
|
|
387
|
+
if (shot.once && placedOnce.has(shot.text)) continue;
|
|
388
|
+
if (shot.once) placedOnce.add(shot.text);
|
|
389
|
+
push(before, opening, [block(shot)]);
|
|
390
|
+
continue;
|
|
624
391
|
}
|
|
392
|
+
const own = shot.toolCallId
|
|
393
|
+
? messages.findIndex((m) => m.role === "toolResult" && (m as { toolCallId?: string }).toolCallId === shot.toolCallId)
|
|
394
|
+
: shot.promptTimestamp !== undefined
|
|
395
|
+
? promptAt(messages, shot.promptTimestamp)
|
|
396
|
+
: -1;
|
|
397
|
+
const at = own !== -1 ? own : tail;
|
|
398
|
+
if (at === -1) continue;
|
|
399
|
+
placed.push(shot);
|
|
400
|
+
push(after, at, [block(shot)]);
|
|
625
401
|
}
|
|
626
|
-
// Overflow compaction may retain only an old reply/result. With no real
|
|
627
|
-
// user turn, a live state must still be visible and pinned to that tail.
|
|
628
|
-
return messages.some(isStickyCarrier) ? undefined : tailAnchor(messages);
|
|
629
|
-
}
|
|
630
402
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
403
|
+
const changed = before.size > 0 || after.size > 0;
|
|
404
|
+
return {
|
|
405
|
+
messages: changed ? messages.map((m, index) => withBlocks(m, before.get(index) ?? [], after.get(index) ?? [])) : [...messages],
|
|
406
|
+
moved,
|
|
407
|
+
stackPlaced,
|
|
408
|
+
stickyPlaced,
|
|
409
|
+
placed,
|
|
410
|
+
};
|
|
634
411
|
}
|
|
635
412
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const last = messages[messages.length - 1] as (AgentMessage & { toolCallId?: string }) | undefined;
|
|
639
|
-
if (last?.role === "toolResult" && typeof last.toolCallId === "string") return { kind: "toolResult", toolCallId: last.toolCallId };
|
|
640
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
641
|
-
const m = messages[i] as AgentMessage & { timestamp?: number };
|
|
642
|
-
if (isUserLike(m) && typeof m.timestamp === "number") {
|
|
643
|
-
return { kind: "user", timestamp: m.timestamp };
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
return undefined;
|
|
413
|
+
function stamp(message: AgentMessage): number {
|
|
414
|
+
return (message as { timestamp?: number }).timestamp ?? 0;
|
|
647
415
|
}
|
|
648
416
|
|
|
417
|
+
/** How much later pi may stamp a custom message it rebuilt from the session than the one it delivered. */
|
|
418
|
+
const RESTAMP_WINDOW_MS = 1000;
|
|
419
|
+
|
|
649
420
|
/**
|
|
650
|
-
* The
|
|
651
|
-
*
|
|
652
|
-
*
|
|
421
|
+
* The prompt a one-shot's `promptTimestamp` names, or -1 when the request does
|
|
422
|
+
* not carry it. pi stores a custom message (a `/skill:` invocation) with the
|
|
423
|
+
* time it persisted it, at `message_end`, so after `/reload` or resume the
|
|
424
|
+
* rebuilt message is stamped a little after the delivered one the pin took;
|
|
425
|
+
* a user message keeps its own stamp.
|
|
653
426
|
*/
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
if (
|
|
657
|
-
return
|
|
427
|
+
function promptAt(messages: readonly AgentMessage[], timestamp: number): number {
|
|
428
|
+
const exact = messages.findIndex((m) => isStickyCarrier(m) && stamp(m) === timestamp);
|
|
429
|
+
if (exact !== -1) return exact;
|
|
430
|
+
return messages.findIndex((m) => m.role === "custom" && stamp(m) > timestamp && stamp(m) - timestamp < RESTAMP_WINDOW_MS);
|
|
658
431
|
}
|
|
659
432
|
|
|
660
|
-
/**
|
|
661
|
-
|
|
662
|
-
|
|
433
|
+
/**
|
|
434
|
+
* Where a state switched on mid-turn first shows: its switching call's result
|
|
435
|
+
* when the request carries it, else (that result compacted away, or no call
|
|
436
|
+
* named) the first tool result stamped after the switch, but never one after a
|
|
437
|
+
* user message that already carries it. With no carrier at all (an overflow
|
|
438
|
+
* compaction kept only a reply and its results), the request's tail.
|
|
439
|
+
*/
|
|
440
|
+
function firstResultAfter(messages: readonly AgentMessage[], state: StickyEntry): number {
|
|
441
|
+
if (state.toolCallId !== undefined) {
|
|
442
|
+
const own = messages.findIndex((m) => m.role === "toolResult" && (m as { toolCallId?: string }).toolCallId === state.toolCallId);
|
|
443
|
+
if (own !== -1) return own;
|
|
444
|
+
}
|
|
445
|
+
for (let i = 0; i < messages.length; i++) {
|
|
446
|
+
const message = messages[i];
|
|
447
|
+
if (stamp(message) < state.since) continue;
|
|
448
|
+
if (isStickyCarrier(message)) return -1;
|
|
449
|
+
if (message.role === "toolResult") return i;
|
|
450
|
+
}
|
|
451
|
+
if (messages.some(isStickyCarrier)) return -1;
|
|
452
|
+
const last = messages.length - 1;
|
|
453
|
+
return last >= 0 && messages[last].role === "toolResult" ? last : -1;
|
|
663
454
|
}
|
|
664
455
|
|
|
665
456
|
/**
|
|
@@ -690,18 +481,16 @@ export const COMPACTION_SUMMARY_PREFIX =
|
|
|
690
481
|
"The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
|
|
691
482
|
export const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
|
|
692
483
|
|
|
693
|
-
type ContentBlock = TextContent | ImageContent;
|
|
694
|
-
|
|
695
484
|
function toBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
|
696
485
|
return typeof content === "string" ? [{ type: "text", text: content }] : [...content];
|
|
697
486
|
}
|
|
698
487
|
|
|
699
|
-
/** A reminder's text exactly as it rides a message: framed
|
|
700
|
-
export function framedReminderText(entry:
|
|
701
|
-
return
|
|
488
|
+
/** A reminder's text exactly as it rides a message: framed, unless raw. */
|
|
489
|
+
export function framedReminderText(entry: { text: string; raw?: boolean }): string {
|
|
490
|
+
return entry.raw ? entry.text : wrapReminder(entry.text);
|
|
702
491
|
}
|
|
703
492
|
|
|
704
|
-
function
|
|
493
|
+
function block(entry: { text: string; raw?: boolean }): TextContent {
|
|
705
494
|
return { type: "text", text: framedReminderText(entry) };
|
|
706
495
|
}
|
|
707
496
|
|
|
@@ -710,9 +499,7 @@ function reminderBlock(entry: ReminderEntry): TextContent {
|
|
|
710
499
|
* (task notification, wakeup, hook context, `<new-diagnostics>`); pi's
|
|
711
500
|
* `convertToLlm` sends it to the model as `role: "user"`, so on the wire it IS
|
|
712
501
|
* a user turn — and a turn opened by one (a `/loop` tick, an agent report
|
|
713
|
-
* arriving while idle) has no `user` message of its own
|
|
714
|
-
* a request got no context stack at all, and its pending one-shots pinned to
|
|
715
|
-
* the previous user message (STEERING-REVIEW-2026-09-05 H1).
|
|
502
|
+
* arriving while idle) has no `user` message of its own (STEERING-REVIEW-2026-09-05 H1).
|
|
716
503
|
*/
|
|
717
504
|
function isUserLike(m: AgentMessage): boolean {
|
|
718
505
|
return isStickyCarrier(m) || m.role === "compactionSummary";
|
|
@@ -741,84 +528,3 @@ function withBlocks(message: AgentMessage, before: TextContent[], after: TextCon
|
|
|
741
528
|
const target = message as AgentMessage & { content: string | ContentBlock[] };
|
|
742
529
|
return { ...target, content: [...before, ...toBlocks(target.content), ...after] } as AgentMessage;
|
|
743
530
|
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Returns a copy of `messages` with reminder blocks injected (Claude Code
|
|
747
|
-
* convention: reminders ride inside message content blocks, never as messages
|
|
748
|
-
* of their own). See the module header for the three placements. A bare string
|
|
749
|
-
* is `last-append` (back-compat). The input array and its messages are not
|
|
750
|
-
* mutated. With nothing to anchor to (no user, tool-result, or compaction
|
|
751
|
-
* summary message), messages are returned unchanged and the caller keeps the
|
|
752
|
-
* reminders queued.
|
|
753
|
-
*/
|
|
754
|
-
export function injectReminders(messages: AgentMessage[], reminders: Array<string | ReminderEntry>): AgentMessage[] {
|
|
755
|
-
if (reminders.length === 0) return messages;
|
|
756
|
-
|
|
757
|
-
const entries: ReminderEntry[] = reminders.map((r) =>
|
|
758
|
-
typeof r === "string" ? { text: r, placement: "last-append", order: 0 } : r,
|
|
759
|
-
);
|
|
760
|
-
const firstPrepend = entries
|
|
761
|
-
.filter((e) => e.placement === "first-prepend")
|
|
762
|
-
.map((e, i) => ({ e, i }))
|
|
763
|
-
.sort((a, b) => a.e.order - b.e.order || a.i - b.i)
|
|
764
|
-
.map((x) => x.e);
|
|
765
|
-
const sticky = entries.filter((e) => e.placement === "sticky-append");
|
|
766
|
-
const pinnedEntries = entries.filter((e) => e.pin !== undefined);
|
|
767
|
-
const lastAppend = entries.filter((e) => e.placement === "last-append" && e.pin === undefined);
|
|
768
|
-
// An unpinned `user-prepend` never reaches here through the queue (drain
|
|
769
|
-
// holds it back); a caller passing one directly gets it on the opening user
|
|
770
|
-
// message, before the text, like a pinned one.
|
|
771
|
-
const looseUserPrepend = entries.filter((e) => e.placement === "user-prepend" && e.pin === undefined);
|
|
772
|
-
|
|
773
|
-
const firstUserIndex = messages.findIndex(isUserLike);
|
|
774
|
-
const lastUserIndex = messages.findLastIndex(isUserLike);
|
|
775
|
-
const lastIndex = messages.length - 1;
|
|
776
|
-
// Mid-turn, the request ends in the tool result(s) the model is about to
|
|
777
|
-
// read — that is where a one-shot reminder belongs. Otherwise the last user turn.
|
|
778
|
-
const tailIndex = lastIndex >= 0 && messages[lastIndex].role === "toolResult" ? lastIndex : lastUserIndex;
|
|
779
|
-
if (firstUserIndex === -1 && tailIndex === -1) return messages;
|
|
780
|
-
|
|
781
|
-
const before = new Map<number, TextContent[]>();
|
|
782
|
-
const after = new Map<number, TextContent[]>();
|
|
783
|
-
const push = (map: Map<number, TextContent[]>, index: number, blocks: TextContent[]) => {
|
|
784
|
-
if (index < 0 || blocks.length === 0) return;
|
|
785
|
-
map.set(index, [...(map.get(index) ?? []), ...blocks]);
|
|
786
|
-
};
|
|
787
|
-
|
|
788
|
-
push(before, firstUserIndex === -1 ? tailIndex : firstUserIndex, firstPrepend.map(reminderBlock));
|
|
789
|
-
|
|
790
|
-
const locate = pinLocator(messages);
|
|
791
|
-
for (const entry of sticky) {
|
|
792
|
-
const carriers: number[] = [];
|
|
793
|
-
messages.forEach((m, index) => {
|
|
794
|
-
if (carriesSticky(m, entry)) carriers.push(index);
|
|
795
|
-
});
|
|
796
|
-
const pin = entry.tailPin ?? firstStickyPin(messages, entry);
|
|
797
|
-
const pinned = pin ? locate(pin) : -1;
|
|
798
|
-
if (pinned !== -1 && !carriers.includes(pinned)) carriers.unshift(pinned);
|
|
799
|
-
if (entry.skipStackCarrier && carriers.includes(firstUserIndex)) {
|
|
800
|
-
for (const index of carriers.filter((index) => index !== firstUserIndex).sort((a, b) => a - b)) push(after, index, [reminderBlock(entry)]);
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
for (const index of carriers.sort((a, b) => a - b)) push(after, index, [reminderBlock(entry)]);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
push(after, tailIndex === -1 ? firstUserIndex : tailIndex, lastAppend.map(reminderBlock));
|
|
807
|
-
if (lastUserIndex !== -1) push(before, lastUserIndex, looseUserPrepend.map(reminderBlock));
|
|
808
|
-
|
|
809
|
-
// Pinned one-shots ride the exact message they first landed on; a message
|
|
810
|
-
// compacted away simply no longer carries its pin. A pinned breadcrumb sits
|
|
811
|
-
// BEFORE that message's text (after the first-prepend stack, which was
|
|
812
|
-
// pushed first); every other pin sits after it.
|
|
813
|
-
if (pinnedEntries.length > 0) {
|
|
814
|
-
const locate = pinLocator(messages);
|
|
815
|
-
for (const entry of pinnedEntries) {
|
|
816
|
-
const index = locate(entry.pin as PinAnchor);
|
|
817
|
-
if (index === -1) continue;
|
|
818
|
-
push(entry.placement === "user-prepend" ? before : after, index, [reminderBlock(entry)]);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
if (before.size === 0 && after.size === 0) return messages;
|
|
823
|
-
return messages.map((m, index) => withBlocks(m, before.get(index) ?? [], after.get(index) ?? []));
|
|
824
|
-
}
|