@oh-my-pi/pi-coding-agent 16.0.10 → 16.1.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/CHANGELOG.md +57 -0
- package/dist/cli.js +3344 -3371
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +52 -0
- package/dist/types/commit/agentic/agent.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +14 -8
- 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/__tests__/skill-message.test.d.ts +1 -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/assistant-message.d.ts +8 -0
- package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
- package/dist/types/modes/components/index.d.ts +0 -1
- package/dist/types/modes/components/message-frame.d.ts +6 -4
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +4 -2
- package/dist/types/modes/theme/theme.d.ts +7 -1
- package/dist/types/modes/types.d.ts +9 -2
- package/dist/types/registry/agent-registry.d.ts +10 -3
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +20 -1
- package/dist/types/session/compact-modes.d.ts +60 -0
- package/dist/types/session/session-context.d.ts +7 -0
- package/dist/types/session/session-dump-format.d.ts +1 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/session/tool-choice-queue.d.ts +14 -0
- package/dist/types/system-prompt.d.ts +3 -3
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/resolve.d.ts +15 -5
- package/package.json +12 -12
- package/src/advisor/index.ts +1 -0
- package/src/advisor/transcript-recorder.ts +136 -0
- package/src/cli/stats-cli.ts +2 -11
- package/src/collab/host.ts +25 -13
- 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/settings-schema.ts +16 -9
- package/src/config/settings.ts +0 -6
- package/src/debug/log-viewer.ts +4 -4
- package/src/debug/raw-sse.ts +4 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/renderer.ts +9 -9
- 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/lsp/render.ts +7 -7
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +63 -0
- package/src/modes/components/__tests__/skill-message.test.ts +92 -0
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/agent-hub.ts +97 -920
- package/src/modes/components/agent-transcript-viewer.ts +461 -0
- package/src/modes/components/assistant-message.ts +21 -0
- package/src/modes/components/cache-invalidation-marker.ts +84 -0
- package/src/modes/components/chat-transcript-builder.ts +476 -0
- package/src/modes/components/compaction-summary-message.ts +29 -1
- package/src/modes/components/custom-message.ts +4 -1
- package/src/modes/components/diff.ts +12 -35
- package/src/modes/components/dynamic-border.ts +1 -1
- package/src/modes/components/extensions/extension-dashboard.ts +1 -1
- package/src/modes/components/extensions/inspector-panel.ts +5 -5
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/index.ts +0 -1
- package/src/modes/components/message-frame.ts +10 -6
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/overlay-box.ts +10 -9
- package/src/modes/components/skill-message.ts +39 -19
- package/src/modes/components/tiny-title-download-progress.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/event-controller.ts +15 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/controllers/selector-controller.ts +11 -1
- package/src/modes/interactive-mode.ts +13 -3
- package/src/modes/theme/theme.ts +14 -0
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/ui-helpers.ts +20 -2
- package/src/prompts/steering/user-interjection.md +3 -4
- package/src/prompts/tools/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +9 -7
- package/src/session/agent-session.ts +182 -16
- package/src/session/compact-modes.ts +105 -0
- package/src/session/messages.ts +7 -9
- package/src/session/session-context.ts +54 -7
- package/src/session/session-dump-format.ts +4 -2
- package/src/session/session-history-format.ts +1 -1
- package/src/session/snapcompact-inline.ts +2 -2
- package/src/session/streaming-output.ts +5 -5
- package/src/session/tool-choice-queue.ts +59 -0
- package/src/slash-commands/builtin-registry.ts +16 -4
- package/src/system-prompt.ts +10 -9
- package/src/task/executor.ts +1 -1
- package/src/task/output-manager.ts +5 -0
- package/src/tools/__tests__/json-tree.test.ts +35 -0
- package/src/tools/approval.ts +1 -1
- package/src/tools/bash-interactive.ts +4 -4
- 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 +4 -0
- package/src/tools/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- package/src/tools/resolve.ts +66 -41
- package/src/tui/output-block.ts +9 -9
- 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
- package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
- package/src/modes/components/branch-summary-message.ts +0 -46
|
@@ -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
|
+
}
|
package/src/session/messages.ts
CHANGED
|
@@ -204,16 +204,14 @@ function wrapSteeringUserMessage(message: UserMessage): UserMessage {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
207
|
+
// Wrap EVERY steering message, not just a trailing run. The wire bytes of a
|
|
208
|
+
// steering message must be a pure function of the message itself, independent
|
|
209
|
+
// of its position in the array. When only the trailing steer was wrapped, the
|
|
210
|
+
// same persisted message was sent enveloped while it was the tail and raw once
|
|
211
|
+
// the assistant's reply buried it — rewriting already-cached prefix bytes and
|
|
212
|
+
// busting the provider prompt cache from that message onward on the next turn.
|
|
215
213
|
let wrappedMessages: AgentMessage[] | undefined;
|
|
216
|
-
for (let i =
|
|
214
|
+
for (let i = 0; i < messages.length; i++) {
|
|
217
215
|
const message = messages[i];
|
|
218
216
|
if (!isSteeringUserMessage(message)) continue;
|
|
219
217
|
const wrappedMessage = wrapSteeringUserMessage(message);
|
|
@@ -20,6 +20,13 @@ export interface SessionContext {
|
|
|
20
20
|
mode: string;
|
|
21
21
|
/** Mode-specific data from the last mode_change entry */
|
|
22
22
|
modeData?: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Array parallel to messages, indicating which assistant turns should
|
|
25
|
+
* have their prompt-cache misses suppressed/explained (because a model,
|
|
26
|
+
* compaction, or plan-mode transition directly preceded them).
|
|
27
|
+
* Only populated in transcript mode.
|
|
28
|
+
*/
|
|
29
|
+
cacheMissExplainedAt?: boolean[];
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** Lists session model strings to try when restoring, in fallback order. */
|
|
@@ -191,12 +198,45 @@ export function buildSessionContext(
|
|
|
191
198
|
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
192
199
|
// 3. Emit messages after compaction
|
|
193
200
|
const messages: AgentMessage[] = [];
|
|
201
|
+
const cacheMissExplainedAt: boolean[] = [];
|
|
202
|
+
let pendingReset = false;
|
|
203
|
+
let currentMode = "none";
|
|
204
|
+
let lastAssistantModel: string | undefined;
|
|
205
|
+
|
|
206
|
+
const handleEntryResetTracking = (entry: SessionEntry) => {
|
|
207
|
+
if (entry.type === "compaction") {
|
|
208
|
+
pendingReset = true;
|
|
209
|
+
} else if (entry.type === "model_change") {
|
|
210
|
+
pendingReset = true;
|
|
211
|
+
} else if (entry.type === "mode_change") {
|
|
212
|
+
const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan");
|
|
213
|
+
if (isPlanTransition) {
|
|
214
|
+
pendingReset = true;
|
|
215
|
+
}
|
|
216
|
+
currentMode = entry.mode;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const pushMessage = (msg: AgentMessage) => {
|
|
221
|
+
messages.push(msg);
|
|
222
|
+
if (!options?.transcript) return;
|
|
223
|
+
if (msg.role === "assistant") {
|
|
224
|
+
const currentModel = `${msg.provider}/${msg.model}`;
|
|
225
|
+
const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel;
|
|
226
|
+
lastAssistantModel = currentModel;
|
|
227
|
+
cacheMissExplainedAt.push(pendingReset || modelChanged);
|
|
228
|
+
pendingReset = false;
|
|
229
|
+
} else {
|
|
230
|
+
cacheMissExplainedAt.push(false);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
194
233
|
|
|
195
234
|
const appendMessage = (entry: SessionEntry) => {
|
|
235
|
+
handleEntryResetTracking(entry);
|
|
196
236
|
if (entry.type === "message") {
|
|
197
|
-
|
|
237
|
+
pushMessage(entry.message);
|
|
198
238
|
} else if (entry.type === "custom_message") {
|
|
199
|
-
|
|
239
|
+
pushMessage(
|
|
200
240
|
createCustomMessage(
|
|
201
241
|
entry.customType,
|
|
202
242
|
entry.content,
|
|
@@ -207,7 +247,7 @@ export function buildSessionContext(
|
|
|
207
247
|
),
|
|
208
248
|
);
|
|
209
249
|
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
210
|
-
|
|
250
|
+
pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
211
251
|
}
|
|
212
252
|
};
|
|
213
253
|
|
|
@@ -217,16 +257,18 @@ export function buildSessionContext(
|
|
|
217
257
|
// TUI) at the point it fired, with any snapcompact frames re-attached so
|
|
218
258
|
// the component can report them.
|
|
219
259
|
for (const entry of path) {
|
|
260
|
+
handleEntryResetTracking(entry);
|
|
220
261
|
if (entry.type === "compaction") {
|
|
221
262
|
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
|
|
222
|
-
|
|
263
|
+
pushMessage(
|
|
223
264
|
createCompactionSummaryMessage(
|
|
224
265
|
entry.summary,
|
|
225
266
|
entry.tokensBefore,
|
|
226
267
|
entry.timestamp,
|
|
227
268
|
entry.shortSummary,
|
|
228
269
|
undefined,
|
|
229
|
-
|
|
270
|
+
undefined,
|
|
271
|
+
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
|
|
230
272
|
),
|
|
231
273
|
);
|
|
232
274
|
} else {
|
|
@@ -251,14 +293,15 @@ export function buildSessionContext(
|
|
|
251
293
|
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
252
294
|
// model can keep reading the archived history after every context rebuild.
|
|
253
295
|
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
254
|
-
|
|
296
|
+
pushMessage(
|
|
255
297
|
createCompactionSummaryMessage(
|
|
256
298
|
compaction.summary,
|
|
257
299
|
compaction.tokensBefore,
|
|
258
300
|
compaction.timestamp,
|
|
259
301
|
compaction.shortSummary,
|
|
260
302
|
providerPayload,
|
|
261
|
-
|
|
303
|
+
undefined,
|
|
304
|
+
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
|
|
262
305
|
),
|
|
263
306
|
);
|
|
264
307
|
|
|
@@ -333,6 +376,9 @@ export function buildSessionContext(
|
|
|
333
376
|
);
|
|
334
377
|
if (normalized.length === 0) {
|
|
335
378
|
messages.splice(i, 1);
|
|
379
|
+
if (options?.transcript) {
|
|
380
|
+
cacheMissExplainedAt.splice(i, 1);
|
|
381
|
+
}
|
|
336
382
|
} else {
|
|
337
383
|
messages[i] = { ...message, content: normalized };
|
|
338
384
|
}
|
|
@@ -340,6 +386,7 @@ export function buildSessionContext(
|
|
|
340
386
|
|
|
341
387
|
return {
|
|
342
388
|
messages,
|
|
389
|
+
cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
|
|
343
390
|
thinkingLevel,
|
|
344
391
|
serviceTier,
|
|
345
392
|
models,
|
|
@@ -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 {
|
|
@@ -38,6 +38,7 @@ export interface FormatSessionDumpTextOptions {
|
|
|
38
38
|
model?: Model | null;
|
|
39
39
|
thinkingLevel?: ThinkingLevel | string | null;
|
|
40
40
|
tools?: readonly SessionDumpToolInfo[];
|
|
41
|
+
inlineToolDescriptors?: boolean;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
interface InventoryTool {
|
|
@@ -78,7 +79,8 @@ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools:
|
|
|
78
79
|
lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`);
|
|
79
80
|
lines.push("\n");
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
const hasSystemPromptToolInventory = options.inlineToolDescriptors === true;
|
|
83
|
+
if (inventoryTools.length > 0 && !hasSystemPromptToolInventory) {
|
|
82
84
|
lines.push("## Available Tools\n");
|
|
83
85
|
lines.push(renderToolInventory(inventoryTools, model?.id ?? ""));
|
|
84
86
|
lines.push("\n");
|
|
@@ -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,
|
|
@@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = (
|
|
|
46
46
|
// Per-provider image-count budgets live in @oh-my-pi/snapcompact
|
|
47
47
|
// (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so
|
|
48
48
|
// dimension/size limits never bind; only COUNT does. Once the budget is
|
|
49
|
-
// spent
|
|
50
|
-
//
|
|
49
|
+
// spent by already-attached archive/system-prompt images, tool results ship
|
|
50
|
+
// verbatim as text.
|
|
51
51
|
const MAX_SYSTEM_PROMPT_FRAMES = 6;
|
|
52
52
|
/** Tool results under this many tokens are never rasterized — the swap can't
|
|
53
53
|
* save enough to justify trading crisp text for an image. */
|
|
@@ -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);
|
|
@@ -65,6 +65,20 @@ interface InFlight {
|
|
|
65
65
|
invoked: boolean;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A non-forcing pending preview invoker. Registered by `queueResolveHandler`
|
|
70
|
+
* (resolve previews) so the `resolve` tool can dispatch to a staged action
|
|
71
|
+
* WITHOUT this queue forcing `tool_choice`. The agent-loop's
|
|
72
|
+
* SoftToolRequirement lifecycle (remind-then-escalate) owns any forcing.
|
|
73
|
+
*/
|
|
74
|
+
interface PendingInvoker {
|
|
75
|
+
/** Unique id for this staged preview; never reused (never clobbered by label). */
|
|
76
|
+
id: string;
|
|
77
|
+
/** Source tool that staged the preview (e.g. "ast_edit"), for the reminder. */
|
|
78
|
+
sourceToolName: string;
|
|
79
|
+
onInvoked: (input: unknown) => Promise<unknown> | unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
// ── Queue ───────────────────────────────────────────────────────────────────
|
|
69
83
|
|
|
70
84
|
export class ToolChoiceQueue {
|
|
@@ -75,6 +89,12 @@ export class ToolChoiceQueue {
|
|
|
75
89
|
* Consumers (e.g. todo reminder suppression) read via consumeLastServedLabel().
|
|
76
90
|
*/
|
|
77
91
|
#lastResolvedLabel: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Non-forcing pending preview invokers, stacked by UNIQUE id. The `resolve`
|
|
94
|
+
* tool dispatches to the head; the agent-loop's soft-tool-requirement
|
|
95
|
+
* lifecycle drives resolution without this queue forcing `tool_choice`.
|
|
96
|
+
*/
|
|
97
|
+
#pendingInvokers: PendingInvoker[] = [];
|
|
78
98
|
|
|
79
99
|
// ── Push ──────────────────────────────────────────────────────────────
|
|
80
100
|
|
|
@@ -190,6 +210,44 @@ export class ToolChoiceQueue {
|
|
|
190
210
|
};
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
// ── Non-forcing pending invokers ──────────────────────────────────────
|
|
214
|
+
// Preview producers (queueResolveHandler) register here so `resolve` can
|
|
215
|
+
// dispatch to a staged action WITHOUT a forced tool_choice (no messages-cache
|
|
216
|
+
// bust). Stacked by UNIQUE id: a re-register replaces only the same id, so
|
|
217
|
+
// concurrent/sequential previews each survive and resolve independently.
|
|
218
|
+
|
|
219
|
+
/** Register (or replace by exact id) a non-forcing pending preview invoker. */
|
|
220
|
+
registerPendingInvoker(
|
|
221
|
+
id: string,
|
|
222
|
+
sourceToolName: string,
|
|
223
|
+
onInvoked: (input: unknown) => Promise<unknown> | unknown,
|
|
224
|
+
): void {
|
|
225
|
+
this.removePendingInvoker(id);
|
|
226
|
+
this.#pendingInvokers.push({ id, sourceToolName, onInvoked });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Drop the pending invoker with this id (e.g. after it resolves). */
|
|
230
|
+
removePendingInvoker(id: string): void {
|
|
231
|
+
this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True when at least one non-forcing pending preview is registered. */
|
|
235
|
+
get hasPendingInvoker(): boolean {
|
|
236
|
+
return this.#pendingInvokers.length > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */
|
|
240
|
+
peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
|
241
|
+
return this.#pendingInvokers.at(-1)?.onInvoked;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The head pending preview's stable id + source tool, for building the agent-level
|
|
245
|
+
* SoftToolRequirement (the id drives reminder re-injection when the head changes). */
|
|
246
|
+
peekPendingHead(): { id: string; sourceToolName: string } | undefined {
|
|
247
|
+
const head = this.#pendingInvokers.at(-1);
|
|
248
|
+
return head ? { id: head.id, sourceToolName: head.sourceToolName } : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
193
251
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
194
252
|
|
|
195
253
|
/** Remove all directives with the given label. Rejects in-flight if it matches. */
|
|
@@ -206,6 +264,7 @@ export class ToolChoiceQueue {
|
|
|
206
264
|
this.reject("cleared");
|
|
207
265
|
}
|
|
208
266
|
this.#queue = [];
|
|
267
|
+
this.#pendingInvokers = [];
|
|
209
268
|
this.#lastResolvedLabel = undefined;
|
|
210
269
|
}
|
|
211
270
|
|
|
@@ -27,6 +27,7 @@ import { resolveMemoryBackend } from "../memory-backend";
|
|
|
27
27
|
import { theme } from "../modes/theme/theme";
|
|
28
28
|
import type { InteractiveModeContext } from "../modes/types";
|
|
29
29
|
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
|
|
30
|
+
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
|
|
30
31
|
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
|
31
32
|
import { urlHyperlinkAlways } from "../tui";
|
|
32
33
|
import { getChangelogPath, parseChangelog } from "../utils/changelog";
|
|
@@ -1257,13 +1258,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1257
1258
|
name: "compact",
|
|
1258
1259
|
description: "Manually compact the session context",
|
|
1259
1260
|
acpDescription: "Compact the conversation",
|
|
1260
|
-
|
|
1261
|
+
subcommands: COMPACT_MODES.map(mode => ({
|
|
1262
|
+
name: mode.name,
|
|
1263
|
+
description: mode.description,
|
|
1264
|
+
usage: mode.rejectsFocus ? undefined : "[focus]",
|
|
1265
|
+
})),
|
|
1266
|
+
acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`,
|
|
1261
1267
|
allowArgs: true,
|
|
1262
1268
|
handle: async (command, runtime) => {
|
|
1269
|
+
const parsed = parseCompactArgs(command.args);
|
|
1270
|
+
if ("error" in parsed) return usage(parsed.error, runtime);
|
|
1263
1271
|
const before = runtime.session.getContextUsage?.();
|
|
1264
1272
|
const beforeTokens = before?.tokens;
|
|
1265
1273
|
try {
|
|
1266
|
-
await runtime.session.compact(
|
|
1274
|
+
await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined);
|
|
1267
1275
|
} catch (err) {
|
|
1268
1276
|
// Compaction precondition failures (no model, already compacted, too
|
|
1269
1277
|
// small) and provider errors propagate as plain Errors; surface them
|
|
@@ -1281,9 +1289,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1281
1289
|
return commandConsumed();
|
|
1282
1290
|
},
|
|
1283
1291
|
handleTui: async (command, runtime) => {
|
|
1284
|
-
const
|
|
1292
|
+
const parsed = parseCompactArgs(command.args);
|
|
1285
1293
|
runtime.ctx.editor.setText("");
|
|
1286
|
-
|
|
1294
|
+
if ("error" in parsed) {
|
|
1295
|
+
runtime.ctx.showWarning(parsed.error);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode);
|
|
1287
1299
|
},
|
|
1288
1300
|
},
|
|
1289
1301
|
{
|
package/src/system-prompt.ts
CHANGED
|
@@ -373,11 +373,11 @@ export interface BuildSystemPromptOptions {
|
|
|
373
373
|
toolNames?: string[];
|
|
374
374
|
/** Text to append to system prompt. */
|
|
375
375
|
appendSystemPrompt?: string;
|
|
376
|
-
/**
|
|
377
|
-
|
|
376
|
+
/** Inline full tool descriptors in the system prompt. Default: true */
|
|
377
|
+
inlineToolDescriptors?: boolean;
|
|
378
378
|
/**
|
|
379
379
|
* Whether provider-native tool calling is active (no owned/in-band syntax).
|
|
380
|
-
* When true and `
|
|
380
|
+
* When true and `inlineToolDescriptors` is false, the inventory renders as a
|
|
381
381
|
* compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true
|
|
382
382
|
*/
|
|
383
383
|
nativeTools?: boolean;
|
|
@@ -433,7 +433,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
433
433
|
customPrompt,
|
|
434
434
|
tools,
|
|
435
435
|
appendSystemPrompt,
|
|
436
|
-
|
|
436
|
+
inlineToolDescriptors: providedInlineToolDescriptors,
|
|
437
437
|
nativeTools = true,
|
|
438
438
|
skillsSettings,
|
|
439
439
|
toolNames: providedToolNames,
|
|
@@ -454,6 +454,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
454
454
|
model,
|
|
455
455
|
personality = "default",
|
|
456
456
|
} = options;
|
|
457
|
+
const inlineToolDescriptors = providedInlineToolDescriptors ?? true;
|
|
457
458
|
const resolvedCwd = cwd ?? getProjectDir();
|
|
458
459
|
|
|
459
460
|
const prepDefaults = {
|
|
@@ -599,10 +600,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
599
600
|
examples: meta?.examples,
|
|
600
601
|
};
|
|
601
602
|
});
|
|
602
|
-
// List mode shows a compact tool-name list; it only applies when
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
const toolListMode = !
|
|
603
|
+
// List mode shows a compact tool-name list; it only applies when descriptors
|
|
604
|
+
// stay in provider-native tool schemas AND native tool calling is active.
|
|
605
|
+
// Otherwise render full `# Tool:` sections inline in the system prompt.
|
|
606
|
+
const toolListMode = !inlineToolDescriptors && nativeTools;
|
|
606
607
|
const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? "");
|
|
607
608
|
|
|
608
609
|
// Filter skills for the rendered system prompt:
|
|
@@ -632,7 +633,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
632
633
|
tools: toolNames,
|
|
633
634
|
toolInfo,
|
|
634
635
|
toolInventory,
|
|
635
|
-
|
|
636
|
+
inlineToolDescriptors,
|
|
636
637
|
toolListMode,
|
|
637
638
|
toolRefs,
|
|
638
639
|
environment,
|
package/src/task/executor.ts
CHANGED
|
@@ -196,7 +196,7 @@ function installSubagentRetryFallbackChain(args: {
|
|
|
196
196
|
function renderIrcPeerRoster(selfId: string): string {
|
|
197
197
|
const peers = AgentRegistry.global()
|
|
198
198
|
.list()
|
|
199
|
-
.filter(ref => ref.id !== selfId && ref.status !== "aborted");
|
|
199
|
+
.filter(ref => ref.id !== selfId && ref.status !== "aborted" && ref.kind !== "advisor");
|
|
200
200
|
if (peers.length === 0) return "- (no other agents)";
|
|
201
201
|
const lines = peers.map(
|
|
202
202
|
peer =>
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* collisions across repeated or nested task invocations.
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs/promises";
|
|
14
|
+
import { ADVISOR_TRANSCRIPT_STEM } from "../advisor/transcript-recorder";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Manages agent output ID allocation to ensure uniqueness.
|
|
@@ -29,6 +30,10 @@ export class AgentOutputManager {
|
|
|
29
30
|
constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) {
|
|
30
31
|
this.#getArtifactsDir = getArtifactsDir;
|
|
31
32
|
this.#parentPrefix = options?.parentPrefix;
|
|
33
|
+
// Reserve the advisor transcript stem: a subagent allocated this id would
|
|
34
|
+
// write `<id>.jsonl`, clobbering the advisor's `__advisor.jsonl` in the same
|
|
35
|
+
// artifacts dir. Reserving bumps such a request to `__advisor-2`.
|
|
36
|
+
this.#taken.add(ADVISOR_TRANSCRIPT_STEM);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
3
|
+
import { formatArgsInline } from "../json-tree";
|
|
4
|
+
|
|
5
|
+
describe("formatArgsInline", () => {
|
|
6
|
+
test("a trailing scalar grows into the available width instead of a fixed cap", () => {
|
|
7
|
+
// Regression: the value used to be hard-capped at 24 columns, so a long
|
|
8
|
+
// note was truncated even when the card had plenty of room (issue: advise
|
|
9
|
+
// preview cut to `note="Your “stric…"`).
|
|
10
|
+
const note = "x".repeat(200);
|
|
11
|
+
const narrow = formatArgsInline({ severity: "concern", note }, 40);
|
|
12
|
+
const wide = formatArgsInline({ severity: "concern", note }, 120);
|
|
13
|
+
expect(Bun.stringWidth(wide)).toBeGreaterThan(Bun.stringWidth(narrow) + 40);
|
|
14
|
+
// Both stay within their budget.
|
|
15
|
+
expect(Bun.stringWidth(narrow)).toBeLessThanOrEqual(40);
|
|
16
|
+
expect(Bun.stringWidth(wide)).toBeLessThanOrEqual(120);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("every key stays visible even when a leading value is long", () => {
|
|
20
|
+
const out = formatArgsInline({ path: "x".repeat(200), pattern: "needle", limit: 5 }, 80);
|
|
21
|
+
expect(out).toContain("path=");
|
|
22
|
+
expect(out).toContain("pattern=");
|
|
23
|
+
expect(out).toContain("limit=");
|
|
24
|
+
expect(Bun.stringWidth(out)).toBeLessThanOrEqual(80);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("short values render fully without truncation markers", () => {
|
|
28
|
+
expect(formatArgsInline({ a: "x", b: 5, c: true }, 80)).toBe('a="x", b=5, c=true');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("hidden meta keys are skipped", () => {
|
|
32
|
+
const out = formatArgsInline({ [INTENT_FIELD]: "noise", __partialJson: "{}", path: "src/foo.ts" }, 80);
|
|
33
|
+
expect(out).toBe('path="src/foo.ts"');
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/tools/approval.ts
CHANGED
|
@@ -159,7 +159,7 @@ export function requiresApproval(
|
|
|
159
159
|
export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string {
|
|
160
160
|
if (value.length <= maxChars) return value;
|
|
161
161
|
const omitted = value.length - maxChars;
|
|
162
|
-
return `${value.slice(0, maxChars)}
|
|
162
|
+
return `${value.slice(0, maxChars)}[…${omitted}ch elided…]`;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
@@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
274
274
|
: truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth);
|
|
275
275
|
const visibleLines = this.#readViewport(innerWidth, maxContentRows);
|
|
276
276
|
const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)];
|
|
277
|
-
const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.
|
|
278
|
-
const borderVertical = this.uiTheme.fg("border", this.uiTheme.
|
|
277
|
+
const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth));
|
|
278
|
+
const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical);
|
|
279
279
|
const boxLine = (line: string) =>
|
|
280
280
|
`${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`;
|
|
281
281
|
return [
|
|
282
|
-
`${this.uiTheme.fg("border", this.uiTheme.
|
|
282
|
+
`${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`,
|
|
283
283
|
boxLine(header),
|
|
284
284
|
...content.map(boxLine),
|
|
285
285
|
boxLine(footer),
|
|
286
|
-
`${this.uiTheme.fg("border", this.uiTheme.
|
|
286
|
+
`${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`,
|
|
287
287
|
];
|
|
288
288
|
}
|
|
289
289
|
|
package/src/tools/bash.ts
CHANGED
|
@@ -485,7 +485,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
485
485
|
// head-retention spill, minimizer miss) may emit more than
|
|
486
486
|
// ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
|
|
487
487
|
const cappedOutputText = await enforceInlineByteCap(outputText, {
|
|
488
|
-
label: "bash output",
|
|
489
488
|
saveArtifact: full => saveBashOriginalArtifact(this.session, full),
|
|
490
489
|
});
|
|
491
490
|
|
package/src/tools/browser.ts
CHANGED
|
@@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
332
332
|
// text inline; the full text stays recoverable via the artifact footer
|
|
333
333
|
// when allocation succeeds.
|
|
334
334
|
const cappedText = await enforceInlineByteCap(textOnly, {
|
|
335
|
-
label: "browser output",
|
|
336
335
|
saveArtifact: full => saveBrowserOutputArtifact(this.session, full),
|
|
337
336
|
});
|
|
338
337
|
details.result = cappedText;
|
package/src/tools/eval.ts
CHANGED
|
@@ -64,7 +64,7 @@ function formatDisplayJsonForText(value: unknown): string {
|
|
|
64
64
|
text = String(value);
|
|
65
65
|
}
|
|
66
66
|
if (text.length > MAX_DISPLAY_TEXT_BYTES) {
|
|
67
|
-
text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n
|
|
67
|
+
text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`;
|
|
68
68
|
}
|
|
69
69
|
return text;
|
|
70
70
|
}
|