@oh-my-pi/pi-coding-agent 16.0.10 → 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 +35 -0
- package/dist/cli.js +3208 -3199
- 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 +0 -4
- 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/compact-modes.d.ts +60 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- 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 +0 -5
- package/src/config/settings.ts +0 -6
- 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 +4 -1
- 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/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/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +1 -1
- package/src/session/agent-session.ts +92 -3
- 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 +16 -4
- 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.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/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- 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/src/modes/types.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { CompactOptions } from "../extensibility/extensions/types";
|
|
|
17
17
|
import type { MCPManager } from "../mcp";
|
|
18
18
|
import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
|
|
19
19
|
import type { AgentSession } from "../session/agent-session";
|
|
20
|
+
import type { CompactMode } from "../session/compact-modes";
|
|
20
21
|
import type { HistoryStorage } from "../session/history-storage";
|
|
21
22
|
import type { SessionContext } from "../session/session-context";
|
|
22
23
|
import type { SessionManager } from "../session/session-manager";
|
|
@@ -293,7 +294,7 @@ export interface InteractiveModeContext {
|
|
|
293
294
|
handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
|
|
294
295
|
handleMCPCommand(text: string): Promise<void>;
|
|
295
296
|
handleSSHCommand(text: string): Promise<void>;
|
|
296
|
-
handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome>;
|
|
297
|
+
handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise<CompactionOutcome>;
|
|
297
298
|
handleHandoffCommand(customInstructions?: string): Promise<void>;
|
|
298
299
|
handleShakeCommand(mode: ShakeMode): Promise<void>;
|
|
299
300
|
handleMoveCommand(targetPath: string): Promise<void>;
|
|
@@ -33,7 +33,7 @@ Append `:<sel>` to `path`; bare path = default mode.
|
|
|
33
33
|
- File with explicit selector → lines prefixed with numbers: `41|def alpha():`.
|
|
34
34
|
{{/if}}
|
|
35
35
|
{{/if}}
|
|
36
|
-
- Parseable code without selector → **structural summary**: declarations kept,
|
|
36
|
+
- Parseable code without selector → **structural summary**: declarations kept, body elided with `…`. The footer shows the recovery selector. Re-issue ONLY the ranges you need via the multi-range selector.
|
|
37
37
|
|
|
38
38
|
# Documents & Notebooks
|
|
39
39
|
|
|
@@ -22,7 +22,13 @@ export const MAIN_AGENT_ID = "Main";
|
|
|
22
22
|
* - `aborted`: hard-killed, terminal.
|
|
23
23
|
*/
|
|
24
24
|
export type AgentStatus = "running" | "idle" | "parked" | "aborted";
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* - `main`/`sub`: the user-facing agent tree (driving agent + task subagents).
|
|
27
|
+
* - `advisor`: a passive review transcript persisted like a subagent for usage
|
|
28
|
+
* attribution and Agent Hub observability, but never a peer — hidden from
|
|
29
|
+
* agent-facing rosters (`irc`, `history://`) and not messageable/revivable.
|
|
30
|
+
*/
|
|
31
|
+
export type AgentKind = "main" | "sub" | "advisor";
|
|
26
32
|
|
|
27
33
|
export interface AgentRef {
|
|
28
34
|
id: string;
|
|
@@ -157,11 +163,14 @@ export class AgentRegistry {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
/**
|
|
160
|
-
* Returns every alive agent (running | idle) except the caller.
|
|
161
|
-
*
|
|
166
|
+
* Returns every alive agent (running | idle) except the caller. Advisor refs
|
|
167
|
+
* are observability-only transcripts, never peers, so they are excluded.
|
|
168
|
+
* Flat namespace: every other agent is visible.
|
|
162
169
|
*/
|
|
163
170
|
listVisibleTo(id: string): AgentRef[] {
|
|
164
|
-
return this.list().filter(
|
|
171
|
+
return this.list().filter(
|
|
172
|
+
ref => ref.id !== id && ref.kind !== "advisor" && (ref.status === "running" || ref.status === "idle"),
|
|
173
|
+
);
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
onChange(listener: RegistryListener): () => void {
|
package/src/sdk.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type AgentTelemetryConfig,
|
|
6
6
|
type AgentTool,
|
|
7
7
|
AppendOnlyContextManager,
|
|
8
|
-
INTENT_FIELD,
|
|
9
8
|
type ThinkingLevel,
|
|
10
9
|
} from "@oh-my-pi/pi-agent-core";
|
|
11
10
|
import {
|
|
@@ -35,6 +34,7 @@ import {
|
|
|
35
34
|
prompt,
|
|
36
35
|
Snowflake,
|
|
37
36
|
} from "@oh-my-pi/pi-utils";
|
|
37
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
38
38
|
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor";
|
|
39
39
|
import { type AsyncJob, AsyncJobManager } from "./async";
|
|
40
40
|
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
|
|
@@ -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,
|
|
@@ -126,6 +127,7 @@ import {
|
|
|
126
127
|
type AdvisorNote,
|
|
127
128
|
AdvisorRuntime,
|
|
128
129
|
type AdvisorSeverity,
|
|
130
|
+
AdvisorTranscriptRecorder,
|
|
129
131
|
formatAdvisorBatchContent,
|
|
130
132
|
isAdvisorInterruptImmuneTurnActive,
|
|
131
133
|
isInterruptingSeverity,
|
|
@@ -277,6 +279,7 @@ import {
|
|
|
277
279
|
shouldEvaluateCodexAutoRedeem,
|
|
278
280
|
shouldPromptCodexAutoRedeem,
|
|
279
281
|
} from "./codex-auto-reset";
|
|
282
|
+
import { findCompactMode } from "./compact-modes";
|
|
280
283
|
import {
|
|
281
284
|
type BashExecutionMessage,
|
|
282
285
|
type CustomMessage,
|
|
@@ -1110,6 +1113,13 @@ export class AgentSession {
|
|
|
1110
1113
|
#advisorReadOnlyTools?: AgentTool[];
|
|
1111
1114
|
#advisorWatchdogPrompt?: string;
|
|
1112
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();
|
|
1113
1123
|
#goalTurnCounter = 0;
|
|
1114
1124
|
#planReferenceSent = false;
|
|
1115
1125
|
#planReferencePath = "local://PLAN.md";
|
|
@@ -1708,7 +1718,13 @@ export class AgentSession {
|
|
|
1708
1718
|
* so none of them inject into the new conversation.
|
|
1709
1719
|
*/
|
|
1710
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;
|
|
1711
1726
|
this.#advisorRuntime?.reset();
|
|
1727
|
+
this.#attachAdvisorRecorderFeed();
|
|
1712
1728
|
this.#advisorPrimaryTurnsCompleted = 0;
|
|
1713
1729
|
this.#advisorInterruptImmuneTurnStart = undefined;
|
|
1714
1730
|
this.#advisorAutoResumeSuppressed = false;
|
|
@@ -1841,6 +1857,18 @@ export class AgentSession {
|
|
|
1841
1857
|
};
|
|
1842
1858
|
|
|
1843
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();
|
|
1844
1872
|
this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
|
|
1845
1873
|
snapshotMessages: () => this.agent.state.messages,
|
|
1846
1874
|
enqueueAdvice,
|
|
@@ -1871,10 +1899,21 @@ export class AgentSession {
|
|
|
1871
1899
|
}
|
|
1872
1900
|
|
|
1873
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;
|
|
1874
1907
|
if (this.#advisorRuntime) {
|
|
1875
1908
|
this.#advisorRuntime.dispose();
|
|
1876
1909
|
this.#advisorRuntime = undefined;
|
|
1877
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
|
+
}
|
|
1878
1917
|
if (this.#advisorAgent) {
|
|
1879
1918
|
this.#advisorAgent = undefined;
|
|
1880
1919
|
}
|
|
@@ -1882,6 +1921,18 @@ export class AgentSession {
|
|
|
1882
1921
|
this.#advisorYieldQueueUnsubscribe = undefined;
|
|
1883
1922
|
}
|
|
1884
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
|
+
|
|
1885
1936
|
async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
|
|
1886
1937
|
const promotionSettings = this.settings.getGroup("contextPromotion");
|
|
1887
1938
|
if (!promotionSettings.enabled) return false;
|
|
@@ -4060,6 +4111,9 @@ export class AgentSession {
|
|
|
4060
4111
|
await shutdownTinyTitleClient();
|
|
4061
4112
|
this.#releasePowerAssertion();
|
|
4062
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;
|
|
4063
4117
|
this.#closeAllProviderSessions("dispose");
|
|
4064
4118
|
// Disconnect the MCP manager this session OWNS so its stdio servers are
|
|
4065
4119
|
// not orphaned at exit. Best-effort: a failure here must never throw out
|
|
@@ -6597,6 +6651,14 @@ export class AgentSession {
|
|
|
6597
6651
|
this.#closeAllProviderSessions("new session");
|
|
6598
6652
|
this.agent.reset();
|
|
6599
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();
|
|
6600
6662
|
try {
|
|
6601
6663
|
await this.sessionManager.dropSession(previousSessionFile);
|
|
6602
6664
|
} catch (err) {
|
|
@@ -7433,6 +7495,15 @@ export class AgentSession {
|
|
|
7433
7495
|
if (this.#compactionAbortController) {
|
|
7434
7496
|
throw new Error("Compaction already in progress");
|
|
7435
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
|
+
}
|
|
7436
7507
|
this.#disconnectFromAgent();
|
|
7437
7508
|
await this.abort({ goalReason: "internal" });
|
|
7438
7509
|
const compactionAbortController = new AbortController();
|
|
@@ -7444,8 +7515,26 @@ export class AgentSession {
|
|
|
7444
7515
|
}
|
|
7445
7516
|
|
|
7446
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
|
+
}
|
|
7447
7536
|
const pathEntries = this.sessionManager.getBranch();
|
|
7448
|
-
const preparation = prepareCompaction(pathEntries,
|
|
7537
|
+
const preparation = prepareCompaction(pathEntries, effectiveSettings);
|
|
7449
7538
|
if (!preparation) {
|
|
7450
7539
|
// Check why we can't compact
|
|
7451
7540
|
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
@@ -7484,7 +7573,7 @@ export class AgentSession {
|
|
|
7484
7573
|
// directed LLM summary; a text-only model cannot read the frames back —
|
|
7485
7574
|
// both take the summarizer path (the latter loudly).
|
|
7486
7575
|
const wantsSnapcompact =
|
|
7487
|
-
compactionPrep.kind !== "fromHook" &&
|
|
7576
|
+
compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
|
|
7488
7577
|
const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
|
|
7489
7578
|
if (wantsSnapcompact && !snapcompactReady) {
|
|
7490
7579
|
this.emitNotice(
|
|
@@ -7516,7 +7605,7 @@ export class AgentSession {
|
|
|
7516
7605
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7517
7606
|
const budget =
|
|
7518
7607
|
ctxWindow > 0
|
|
7519
|
-
? ctxWindow - effectiveReserveTokens(ctxWindow,
|
|
7608
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7520
7609
|
: Number.POSITIVE_INFINITY;
|
|
7521
7610
|
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7522
7611
|
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
|
|
@@ -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);
|
|
@@ -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/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
|
/**
|
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
|
}
|
package/src/tools/gh.ts
CHANGED
|
@@ -2179,7 +2179,7 @@ function formatPrFiles(files: GhPrFile[] | undefined): string[] {
|
|
|
2179
2179
|
}
|
|
2180
2180
|
|
|
2181
2181
|
if (files.length > FILE_PREVIEW_LIMIT) {
|
|
2182
|
-
lines.push(
|
|
2182
|
+
lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`);
|
|
2183
2183
|
}
|
|
2184
2184
|
|
|
2185
2185
|
return lines;
|
package/src/tools/irc.ts
CHANGED
|
@@ -182,7 +182,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
|
|
|
182
182
|
const bus = IrcBus.global();
|
|
183
183
|
const peers = registry
|
|
184
184
|
.list()
|
|
185
|
-
.filter(ref => ref.id !== senderId && ref.status !== "aborted")
|
|
185
|
+
.filter(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor")
|
|
186
186
|
.map(ref => ({
|
|
187
187
|
id: ref.id,
|
|
188
188
|
displayName: ref.displayName,
|