@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11
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 +58 -0
- package/dist/cli.js +3402 -3443
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +52 -0
- package/dist/types/collab/host.d.ts +2 -2
- package/dist/types/collab/protocol.d.ts +4 -5
- package/dist/types/commit/agentic/agent.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +11 -2
- package/dist/types/config/settings-schema.d.ts +12 -6
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +6 -1
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/registry/agent-registry.d.ts +10 -3
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/compact-modes.d.ts +60 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- package/dist/types/tools/index.d.ts +9 -1
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/dist/types/utils/qrcode.d.ts +48 -0
- package/package.json +12 -12
- package/src/advisor/index.ts +1 -0
- package/src/advisor/transcript-recorder.ts +136 -0
- package/src/cli/args.ts +7 -1
- package/src/cli/stats-cli.ts +2 -11
- package/src/collab/host.ts +29 -17
- package/src/collab/protocol.ts +48 -15
- package/src/commit/agentic/agent.ts +2 -1
- package/src/commit/agentic/tools/git-file-diff.ts +2 -2
- package/src/commit/changelog/index.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/utils.ts +1 -1
- package/src/config/config-file.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-registry.ts +16 -4
- package/src/config/model-resolver.ts +193 -35
- package/src/config/settings-schema.ts +14 -7
- package/src/config/settings.ts +3 -9
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/eval/js/tool-bridge.ts +3 -2
- package/src/eval/py/prelude.py +3 -2
- package/src/export/html/tool-views.generated.js +28 -28
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/history-protocol.ts +8 -3
- package/src/irc/bus.ts +8 -0
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +6 -3
- package/src/modes/acp/acp-agent.ts +63 -0
- package/src/modes/components/agent-hub.ts +97 -920
- package/src/modes/components/agent-transcript-viewer.ts +461 -0
- package/src/modes/components/chat-transcript-builder.ts +462 -0
- package/src/modes/components/diff.ts +12 -35
- package/src/modes/components/oauth-selector.ts +31 -2
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/types.ts +2 -1
- package/src/prompts/tools/inspect-image.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +27 -8
- package/src/session/agent-session.ts +185 -17
- package/src/session/compact-modes.ts +105 -0
- package/src/session/session-dump-format.ts +1 -1
- package/src/session/session-history-format.ts +1 -1
- package/src/session/streaming-output.ts +5 -5
- package/src/slash-commands/builtin-registry.ts +45 -15
- package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
- package/src/task/executor.ts +1 -1
- package/src/task/output-manager.ts +5 -0
- package/src/thinking.ts +25 -5
- package/src/tools/__tests__/json-tree.test.ts +35 -0
- package/src/tools/approval.ts +1 -1
- package/src/tools/bash.ts +0 -1
- package/src/tools/browser.ts +0 -1
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh.ts +1 -1
- package/src/tools/index.ts +10 -1
- package/src/tools/inspect-image.ts +72 -9
- package/src/tools/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- package/src/utils/file-mentions.ts +5 -2
- package/src/utils/image-loading.ts +58 -0
- package/src/utils/qrcode.ts +535 -0
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/github.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/metacpan.ts +2 -2
- package/src/web/scrapers/nvd.ts +2 -2
- package/src/web/scrapers/ollama.ts +1 -1
- package/src/web/scrapers/opencorporates.ts +1 -1
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/repology.ts +1 -1
- package/src/web/scrapers/sourcegraph.ts +1 -1
- package/src/web/scrapers/terraform.ts +6 -6
- package/src/web/scrapers/wikidata.ts +2 -2
- package/src/workspace-tree.ts +1 -1
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type ShakeRegion,
|
|
63
63
|
type SummaryOptions,
|
|
64
64
|
shouldCompact,
|
|
65
|
+
shouldUseOpenAiRemoteCompaction,
|
|
65
66
|
} from "@oh-my-pi/pi-agent-core/compaction";
|
|
66
67
|
import {
|
|
67
68
|
DEFAULT_PRUNE_CONFIG,
|
|
@@ -116,6 +117,7 @@ import {
|
|
|
116
117
|
prompt,
|
|
117
118
|
relativePathWithinRoot,
|
|
118
119
|
Snowflake,
|
|
120
|
+
withTimeout,
|
|
119
121
|
} from "@oh-my-pi/pi-utils";
|
|
120
122
|
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
121
123
|
import {
|
|
@@ -125,6 +127,7 @@ import {
|
|
|
125
127
|
type AdvisorNote,
|
|
126
128
|
AdvisorRuntime,
|
|
127
129
|
type AdvisorSeverity,
|
|
130
|
+
AdvisorTranscriptRecorder,
|
|
128
131
|
formatAdvisorBatchContent,
|
|
129
132
|
isAdvisorInterruptImmuneTurnActive,
|
|
130
133
|
isInterruptingSeverity,
|
|
@@ -235,6 +238,7 @@ import {
|
|
|
235
238
|
AUTO_THINKING,
|
|
236
239
|
type ConfiguredThinkingLevel,
|
|
237
240
|
clampAutoThinkingEffort,
|
|
241
|
+
parseConfiguredThinkingLevel,
|
|
238
242
|
resolveProvisionalAutoLevel,
|
|
239
243
|
resolveThinkingLevelForModel,
|
|
240
244
|
shouldDisableReasoning,
|
|
@@ -275,6 +279,7 @@ import {
|
|
|
275
279
|
shouldEvaluateCodexAutoRedeem,
|
|
276
280
|
shouldPromptCodexAutoRedeem,
|
|
277
281
|
} from "./codex-auto-reset";
|
|
282
|
+
import { findCompactMode } from "./compact-modes";
|
|
278
283
|
import {
|
|
279
284
|
type BashExecutionMessage,
|
|
280
285
|
type CustomMessage,
|
|
@@ -511,6 +516,13 @@ export interface AgentSessionConfig {
|
|
|
511
516
|
advisorReadOnlyTools?: AgentTool[];
|
|
512
517
|
/** Preloaded watchdog prompt content for the advisor. */
|
|
513
518
|
advisorWatchdogPrompt?: string;
|
|
519
|
+
/**
|
|
520
|
+
* Disconnect this session's OWNED MCP manager on dispose. Provided only when
|
|
521
|
+
* the session created the manager (top-level sessions); subagents reuse a
|
|
522
|
+
* parent's manager via `options.mcpManager` and omit this so a child's
|
|
523
|
+
* teardown never tears down the shared servers.
|
|
524
|
+
*/
|
|
525
|
+
disconnectOwnedMcpManager?: () => Promise<void>;
|
|
514
526
|
}
|
|
515
527
|
|
|
516
528
|
/** Options for AgentSession.prompt() */
|
|
@@ -664,10 +676,16 @@ interface ActiveRetryFallbackState {
|
|
|
664
676
|
pinned: boolean;
|
|
665
677
|
}
|
|
666
678
|
|
|
667
|
-
function parseRetryFallbackSelector(
|
|
679
|
+
function parseRetryFallbackSelector(
|
|
680
|
+
selector: string,
|
|
681
|
+
modelLookup?: { find(provider: string, id: string): Model | undefined },
|
|
682
|
+
): RetryFallbackSelector | undefined {
|
|
668
683
|
const trimmed = selector.trim();
|
|
669
684
|
if (!trimmed) return undefined;
|
|
670
|
-
const parsed = parseModelString(trimmed
|
|
685
|
+
const parsed = parseModelString(trimmed, {
|
|
686
|
+
allowMaxAlias: true,
|
|
687
|
+
isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
|
|
688
|
+
});
|
|
671
689
|
if (!parsed) return undefined;
|
|
672
690
|
return {
|
|
673
691
|
raw: trimmed,
|
|
@@ -1095,6 +1113,13 @@ export class AgentSession {
|
|
|
1095
1113
|
#advisorReadOnlyTools?: AgentTool[];
|
|
1096
1114
|
#advisorWatchdogPrompt?: string;
|
|
1097
1115
|
#advisorYieldQueueUnsubscribe?: () => void;
|
|
1116
|
+
/** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
|
|
1117
|
+
* attribution and Agent Hub observability. Undefined when no advisor is active. */
|
|
1118
|
+
#advisorTranscriptRecorder?: AdvisorTranscriptRecorder;
|
|
1119
|
+
/** Unsubscribe for the advisor agent's event stream feeding the recorder. */
|
|
1120
|
+
#advisorAgentUnsubscribe?: () => void;
|
|
1121
|
+
/** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */
|
|
1122
|
+
#advisorRecorderClosed: Promise<void> = Promise.resolve();
|
|
1098
1123
|
#goalTurnCounter = 0;
|
|
1099
1124
|
#planReferenceSent = false;
|
|
1100
1125
|
#planReferencePath = "local://PLAN.md";
|
|
@@ -1195,6 +1220,7 @@ export class AgentSession {
|
|
|
1195
1220
|
| undefined;
|
|
1196
1221
|
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
|
|
1197
1222
|
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
|
|
1223
|
+
#disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
|
|
1198
1224
|
#requestedToolNames: ReadonlySet<string> | undefined;
|
|
1199
1225
|
#baseSystemPrompt: string[];
|
|
1200
1226
|
/**
|
|
@@ -1561,6 +1587,7 @@ export class AgentSession {
|
|
|
1561
1587
|
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
|
1562
1588
|
this.#getMcpServerInstructions = config.getMcpServerInstructions;
|
|
1563
1589
|
this.#reloadSshTool = config.reloadSshTool;
|
|
1590
|
+
this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
|
|
1564
1591
|
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
1565
1592
|
this.#promptModelKey = this.#currentPromptModelKey();
|
|
1566
1593
|
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
|
|
@@ -1691,7 +1718,13 @@ export class AgentSession {
|
|
|
1691
1718
|
* so none of them inject into the new conversation.
|
|
1692
1719
|
*/
|
|
1693
1720
|
#resetAdvisorSessionState(): void {
|
|
1721
|
+
// Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
|
|
1722
|
+
// loop, and that abort can emit an `aborted` message_end we must not attribute to
|
|
1723
|
+
// either session's transcript. Detach, reset, then re-attach the live agent's feed.
|
|
1724
|
+
this.#advisorAgentUnsubscribe?.();
|
|
1725
|
+
this.#advisorAgentUnsubscribe = undefined;
|
|
1694
1726
|
this.#advisorRuntime?.reset();
|
|
1727
|
+
this.#attachAdvisorRecorderFeed();
|
|
1695
1728
|
this.#advisorPrimaryTurnsCompleted = 0;
|
|
1696
1729
|
this.#advisorInterruptImmuneTurnStart = undefined;
|
|
1697
1730
|
this.#advisorAutoResumeSuppressed = false;
|
|
@@ -1824,6 +1857,18 @@ export class AgentSession {
|
|
|
1824
1857
|
};
|
|
1825
1858
|
|
|
1826
1859
|
this.#advisorAgent = advisorAgent;
|
|
1860
|
+
// Persist the advisor's turns to `<session>/__advisor.jsonl` (resolved lazily
|
|
1861
|
+
// so it follows session switches) so its model usage is attributed in stats
|
|
1862
|
+
// and its transcript shows in the Agent Hub — without registering it as a peer.
|
|
1863
|
+
const recorder = new AdvisorTranscriptRecorder(
|
|
1864
|
+
() => this.sessionManager.getSessionFile(),
|
|
1865
|
+
() => this.sessionManager.getCwd(),
|
|
1866
|
+
// On the advisor on→off→on toggle, wait for the prior recorder's close so
|
|
1867
|
+
// two SessionManagers never hold the same __advisor.jsonl at once.
|
|
1868
|
+
this.#advisorRecorderClosed,
|
|
1869
|
+
);
|
|
1870
|
+
this.#advisorTranscriptRecorder = recorder;
|
|
1871
|
+
this.#attachAdvisorRecorderFeed();
|
|
1827
1872
|
this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
|
|
1828
1873
|
snapshotMessages: () => this.agent.state.messages,
|
|
1829
1874
|
enqueueAdvice,
|
|
@@ -1854,10 +1899,21 @@ export class AgentSession {
|
|
|
1854
1899
|
}
|
|
1855
1900
|
|
|
1856
1901
|
#stopAdvisorRuntime(): void {
|
|
1902
|
+
// Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
|
|
1903
|
+
// the loop, and an abort emits a final `message_end` we must not enqueue against
|
|
1904
|
+
// a closing recorder (it would reopen and resurrect an already-released file).
|
|
1905
|
+
this.#advisorAgentUnsubscribe?.();
|
|
1906
|
+
this.#advisorAgentUnsubscribe = undefined;
|
|
1857
1907
|
if (this.#advisorRuntime) {
|
|
1858
1908
|
this.#advisorRuntime.dispose();
|
|
1859
1909
|
this.#advisorRuntime = undefined;
|
|
1860
1910
|
}
|
|
1911
|
+
if (this.#advisorTranscriptRecorder) {
|
|
1912
|
+
// Capture the close so dispose()/`/drop` can await the queued open+append+close —
|
|
1913
|
+
// the last advisor turn would otherwise be lost on a fast process exit.
|
|
1914
|
+
this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close();
|
|
1915
|
+
this.#advisorTranscriptRecorder = undefined;
|
|
1916
|
+
}
|
|
1861
1917
|
if (this.#advisorAgent) {
|
|
1862
1918
|
this.#advisorAgent = undefined;
|
|
1863
1919
|
}
|
|
@@ -1865,6 +1921,18 @@ export class AgentSession {
|
|
|
1865
1921
|
this.#advisorYieldQueueUnsubscribe = undefined;
|
|
1866
1922
|
}
|
|
1867
1923
|
|
|
1924
|
+
/** Subscribe the advisor agent's finalized messages into the transcript recorder.
|
|
1925
|
+
* Idempotent-by-replacement: callers detach the prior feed first. Kept separate
|
|
1926
|
+
* so the re-prime path can mute the feed across an abort-driven reset. */
|
|
1927
|
+
#attachAdvisorRecorderFeed(): void {
|
|
1928
|
+
const agent = this.#advisorAgent;
|
|
1929
|
+
const recorder = this.#advisorTranscriptRecorder;
|
|
1930
|
+
if (!agent || !recorder) return;
|
|
1931
|
+
this.#advisorAgentUnsubscribe = agent.subscribe(event => {
|
|
1932
|
+
if (event.type === "message_end") recorder.record(event.message);
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1868
1936
|
async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
|
|
1869
1937
|
const promotionSettings = this.settings.getGroup("contextPromotion");
|
|
1870
1938
|
if (!promotionSettings.enabled) return false;
|
|
@@ -4043,7 +4111,34 @@ export class AgentSession {
|
|
|
4043
4111
|
await shutdownTinyTitleClient();
|
|
4044
4112
|
this.#releasePowerAssertion();
|
|
4045
4113
|
await this.sessionManager.close();
|
|
4114
|
+
// beginDispose() stopped the advisor and captured its recorder close; await
|
|
4115
|
+
// it so the final advisor turn is flushed before the process may exit.
|
|
4116
|
+
await this.#advisorRecorderClosed;
|
|
4046
4117
|
this.#closeAllProviderSessions("dispose");
|
|
4118
|
+
// Disconnect the MCP manager this session OWNS so its stdio servers are
|
|
4119
|
+
// not orphaned at exit. Best-effort: a failure here must never throw out
|
|
4120
|
+
// of dispose. Only owning (top-level) sessions provide this callback;
|
|
4121
|
+
// subagents reuse a parent's manager and must not tear it down. Idempotent
|
|
4122
|
+
// with the deferred-discovery disconnect in `createAgentSession`.
|
|
4123
|
+
//
|
|
4124
|
+
// BOUNDED: an owned manager may hold an HTTP/SSE server whose session-
|
|
4125
|
+
// termination DELETE blocks up to the MCP request timeout (30s default,
|
|
4126
|
+
// unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()`
|
|
4127
|
+
// unbounded would stall /exit and print-mode shutdown on a broken remote
|
|
4128
|
+
// endpoint. Race it against a short deadline — stdio close (the subprocess
|
|
4129
|
+
// reap this targets) completes well within the bound; a slow transport
|
|
4130
|
+
// close is left to finish detached. Mirrors the bounded async-job teardown.
|
|
4131
|
+
if (this.#disconnectOwnedMcpManager) {
|
|
4132
|
+
try {
|
|
4133
|
+
await withTimeout(
|
|
4134
|
+
this.#disconnectOwnedMcpManager(),
|
|
4135
|
+
3_000,
|
|
4136
|
+
"Timed out disconnecting owned MCP manager during dispose",
|
|
4137
|
+
);
|
|
4138
|
+
} catch (error) {
|
|
4139
|
+
logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4047
4142
|
// Flush the retain queue BEFORE clearing the session's pointer so
|
|
4048
4143
|
// `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
|
|
4049
4144
|
// Reversed, the spliced batch survives just long enough to fail the
|
|
@@ -4938,6 +5033,24 @@ export class AgentSession {
|
|
|
4938
5033
|
return this.agent.state.messages;
|
|
4939
5034
|
}
|
|
4940
5035
|
|
|
5036
|
+
/** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */
|
|
5037
|
+
getImageAttachments(): { label: string; uri: string; image: ImageContent }[] {
|
|
5038
|
+
for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
|
|
5039
|
+
const message = this.agent.state.messages[i];
|
|
5040
|
+
if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) {
|
|
5041
|
+
continue;
|
|
5042
|
+
}
|
|
5043
|
+
const images = message.content.filter((part): part is ImageContent => part.type === "image");
|
|
5044
|
+
if (images.length === 0) continue;
|
|
5045
|
+
return images.map((image, index) => ({
|
|
5046
|
+
label: `Image #${index + 1}`,
|
|
5047
|
+
uri: `attachment://${index + 1}`,
|
|
5048
|
+
image,
|
|
5049
|
+
}));
|
|
5050
|
+
}
|
|
5051
|
+
return [];
|
|
5052
|
+
}
|
|
5053
|
+
|
|
4941
5054
|
buildDisplaySessionContext(): SessionContext {
|
|
4942
5055
|
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
|
|
4943
5056
|
}
|
|
@@ -6538,6 +6651,14 @@ export class AgentSession {
|
|
|
6538
6651
|
this.#closeAllProviderSessions("new session");
|
|
6539
6652
|
this.agent.reset();
|
|
6540
6653
|
if (options?.drop && previousSessionFile) {
|
|
6654
|
+
// Detach the advisor recorder feed and drain its writer BEFORE deleting the
|
|
6655
|
+
// old artifacts dir: `await this.abort()` only stops the primary, so a still-
|
|
6656
|
+
// running advisor turn could otherwise finish, emit `message_end`, and recreate
|
|
6657
|
+
// `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
|
|
6658
|
+
// the advisor and re-attaches the feed at the new session's path.
|
|
6659
|
+
this.#advisorAgentUnsubscribe?.();
|
|
6660
|
+
this.#advisorAgentUnsubscribe = undefined;
|
|
6661
|
+
if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close();
|
|
6541
6662
|
try {
|
|
6542
6663
|
await this.sessionManager.dropSession(previousSessionFile);
|
|
6543
6664
|
} catch (err) {
|
|
@@ -7374,6 +7495,15 @@ export class AgentSession {
|
|
|
7374
7495
|
if (this.#compactionAbortController) {
|
|
7375
7496
|
throw new Error("Compaction already in progress");
|
|
7376
7497
|
}
|
|
7498
|
+
// Resolve the `/compact <mode>` subcommand up front so input validation
|
|
7499
|
+
// runs before we disconnect/abort the active agent operation below.
|
|
7500
|
+
const compactMode = options?.mode ? findCompactMode(options.mode) : undefined;
|
|
7501
|
+
// Modes that produce no LLM summary (snapcompact) have nothing to focus.
|
|
7502
|
+
// Reject focus text loudly so programmatic callers don't silently lose
|
|
7503
|
+
// instructions (the slash path pre-validates via parseCompactArgs).
|
|
7504
|
+
if (compactMode?.rejectsFocus && customInstructions) {
|
|
7505
|
+
throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
|
|
7506
|
+
}
|
|
7377
7507
|
this.#disconnectFromAgent();
|
|
7378
7508
|
await this.abort({ goalReason: "internal" });
|
|
7379
7509
|
const compactionAbortController = new AbortController();
|
|
@@ -7385,8 +7515,26 @@ export class AgentSession {
|
|
|
7385
7515
|
}
|
|
7386
7516
|
|
|
7387
7517
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
7518
|
+
// The `/compact <mode>` override (resolved above) replaces the configured
|
|
7519
|
+
// strategy/remote flags for this one invocation. Merged before
|
|
7520
|
+
// prepareCompaction so the remote gating (preparation.settings.
|
|
7521
|
+
// remoteEnabled/endpoint) and the snapcompact decision below both see it.
|
|
7522
|
+
const effectiveSettings = compactMode
|
|
7523
|
+
? { ...compactionSettings, ...compactMode.overrides }
|
|
7524
|
+
: compactionSettings;
|
|
7525
|
+
if (compactMode?.requiresRemote) {
|
|
7526
|
+
const remoteReady =
|
|
7527
|
+
Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model);
|
|
7528
|
+
if (!remoteReady) {
|
|
7529
|
+
this.emitNotice(
|
|
7530
|
+
"warning",
|
|
7531
|
+
`remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`,
|
|
7532
|
+
"compaction",
|
|
7533
|
+
);
|
|
7534
|
+
}
|
|
7535
|
+
}
|
|
7388
7536
|
const pathEntries = this.sessionManager.getBranch();
|
|
7389
|
-
const preparation = prepareCompaction(pathEntries,
|
|
7537
|
+
const preparation = prepareCompaction(pathEntries, effectiveSettings);
|
|
7390
7538
|
if (!preparation) {
|
|
7391
7539
|
// Check why we can't compact
|
|
7392
7540
|
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
@@ -7425,7 +7573,7 @@ export class AgentSession {
|
|
|
7425
7573
|
// directed LLM summary; a text-only model cannot read the frames back —
|
|
7426
7574
|
// both take the summarizer path (the latter loudly).
|
|
7427
7575
|
const wantsSnapcompact =
|
|
7428
|
-
compactionPrep.kind !== "fromHook" &&
|
|
7576
|
+
compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
|
|
7429
7577
|
const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
|
|
7430
7578
|
if (wantsSnapcompact && !snapcompactReady) {
|
|
7431
7579
|
this.emitNotice(
|
|
@@ -7457,7 +7605,7 @@ export class AgentSession {
|
|
|
7457
7605
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7458
7606
|
const budget =
|
|
7459
7607
|
ctxWindow > 0
|
|
7460
|
-
? ctxWindow - effectiveReserveTokens(ctxWindow,
|
|
7608
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7461
7609
|
: Number.POSITIVE_INFINITY;
|
|
7462
7610
|
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7463
7611
|
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
|
|
@@ -7729,7 +7877,17 @@ export class AgentSession {
|
|
|
7729
7877
|
await this.sessionManager.flush();
|
|
7730
7878
|
this.#cancelOwnAsyncJobs();
|
|
7731
7879
|
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
|
|
7880
|
+
// agent.reset() clears the core steering/follow-up queues. Preserve any queued
|
|
7881
|
+
// steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
|
|
7882
|
+
// pre-loader TUI steer) so they survive into the post-handoff session instead of
|
|
7883
|
+
// being silently dropped. Capture is synchronous immediately before reset and
|
|
7884
|
+
// restore is synchronous immediately after — no await gap — so a steer arriving
|
|
7885
|
+
// later (during ensureOnDisk/Bun.write below) appends to the restored queue
|
|
7886
|
+
// rather than being clobbered.
|
|
7887
|
+
const preservedSteering = this.agent.peekSteeringQueue().slice();
|
|
7888
|
+
const preservedFollowUp = this.agent.peekFollowUpQueue().slice();
|
|
7732
7889
|
this.agent.reset();
|
|
7890
|
+
this.agent.replaceQueues(preservedSteering, preservedFollowUp);
|
|
7733
7891
|
this.#freshProviderSessionId = undefined;
|
|
7734
7892
|
this.#syncAgentSessionId();
|
|
7735
7893
|
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
@@ -8785,14 +8943,20 @@ export class AgentSession {
|
|
|
8785
8943
|
const existingRoleValue = this.settings.getModelRole(role);
|
|
8786
8944
|
if (!existingRoleValue) return modelKey;
|
|
8787
8945
|
|
|
8788
|
-
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings
|
|
8946
|
+
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, {
|
|
8947
|
+
isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined,
|
|
8948
|
+
});
|
|
8789
8949
|
return formatModelSelectorValue(modelKey, thinkingLevel);
|
|
8790
8950
|
}
|
|
8791
8951
|
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
8792
8952
|
const configuredTarget = currentModel.contextPromotionTarget?.trim();
|
|
8793
8953
|
if (!configuredTarget) return undefined;
|
|
8794
8954
|
|
|
8795
|
-
const parsed = parseModelString(configuredTarget
|
|
8955
|
+
const parsed = parseModelString(configuredTarget, {
|
|
8956
|
+
allowMaxAlias: true,
|
|
8957
|
+
isLiteralModelId: (provider, id) =>
|
|
8958
|
+
availableModels.some(model => model.provider === provider && model.id === id),
|
|
8959
|
+
});
|
|
8796
8960
|
if (parsed) {
|
|
8797
8961
|
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
8798
8962
|
if (explicitModel) return explicitModel;
|
|
@@ -9087,7 +9251,6 @@ export class AgentSession {
|
|
|
9087
9251
|
);
|
|
9088
9252
|
}
|
|
9089
9253
|
}
|
|
9090
|
-
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9091
9254
|
// Abort any older auto-compaction before installing this run's controller.
|
|
9092
9255
|
this.#autoCompactionAbortController?.abort();
|
|
9093
9256
|
const autoCompactionAbortController = new AbortController();
|
|
@@ -9095,11 +9258,16 @@ export class AgentSession {
|
|
|
9095
9258
|
const autoCompactionSignal = autoCompactionAbortController.signal;
|
|
9096
9259
|
|
|
9097
9260
|
try {
|
|
9261
|
+
// Emit start AFTER the controller is installed so isCompacting is already true
|
|
9262
|
+
// for any listener — and for input routed during this emit's event-loop yield:
|
|
9263
|
+
// a message typed as the compaction loader appears must land in the compaction
|
|
9264
|
+
// queue, not the core steering queue (which handoff's agent.reset() would wipe).
|
|
9265
|
+
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9098
9266
|
if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
|
|
9099
9267
|
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
|
9100
9268
|
const handoffResult = await this.handoff(handoffFocus, {
|
|
9101
9269
|
autoTriggered: true,
|
|
9102
|
-
signal:
|
|
9270
|
+
signal: autoCompactionSignal,
|
|
9103
9271
|
});
|
|
9104
9272
|
if (!handoffResult) {
|
|
9105
9273
|
const aborted = autoCompactionSignal.aborted;
|
|
@@ -9531,12 +9699,12 @@ export class AgentSession {
|
|
|
9531
9699
|
triggerContextTokens?: number,
|
|
9532
9700
|
): Promise<CompactionCheckResult | "fallback"> {
|
|
9533
9701
|
const action = "shake";
|
|
9534
|
-
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9535
9702
|
this.#autoCompactionAbortController?.abort();
|
|
9536
9703
|
const controller = new AbortController();
|
|
9537
9704
|
this.#autoCompactionAbortController = controller;
|
|
9538
9705
|
const signal = controller.signal;
|
|
9539
9706
|
try {
|
|
9707
|
+
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9540
9708
|
const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
|
|
9541
9709
|
if (signal.aborted) {
|
|
9542
9710
|
await this.#emitSessionEvent({
|
|
@@ -9834,7 +10002,7 @@ export class AgentSession {
|
|
|
9834
10002
|
this.configWarnings.push(msg);
|
|
9835
10003
|
continue;
|
|
9836
10004
|
}
|
|
9837
|
-
const parsed = parseRetryFallbackSelector(selectorStr);
|
|
10005
|
+
const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
|
|
9838
10006
|
if (!parsed) {
|
|
9839
10007
|
const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
|
|
9840
10008
|
logger.warn(msg);
|
|
@@ -9857,7 +10025,7 @@ export class AgentSession {
|
|
|
9857
10025
|
|
|
9858
10026
|
#getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
|
|
9859
10027
|
const configuredSelector = this.settings.getModelRole(role);
|
|
9860
|
-
return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
|
|
10028
|
+
return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
|
|
9861
10029
|
}
|
|
9862
10030
|
|
|
9863
10031
|
#clearActiveRetryFallback(): void {
|
|
@@ -9878,7 +10046,7 @@ export class AgentSession {
|
|
|
9878
10046
|
}
|
|
9879
10047
|
|
|
9880
10048
|
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
|
|
9881
|
-
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
|
10049
|
+
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
|
9882
10050
|
if (!parsedCurrent) return undefined;
|
|
9883
10051
|
const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
|
|
9884
10052
|
const currentPlainSelector = this.model
|
|
@@ -9910,7 +10078,7 @@ export class AgentSession {
|
|
|
9910
10078
|
const chain = [primarySelector];
|
|
9911
10079
|
const seen = new Set<string>([primarySelector.raw]);
|
|
9912
10080
|
for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
|
|
9913
|
-
const parsed = parseRetryFallbackSelector(selector);
|
|
10081
|
+
const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
|
|
9914
10082
|
if (!parsed || seen.has(parsed.raw)) continue;
|
|
9915
10083
|
seen.add(parsed.raw);
|
|
9916
10084
|
chain.push(parsed);
|
|
@@ -9921,7 +10089,7 @@ export class AgentSession {
|
|
|
9921
10089
|
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
|
9922
10090
|
const chain = this.#getRetryFallbackEffectiveChain(role);
|
|
9923
10091
|
if (chain.length <= 1) return [];
|
|
9924
|
-
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
|
10092
|
+
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
|
9925
10093
|
const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
|
|
9926
10094
|
const currentPlainSelector =
|
|
9927
10095
|
this.model && parsedCurrent
|
|
@@ -10018,7 +10186,7 @@ export class AgentSession {
|
|
|
10018
10186
|
originalThinkingLevel,
|
|
10019
10187
|
lastAppliedFallbackThinkingLevel,
|
|
10020
10188
|
} = this.#activeRetryFallback;
|
|
10021
|
-
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
|
|
10189
|
+
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry);
|
|
10022
10190
|
if (!originalSelector) {
|
|
10023
10191
|
this.#clearActiveRetryFallback();
|
|
10024
10192
|
return;
|
|
@@ -11070,7 +11238,7 @@ export class AgentSession {
|
|
|
11070
11238
|
const hasServiceTierEntry = this.sessionManager
|
|
11071
11239
|
.getBranch()
|
|
11072
11240
|
.some(entry => entry.type === "service_tier_change");
|
|
11073
|
-
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
11241
|
+
const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
|
|
11074
11242
|
const configuredServiceTier = this.settings.get("serviceTier");
|
|
11075
11243
|
// Session log entries store only concrete levels. When `auto` has resolved
|
|
11076
11244
|
// for a turn, the persisted context may already carry that concrete level
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual `/compact` subcommands. Kept in a dependency-free leaf module so the
|
|
3
|
+
* slash-command registry, the interactive controllers, and `AgentSession`
|
|
4
|
+
* can all import the mode metadata + parser without pulling in the heavy
|
|
5
|
+
* `agent-session` module graph (which would form an import cycle through the
|
|
6
|
+
* slash-command registry) — same rationale as `shake-types.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Each mode is a one-off override layered on top of the configured
|
|
9
|
+
* `compaction.*` settings for a single invocation; it never mutates settings.
|
|
10
|
+
* Adding a mode is a single entry here: the command surface (autocomplete +
|
|
11
|
+
* ACP hint), the parser, and the engine override all read this table.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Subcommand selecting a one-off compaction mode for manual `/compact`. */
|
|
15
|
+
export type CompactMode = "soft" | "remote" | "snapcompact";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-invocation overrides merged over the configured `compaction.*` settings.
|
|
19
|
+
* Narrowed to the two knobs the modes actually flip; the result stays
|
|
20
|
+
* assignable to the full `CompactionSettings`.
|
|
21
|
+
*/
|
|
22
|
+
export interface CompactionOverride {
|
|
23
|
+
strategy?: "context-full" | "snapcompact";
|
|
24
|
+
remoteEnabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CompactModeDef {
|
|
28
|
+
readonly name: CompactMode;
|
|
29
|
+
/** One-line description surfaced in autocomplete + help. */
|
|
30
|
+
readonly description: string;
|
|
31
|
+
/** Settings overrides applied on top of `compaction.*` for this run. */
|
|
32
|
+
readonly overrides: CompactionOverride;
|
|
33
|
+
/**
|
|
34
|
+
* When true, the mode produces no LLM summary, so trailing focus text is
|
|
35
|
+
* meaningless and rejected by the parser (snapcompact archives history into
|
|
36
|
+
* images without a directed summary).
|
|
37
|
+
*/
|
|
38
|
+
readonly rejectsFocus?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* When true, the mode explicitly demands a remote path; the engine warns and
|
|
41
|
+
* falls back to a local summary if neither a remote endpoint nor a
|
|
42
|
+
* provider-native compaction path is available.
|
|
43
|
+
*/
|
|
44
|
+
readonly requiresRemote?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const COMPACT_MODES: readonly CompactModeDef[] = [
|
|
48
|
+
{
|
|
49
|
+
name: "soft",
|
|
50
|
+
description: "Summarize locally with the active model (skip remote endpoints)",
|
|
51
|
+
overrides: { strategy: "context-full", remoteEnabled: false },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "remote",
|
|
55
|
+
description: "Summarize via the remote endpoint / provider-native compaction",
|
|
56
|
+
overrides: { strategy: "context-full", remoteEnabled: true },
|
|
57
|
+
requiresRemote: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "snapcompact",
|
|
61
|
+
description: "Archive history onto dense bitmap images the model reads back (no LLM call)",
|
|
62
|
+
overrides: { strategy: "snapcompact" },
|
|
63
|
+
rejectsFocus: true,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** Resolve a subcommand token (case-insensitive) to its mode definition. */
|
|
68
|
+
export function findCompactMode(name: string): CompactModeDef | undefined {
|
|
69
|
+
const key = name.trim().toLowerCase();
|
|
70
|
+
return COMPACT_MODES.find(mode => mode.name === key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parsed `/compact` arguments: an optional mode plus optional focus text. */
|
|
74
|
+
export interface ParsedCompactArgs {
|
|
75
|
+
mode?: CompactMode;
|
|
76
|
+
instructions?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Split `/compact` args into a leading mode subcommand + focus instructions.
|
|
81
|
+
*
|
|
82
|
+
* Backward compatible: when the first token is not a known mode, the entire
|
|
83
|
+
* argument string is treated as focus instructions (the historical behavior).
|
|
84
|
+
* A recognized mode with `rejectsFocus` and trailing text is an error.
|
|
85
|
+
*/
|
|
86
|
+
export function parseCompactArgs(args: string): ParsedCompactArgs | { error: string } {
|
|
87
|
+
const trimmed = args.trim();
|
|
88
|
+
if (!trimmed) return {};
|
|
89
|
+
|
|
90
|
+
const spaceIndex = trimmed.search(/\s/);
|
|
91
|
+
const firstToken = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
|
92
|
+
const mode = findCompactMode(firstToken);
|
|
93
|
+
if (!mode) {
|
|
94
|
+
// No recognized mode prefix — keep the whole thing as focus instructions.
|
|
95
|
+
return { instructions: trimmed };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const focus = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim();
|
|
99
|
+
if (mode.rejectsFocus && focus) {
|
|
100
|
+
return {
|
|
101
|
+
error: `/compact ${mode.name} does not take focus instructions (it archives history without an LLM summary).`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { mode: mode.name, instructions: focus || undefined };
|
|
105
|
+
}
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* args), `### Tool Result: <name>`, and the execution/summary sections.
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
|
11
10
|
import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
|
12
11
|
import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
|
12
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
13
13
|
import { YAML } from "bun";
|
|
14
14
|
import { canonicalizeMessage } from "../utils/thinking-display";
|
|
15
15
|
import {
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* as one-liners. No system prompt, no tool catalog, no config sections.
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
|
11
10
|
import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
12
12
|
import type {
|
|
13
13
|
BashExecutionMessage,
|
|
14
14
|
BranchSummaryMessage,
|
|
@@ -495,8 +495,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
|
|
495
495
|
* Returned without surrounding newlines so callers can position it freely.
|
|
496
496
|
*/
|
|
497
497
|
export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
|
|
498
|
-
|
|
499
|
-
|
|
498
|
+
// A 0/1-line elision (e.g. one giant single line) would read as
|
|
499
|
+
// "[…0ln elided…]"; fall back to a byte count there.
|
|
500
|
+
if (elidedLines <= 1) return `[…${elidedBytes}B elided…]`;
|
|
501
|
+
return `[…${elidedLines}ln elided…]`;
|
|
500
502
|
}
|
|
501
503
|
|
|
502
504
|
/**
|
|
@@ -578,8 +580,6 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
|
|
|
578
580
|
export interface InlineByteCapOptions {
|
|
579
581
|
/** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */
|
|
580
582
|
maxBytes?: number;
|
|
581
|
-
/** What the text is, for the elision marker (e.g. "bash output"). */
|
|
582
|
-
label: string;
|
|
583
583
|
/**
|
|
584
584
|
* Persist the full text as a session artifact. When an artifact id is
|
|
585
585
|
* returned, a `[raw output: artifact://<id>]` footer is appended so the
|
|
@@ -619,7 +619,7 @@ export async function enforceInlineByteCap(text: string, options: InlineByteCapO
|
|
|
619
619
|
const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text);
|
|
620
620
|
const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text);
|
|
621
621
|
const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8"));
|
|
622
|
-
const marker = `[
|
|
622
|
+
const marker = `[…${elidedBytes}B elided…]`;
|
|
623
623
|
let composed = `${head}\n${marker}\n${tail}`;
|
|
624
624
|
|
|
625
625
|
const artifactId = await options.saveArtifact?.(text);
|