@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.5
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/CHANGELOG.md +86 -43
- package/dist/cli.js +3540 -3521
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +7 -1
- package/dist/types/async/job-manager.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +7 -0
- package/dist/types/config/model-resolver.d.ts +8 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/dap/client.d.ts +10 -0
- package/dist/types/dap/types.d.ts +6 -5
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
- package/dist/types/launch/spawn-options.d.ts +10 -0
- package/dist/types/launch/spawn-options.test.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +2 -3
- package/dist/types/modes/components/status-line/types.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +4 -3
- package/dist/types/modes/components/tree-selector.d.ts +6 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/print-mode.d.ts +4 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +19 -1
- package/dist/types/session/messages.d.ts +6 -0
- package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
- package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/gh.d.ts +5 -3
- package/dist/types/tools/hub/index.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +1 -0
- package/dist/types/tools/hub/types.d.ts +2 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/utils/git.d.ts +33 -0
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +150 -0
- package/src/advisor/advise-tool.ts +4 -0
- package/src/advisor/runtime.ts +38 -14
- package/src/async/job-manager.ts +3 -0
- package/src/cli/bench-cli.ts +8 -2
- package/src/cli/dry-balance-cli.ts +1 -0
- package/src/config/model-registry.ts +89 -8
- package/src/config/model-resolver.ts +78 -14
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings.ts +3 -1
- package/src/dap/client.ts +168 -1
- package/src/dap/config.ts +51 -1
- package/src/dap/session.ts +575 -234
- package/src/dap/types.ts +6 -5
- package/src/discovery/agents.ts +2 -2
- package/src/extensibility/extensions/runner.ts +6 -4
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
- package/src/launch/broker.ts +34 -31
- package/src/launch/client.ts +7 -1
- package/src/launch/spawn-options.test.ts +31 -0
- package/src/launch/spawn-options.ts +17 -0
- package/src/lsp/types.ts +5 -1
- package/src/main.ts +17 -4
- package/src/mcp/transports/stdio.test.ts +9 -1
- package/src/modes/components/ask-dialog.ts +137 -73
- package/src/modes/components/status-line/component.ts +2 -2
- package/src/modes/components/status-line/segments.ts +20 -3
- package/src/modes/components/status-line/types.ts +3 -1
- package/src/modes/components/tool-execution.ts +5 -0
- package/src/modes/components/transcript-container.ts +23 -122
- package/src/modes/components/tree-selector.ts +10 -4
- package/src/modes/controllers/event-controller.ts +1 -2
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +29 -8
- package/src/modes/interactive-mode.ts +40 -8
- package/src/modes/noninteractive-dispose.test.ts +12 -1
- package/src/modes/print-mode.ts +21 -9
- package/src/modes/rpc/rpc-input.ts +38 -0
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +3 -2
- package/src/prompts/tools/browser.md +3 -2
- package/src/prompts/tools/debug.md +2 -7
- package/src/prompts/tools/github.md +6 -1
- package/src/prompts/tools/hub.md +4 -2
- package/src/prompts/tools/task-async-contract.md +1 -0
- package/src/prompts/tools/task.md +9 -2
- package/src/sdk.ts +38 -6
- package/src/session/agent-session.ts +395 -162
- package/src/session/messages.test.ts +91 -0
- package/src/session/messages.ts +248 -110
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/task/executor.ts +59 -33
- package/src/task/index.ts +46 -9
- package/src/task/worktree.ts +10 -0
- package/src/tools/browser/aria/aria-snapshot.ts +36 -8
- package/src/tools/browser/cmux/cmux-tab.ts +2 -1
- package/src/tools/browser/cmux/socket-client.ts +139 -3
- package/src/tools/browser/run-cancellation.ts +4 -0
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +21 -11
- package/src/tools/browser/tab-worker.ts +199 -33
- package/src/tools/debug.ts +3 -0
- package/src/tools/gh.ts +40 -2
- package/src/tools/hub/index.ts +4 -1
- package/src/tools/hub/jobs.ts +42 -1
- package/src/tools/hub/types.ts +2 -0
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/utils/git.ts +237 -0
- package/src/web/search/index.ts +9 -5
- package/src/web/search/providers/codex.ts +195 -99
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
replaceLlmImagesWithText,
|
|
9
9
|
SKILL_PROMPT_MESSAGE_TYPE,
|
|
10
10
|
type SkillPromptDetails,
|
|
11
|
+
stripImagesFromMessage,
|
|
11
12
|
} from "./messages";
|
|
12
13
|
|
|
13
14
|
function customMessage(customType: string, attribution: "agent" | "user"): CustomMessage<SkillPromptDetails> {
|
|
@@ -125,6 +126,96 @@ describe("convertToLlm", () => {
|
|
|
125
126
|
});
|
|
126
127
|
});
|
|
127
128
|
|
|
129
|
+
function settledAssistant(text: string): AssistantMessage {
|
|
130
|
+
return {
|
|
131
|
+
role: "assistant",
|
|
132
|
+
content: [{ type: "text", text }],
|
|
133
|
+
api: "anthropic-messages",
|
|
134
|
+
provider: "anthropic",
|
|
135
|
+
model: "claude-sonnet-4-5",
|
|
136
|
+
usage: {
|
|
137
|
+
input: 100,
|
|
138
|
+
output: 20,
|
|
139
|
+
cacheRead: 0,
|
|
140
|
+
cacheWrite: 0,
|
|
141
|
+
totalTokens: 120,
|
|
142
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
143
|
+
},
|
|
144
|
+
stopReason: "stop",
|
|
145
|
+
timestamp: 1,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function userMessage(text: string, timestamp: number): AgentMessage {
|
|
150
|
+
return { role: "user", content: text, attribution: "user", timestamp } as AgentMessage;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("convertToLlm caching", () => {
|
|
154
|
+
it("reuses the outer array on an exact repeat of the same history", () => {
|
|
155
|
+
const messages: AgentMessage[] = [userMessage("hello", 1), settledAssistant("hi")];
|
|
156
|
+
const first = convertToLlm(messages);
|
|
157
|
+
const second = convertToLlm(messages);
|
|
158
|
+
expect(second).toBe(first);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("reuses the unchanged prefix output on append-only growth", () => {
|
|
162
|
+
const messages: AgentMessage[] = [userMessage("one", 1), settledAssistant("reply one")];
|
|
163
|
+
const first = convertToLlm(messages);
|
|
164
|
+
messages.push(userMessage("two", 2));
|
|
165
|
+
const grown = convertToLlm(messages);
|
|
166
|
+
// New outer array (no held-result aliasing), but the converted prefix is
|
|
167
|
+
// byte-identical and the appended turn is present.
|
|
168
|
+
expect(grown).not.toBe(first);
|
|
169
|
+
expect(grown.length).toBe(first.length + 1);
|
|
170
|
+
expect(grown.slice(0, first.length)).toEqual(first);
|
|
171
|
+
expect(grown[grown.length - 1]?.role).toBe("user");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("recomputes the boundary assistant when a following interrupted-thinking marker appears on growth", () => {
|
|
175
|
+
const messages: AgentMessage[] = [
|
|
176
|
+
abortedAssistant([
|
|
177
|
+
{ type: "text", text: "partial answer" },
|
|
178
|
+
{ type: "thinking", thinking: "interrupted reasoning" },
|
|
179
|
+
]),
|
|
180
|
+
];
|
|
181
|
+
const before = convertToLlm(messages);
|
|
182
|
+
const beforeAssistant = before.find(entry => entry.role === "assistant");
|
|
183
|
+
expect(Array.isArray(beforeAssistant?.content) && beforeAssistant.content.map(b => b.type)).toEqual([
|
|
184
|
+
"text",
|
|
185
|
+
"thinking",
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
// Append the continuity marker on the same array: the assistant is now the
|
|
189
|
+
// boundary message and its LLM view must drop the trailing thinking run.
|
|
190
|
+
messages.push(interruptedThinkingContinuity());
|
|
191
|
+
const after = convertToLlm(messages);
|
|
192
|
+
const afterAssistant = after.find(entry => entry.role === "assistant");
|
|
193
|
+
expect(Array.isArray(afterAssistant?.content) && afterAssistant.content.map(b => b.type)).toEqual(["text"]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("recomputes a message after strip-images invalidates its cache", () => {
|
|
197
|
+
const withImage: AgentMessage = {
|
|
198
|
+
role: "user",
|
|
199
|
+
content: [
|
|
200
|
+
{ type: "text", text: "look" },
|
|
201
|
+
{ type: "image", data: "aaaa", mimeType: "image/png" },
|
|
202
|
+
],
|
|
203
|
+
attribution: "user",
|
|
204
|
+
timestamp: 1,
|
|
205
|
+
};
|
|
206
|
+
const messages: AgentMessage[] = [withImage];
|
|
207
|
+
const before = convertToLlm(messages);
|
|
208
|
+
const beforeUser = before.find(entry => entry.role === "user");
|
|
209
|
+
expect(Array.isArray(beforeUser?.content) && beforeUser.content.some(b => b.type === "image")).toBe(true);
|
|
210
|
+
|
|
211
|
+
// Mutate in place through the owner seam, which must invalidate the cache.
|
|
212
|
+
stripImagesFromMessage(withImage);
|
|
213
|
+
const after = convertToLlm(messages);
|
|
214
|
+
const afterUser = after.find(entry => entry.role === "user");
|
|
215
|
+
expect(Array.isArray(afterUser?.content) && afterUser.content.some(b => b.type === "image")).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
128
219
|
describe("replaceLlmImagesWithText", () => {
|
|
129
220
|
it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
|
|
130
221
|
const converted = convertToLlm([
|
package/src/session/messages.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* and provides a transformer to convert them to LLM-compatible messages.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
8
|
+
import {
|
|
9
|
+
invalidateMessageCache,
|
|
10
|
+
registerMessageCacheInvalidator,
|
|
11
|
+
} from "@oh-my-pi/pi-agent-core/compaction/message-cache";
|
|
8
12
|
import {
|
|
9
13
|
type BranchSummaryMessage,
|
|
10
14
|
type CompactionSummaryMessage,
|
|
@@ -457,6 +461,14 @@ function stripImagesFromArrayContent(content: (TextContent | ImageContent)[]): S
|
|
|
457
461
|
* pure local mutation and intentionally does neither.
|
|
458
462
|
*/
|
|
459
463
|
export function stripImagesFromMessage(message: AgentMessage): number {
|
|
464
|
+
const removed = stripImagesFromMessageContent(message);
|
|
465
|
+
// The mutated message keeps its identity across context rebuilds, so drop its
|
|
466
|
+
// cached estimate/convert before the next pass counts/converts the new shape.
|
|
467
|
+
if (removed > 0) invalidateMessageCache(message);
|
|
468
|
+
return removed;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function stripImagesFromMessageContent(message: AgentMessage): number {
|
|
460
472
|
switch (message.role) {
|
|
461
473
|
case "user":
|
|
462
474
|
case "developer":
|
|
@@ -751,127 +763,253 @@ function convertImageBearingCustomMessage(message: CustomMessage | HookMessage):
|
|
|
751
763
|
}
|
|
752
764
|
|
|
753
765
|
/**
|
|
754
|
-
*
|
|
766
|
+
* Per-message conversion result, keyed by message identity. `interruptedNext`
|
|
767
|
+
* records the neighbor state the fragment was built against so an assistant
|
|
768
|
+
* whose following {@link INTERRUPTED_THINKING_MESSAGE_TYPE} marker appears or
|
|
769
|
+
* disappears is recomputed (its LLM view strips the trailing thinking run only
|
|
770
|
+
* while that marker follows).
|
|
755
771
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* -
|
|
759
|
-
*
|
|
772
|
+
* WeakMap (not a symbol tag) is deliberate: `wrapSteeringForModel` and
|
|
773
|
+
* `deobfuscateAgentMessages` spread messages into fresh variants with different
|
|
774
|
+
* content; a symbol-keyed fragment would ride that spread and mis-convert the
|
|
775
|
+
* copy. Identity keying keeps the cache off spread copies.
|
|
760
776
|
*/
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
777
|
+
interface ConvertMemoEntry {
|
|
778
|
+
interruptedNext: boolean;
|
|
779
|
+
fragment: Message[];
|
|
780
|
+
}
|
|
781
|
+
const convertCache = new WeakMap<AgentMessage, ConvertMemoEntry>();
|
|
782
|
+
|
|
783
|
+
// Array-level shortcuts over the per-message memo. The live agent mutates one
|
|
784
|
+
// `AgentMessage[]` identity across a turn: appending new messages and swapping
|
|
785
|
+
// the streaming tail (`context.messages[len-1] = partial → trailing`). Between
|
|
786
|
+
// owner invalidations (prune/shake/strip bump `convertGeneration`) and for a
|
|
787
|
+
// given array identity, only the last index is ever swapped and the array only
|
|
788
|
+
// grows — interior prefix messages are immutable. That invariant lets two
|
|
789
|
+
// shortcuts skip the O(N) re-walk:
|
|
790
|
+
// - exact-repeat: same array, same length, same generation, same tail identity
|
|
791
|
+
// → hand back the same outer array.
|
|
792
|
+
// - slice-on-growth: same array, same generation, length grew → copy the
|
|
793
|
+
// unchanged prefix output and reconvert only the neighbor-sensitive boundary
|
|
794
|
+
// message plus the appended suffix.
|
|
795
|
+
// The tail-identity guard on exact-repeat catches the streaming snapshot swap
|
|
796
|
+
// (partial → trailing is a fresh identity), so a settled tail is never served
|
|
797
|
+
// from a stale mid-stream fragment.
|
|
798
|
+
let convertGeneration = 0;
|
|
799
|
+
let lastConvertInput: AgentMessage[] | undefined;
|
|
800
|
+
let lastConvertLength = 0;
|
|
801
|
+
let lastConvertOutput: Message[] | undefined;
|
|
802
|
+
let lastConvertGeneration = -1;
|
|
803
|
+
let lastConvertTail: AgentMessage | undefined;
|
|
804
|
+
// Output-message count contributed by messages[0 .. lastConvertLength-1), i.e.
|
|
805
|
+
// every message except the last. The last message is neighbor-sensitive (its LLM
|
|
806
|
+
// view drops the trailing thinking run only while an interrupted-thinking marker
|
|
807
|
+
// follows), so growth reconverts it rather than reusing its old fragment.
|
|
808
|
+
let lastConvertPrefixOutputLen = 0;
|
|
809
|
+
|
|
810
|
+
registerMessageCacheInvalidator(message => {
|
|
811
|
+
convertCache.delete(message);
|
|
812
|
+
convertGeneration++;
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
/** Convert one message to its LLM fragment. `interruptedNext` is true only for an
|
|
816
|
+
* assistant turn immediately followed by its interrupted-thinking marker. */
|
|
817
|
+
function convertOne(m: AgentMessage, interruptedNext: boolean): Message[] {
|
|
818
|
+
switch (m.role) {
|
|
819
|
+
case "bashExecution":
|
|
820
|
+
if (m.excludeFromContext) {
|
|
821
|
+
return [];
|
|
822
|
+
}
|
|
823
|
+
return [
|
|
824
|
+
{
|
|
825
|
+
role: "user",
|
|
826
|
+
content: [{ type: "text", text: bashExecutionToText(m) }],
|
|
827
|
+
attribution: "user",
|
|
828
|
+
timestamp: m.timestamp,
|
|
829
|
+
},
|
|
830
|
+
];
|
|
831
|
+
case "pythonExecution":
|
|
832
|
+
if (m.excludeFromContext) {
|
|
833
|
+
return [];
|
|
834
|
+
}
|
|
835
|
+
return [
|
|
836
|
+
{
|
|
837
|
+
role: "user",
|
|
838
|
+
content: [{ type: "text", text: pythonExecutionToText(m) }],
|
|
839
|
+
attribution: "user",
|
|
840
|
+
timestamp: m.timestamp,
|
|
841
|
+
},
|
|
842
|
+
];
|
|
843
|
+
case "fileMention": {
|
|
844
|
+
// One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
|
|
845
|
+
// in the same turn (`generateFileMentionMessages` packs every `@…` into a
|
|
846
|
+
// single message). Splitting by image presence keeps text-only mentions on
|
|
847
|
+
// the higher-priority `developer` slot while routing image attachments
|
|
848
|
+
// through `user`, the only Responses content slot that legitimately accepts
|
|
849
|
+
// `input_image` (Codex chatgpt.com /codex/responses rejects everything else
|
|
850
|
+
// with `Invalid value: 'input_image'`, #3443).
|
|
851
|
+
const wrap = (file: FileMentionMessage["files"][number]): string => {
|
|
852
|
+
const inner = file.content ? `\n${file.content}\n` : "\n";
|
|
853
|
+
return `<file path="${file.path}">${inner}</file>`;
|
|
854
|
+
};
|
|
855
|
+
const textFiles = m.files.filter(file => !file.image);
|
|
856
|
+
const imageFiles = m.files.filter(file => file.image);
|
|
857
|
+
const out: Message[] = [];
|
|
858
|
+
if (textFiles.length > 0) {
|
|
859
|
+
out.push({
|
|
860
|
+
role: "developer",
|
|
861
|
+
content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
|
|
862
|
+
attribution: "user",
|
|
863
|
+
timestamp: m.timestamp,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (imageFiles.length > 0) {
|
|
867
|
+
const content: (TextContent | ImageContent)[] = [
|
|
868
|
+
{ type: "text" as const, text: imageFiles.map(wrap).join("\n") },
|
|
775
869
|
];
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
return [];
|
|
870
|
+
for (const file of imageFiles) {
|
|
871
|
+
if (file.image) content.push(file.image);
|
|
779
872
|
}
|
|
873
|
+
out.push({
|
|
874
|
+
role: "user",
|
|
875
|
+
content,
|
|
876
|
+
attribution: "user",
|
|
877
|
+
timestamp: m.timestamp,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
case "custom": {
|
|
883
|
+
if (!isCustomMessageContent(m.content)) return [];
|
|
884
|
+
if (isUserInvokedSkillPrompt(m)) {
|
|
780
885
|
return [
|
|
781
886
|
{
|
|
782
887
|
role: "user",
|
|
783
|
-
content:
|
|
888
|
+
content: customMessageContentToLlmContent(m.content),
|
|
784
889
|
attribution: "user",
|
|
785
890
|
timestamp: m.timestamp,
|
|
786
891
|
},
|
|
787
892
|
];
|
|
788
|
-
case "fileMention": {
|
|
789
|
-
// One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
|
|
790
|
-
// in the same turn (`generateFileMentionMessages` packs every `@…` into a
|
|
791
|
-
// single message). Splitting by image presence keeps text-only mentions on
|
|
792
|
-
// the higher-priority `developer` slot while routing image attachments
|
|
793
|
-
// through `user`, the only Responses content slot that legitimately accepts
|
|
794
|
-
// `input_image` (Codex chatgpt.com /codex/responses rejects everything else
|
|
795
|
-
// with `Invalid value: 'input_image'`, #3443).
|
|
796
|
-
const wrap = (file: FileMentionMessage["files"][number]): string => {
|
|
797
|
-
const inner = file.content ? `\n${file.content}\n` : "\n";
|
|
798
|
-
return `<file path="${file.path}">${inner}</file>`;
|
|
799
|
-
};
|
|
800
|
-
const textFiles = m.files.filter(file => !file.image);
|
|
801
|
-
const imageFiles = m.files.filter(file => file.image);
|
|
802
|
-
const out: Message[] = [];
|
|
803
|
-
if (textFiles.length > 0) {
|
|
804
|
-
out.push({
|
|
805
|
-
role: "developer",
|
|
806
|
-
content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
|
|
807
|
-
attribution: "user",
|
|
808
|
-
timestamp: m.timestamp,
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
if (imageFiles.length > 0) {
|
|
812
|
-
const content: (TextContent | ImageContent)[] = [
|
|
813
|
-
{ type: "text" as const, text: imageFiles.map(wrap).join("\n") },
|
|
814
|
-
];
|
|
815
|
-
for (const file of imageFiles) {
|
|
816
|
-
if (file.image) content.push(file.image);
|
|
817
|
-
}
|
|
818
|
-
out.push({
|
|
819
|
-
role: "user",
|
|
820
|
-
content,
|
|
821
|
-
attribution: "user",
|
|
822
|
-
timestamp: m.timestamp,
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
return out;
|
|
826
|
-
}
|
|
827
|
-
case "custom": {
|
|
828
|
-
if (!isCustomMessageContent(m.content)) return [];
|
|
829
|
-
if (isUserInvokedSkillPrompt(m)) {
|
|
830
|
-
return [
|
|
831
|
-
{
|
|
832
|
-
role: "user",
|
|
833
|
-
content: customMessageContentToLlmContent(m.content),
|
|
834
|
-
attribution: "user",
|
|
835
|
-
timestamp: m.timestamp,
|
|
836
|
-
},
|
|
837
|
-
];
|
|
838
|
-
}
|
|
839
|
-
const split = convertImageBearingCustomMessage(m);
|
|
840
|
-
if (split) return split;
|
|
841
|
-
const converted = convertMessageToLlm(m);
|
|
842
|
-
return converted ? [converted] : [];
|
|
843
|
-
}
|
|
844
|
-
case "hookMessage": {
|
|
845
|
-
if (!isCustomMessageContent(m.content)) return [];
|
|
846
|
-
const split = convertImageBearingCustomMessage(m);
|
|
847
|
-
if (split) return split;
|
|
848
|
-
const converted = convertMessageToLlm(m);
|
|
849
|
-
return converted ? [converted] : [];
|
|
850
893
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// resend, so strip it here — LLM path only — when the hidden
|
|
856
|
-
// interrupted-thinking continuity message follows.
|
|
857
|
-
const source = followedByInterruptedThinking(messages, index) ? stripDemotedThinkingForLlm(m) : m;
|
|
858
|
-
const converted = convertMessageToLlm(source);
|
|
859
|
-
return converted ? [converted] : [];
|
|
860
|
-
}
|
|
861
|
-
case "branchSummary":
|
|
862
|
-
case "compactionSummary":
|
|
863
|
-
case "user":
|
|
864
|
-
case "developer":
|
|
865
|
-
case "toolResult": {
|
|
866
|
-
// Core roles share one transformer with agent-core —
|
|
867
|
-
// duplicating them here is how snapcompact frames once
|
|
868
|
-
// silently fell off the provider request.
|
|
869
|
-
const converted = convertMessageToLlm(m);
|
|
870
|
-
return converted ? [converted] : [];
|
|
871
|
-
}
|
|
872
|
-
default:
|
|
873
|
-
m satisfies never;
|
|
874
|
-
return [];
|
|
894
|
+
const split = convertImageBearingCustomMessage(m);
|
|
895
|
+
if (split) return split;
|
|
896
|
+
const converted = convertMessageToLlm(m);
|
|
897
|
+
return converted ? [converted] : [];
|
|
875
898
|
}
|
|
876
|
-
|
|
899
|
+
case "hookMessage": {
|
|
900
|
+
if (!isCustomMessageContent(m.content)) return [];
|
|
901
|
+
const split = convertImageBearingCustomMessage(m);
|
|
902
|
+
if (split) return split;
|
|
903
|
+
const converted = convertMessageToLlm(m);
|
|
904
|
+
return converted ? [converted] : [];
|
|
905
|
+
}
|
|
906
|
+
case "assistant": {
|
|
907
|
+
// A user-interrupted turn keeps its trailing thinking run on the
|
|
908
|
+
// persisted/displayed message so reload and Ctrl+L rebuilds still
|
|
909
|
+
// show it. That run is incomplete/unsigned and gets rejected on
|
|
910
|
+
// resend, so strip it here — LLM path only — when the hidden
|
|
911
|
+
// interrupted-thinking continuity message follows.
|
|
912
|
+
const source = interruptedNext ? stripDemotedThinkingForLlm(m) : m;
|
|
913
|
+
const converted = convertMessageToLlm(source);
|
|
914
|
+
return converted ? [converted] : [];
|
|
915
|
+
}
|
|
916
|
+
case "branchSummary":
|
|
917
|
+
case "compactionSummary":
|
|
918
|
+
case "user":
|
|
919
|
+
case "developer":
|
|
920
|
+
case "toolResult": {
|
|
921
|
+
// Core roles share one transformer with agent-core —
|
|
922
|
+
// duplicating them here is how snapcompact frames once
|
|
923
|
+
// silently fell off the provider request.
|
|
924
|
+
const converted = convertMessageToLlm(m);
|
|
925
|
+
return converted ? [converted] : [];
|
|
926
|
+
}
|
|
927
|
+
default:
|
|
928
|
+
m satisfies never;
|
|
929
|
+
return [];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Cached per-message conversion. Reuses the stored fragment while identity and
|
|
934
|
+
* `interruptedNext` neighbor state hold; recomputes on a neighbor flip. */
|
|
935
|
+
function convertOneCached(m: AgentMessage, interruptedNext: boolean): Message[] {
|
|
936
|
+
const cached = convertCache.get(m);
|
|
937
|
+
if (cached !== undefined && cached.interruptedNext === interruptedNext) return cached.fragment;
|
|
938
|
+
const fragment = convertOne(m, interruptedNext);
|
|
939
|
+
convertCache.set(m, { interruptedNext, fragment });
|
|
940
|
+
return fragment;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
|
|
945
|
+
*
|
|
946
|
+
* This is used by:
|
|
947
|
+
* - Agent's transormToLlm option (for prompt calls and queued messages)
|
|
948
|
+
* - Compaction's generateSummary (for summarization)
|
|
949
|
+
* - Custom extensions and tools
|
|
950
|
+
*
|
|
951
|
+
* Settled history converts once and is reused per message identity: an
|
|
952
|
+
* append-only turn on the same array re-pays only the new suffix, and an
|
|
953
|
+
* unchanged re-convert of the same array hands back the same outer `Message[]`.
|
|
954
|
+
* Owner mutations (prune/shake/strip-images) invalidate the affected message
|
|
955
|
+
* through the shared registry before the next pass.
|
|
956
|
+
*/
|
|
957
|
+
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
958
|
+
const len = messages.length;
|
|
959
|
+
const sameArray = messages === lastConvertInput && lastConvertGeneration === convertGeneration;
|
|
960
|
+
const tail = len > 0 ? messages[len - 1] : undefined;
|
|
961
|
+
|
|
962
|
+
// Exact-repeat: same array, same length, same trailing identity → reuse the
|
|
963
|
+
// outer array. The tail-identity check rejects the streaming snapshot swap
|
|
964
|
+
// (partial → settled trailing keeps array identity/length but mints a fresh
|
|
965
|
+
// tail), so a settled tail never reads a stale mid-stream fragment.
|
|
966
|
+
if (sameArray && lastConvertOutput !== undefined && len === lastConvertLength && tail === lastConvertTail) {
|
|
967
|
+
return lastConvertOutput;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Slice-on-growth: same array grew by append. Every interior message is
|
|
971
|
+
// immutable under one array identity, so copy the unchanged prefix output
|
|
972
|
+
// (messages[0 .. lastLen-1)) and reconvert only the old boundary message
|
|
973
|
+
// (neighbor-sensitive: a following interrupted-thinking marker may now exist)
|
|
974
|
+
// plus the appended suffix. The boundary-identity check (old tail still sits
|
|
975
|
+
// at its old index) rejects an in-place interior splice-replace that grew the
|
|
976
|
+
// array while swapping earlier identities, forcing a full rebuild.
|
|
977
|
+
let out: Message[];
|
|
978
|
+
let start: number;
|
|
979
|
+
if (
|
|
980
|
+
sameArray &&
|
|
981
|
+
lastConvertOutput !== undefined &&
|
|
982
|
+
len > lastConvertLength &&
|
|
983
|
+
lastConvertLength > 0 &&
|
|
984
|
+
messages[lastConvertLength - 1] === lastConvertTail &&
|
|
985
|
+
lastConvertPrefixOutputLen <= lastConvertOutput.length
|
|
986
|
+
) {
|
|
987
|
+
out = lastConvertOutput.slice(0, lastConvertPrefixOutputLen);
|
|
988
|
+
start = lastConvertLength - 1;
|
|
989
|
+
} else {
|
|
990
|
+
out = [];
|
|
991
|
+
start = 0;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Output length contributed by messages[0 .. len-1), captured when the loop
|
|
995
|
+
// reaches the final index so the next growth can reuse this prefix.
|
|
996
|
+
let prefixOutputLen = 0;
|
|
997
|
+
for (let i = start; i < len; i++) {
|
|
998
|
+
if (i === len - 1) prefixOutputLen = out.length;
|
|
999
|
+
const m = messages[i];
|
|
1000
|
+
const interruptedNext = m.role === "assistant" && followedByInterruptedThinking(messages, i);
|
|
1001
|
+
const fragment = convertOneCached(m, interruptedNext);
|
|
1002
|
+
for (const msg of fragment) out.push(msg);
|
|
1003
|
+
}
|
|
1004
|
+
if (len === 0) prefixOutputLen = 0;
|
|
1005
|
+
|
|
1006
|
+
// Record for the next call's shortcuts. `out` is a fresh array (slice or new),
|
|
1007
|
+
// so a prior caller holding the previous `lastConvertOutput` never sees it grow.
|
|
1008
|
+
lastConvertInput = messages;
|
|
1009
|
+
lastConvertLength = len;
|
|
1010
|
+
lastConvertOutput = out;
|
|
1011
|
+
lastConvertGeneration = convertGeneration;
|
|
1012
|
+
lastConvertTail = tail;
|
|
1013
|
+
lastConvertPrefixOutputLen = prefixOutputLen;
|
|
1014
|
+
return out;
|
|
877
1015
|
}
|
|
@@ -316,6 +316,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
316
316
|
allowArgs: true,
|
|
317
317
|
getTuiAutocompleteDescription: runtime => {
|
|
318
318
|
if (!runtime.ctx.loopModeEnabled) return "Loop: off";
|
|
319
|
+
if (runtime.ctx.loopModePaused) return "Loop: paused";
|
|
319
320
|
if (runtime.ctx.loopLimit) return `Loop: on (${describeLoopLimitRuntime(runtime.ctx.loopLimit)})`;
|
|
320
321
|
if (runtime.ctx.loopPrompt) return "Loop: on (repeating prompt)";
|
|
321
322
|
return "Loop: on (waiting for next prompt)";
|