@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +3208 -3199
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/commit/agentic/agent.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +0 -4
  7. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  9. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  10. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  11. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  12. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  13. package/dist/types/modes/interactive-mode.d.ts +2 -1
  14. package/dist/types/modes/types.d.ts +2 -1
  15. package/dist/types/registry/agent-registry.d.ts +10 -3
  16. package/dist/types/session/compact-modes.d.ts +60 -0
  17. package/dist/types/session/streaming-output.d.ts +0 -2
  18. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  19. package/package.json +12 -12
  20. package/src/advisor/index.ts +1 -0
  21. package/src/advisor/transcript-recorder.ts +136 -0
  22. package/src/cli/stats-cli.ts +2 -11
  23. package/src/collab/host.ts +25 -13
  24. package/src/commit/agentic/agent.ts +2 -1
  25. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  26. package/src/commit/changelog/index.ts +1 -1
  27. package/src/commit/map-reduce/map-phase.ts +1 -1
  28. package/src/commit/map-reduce/utils.ts +1 -1
  29. package/src/config/settings-schema.ts +0 -5
  30. package/src/config/settings.ts +0 -6
  31. package/src/edit/file-snapshot-store.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/eval/js/tool-bridge.ts +3 -2
  34. package/src/eval/py/prelude.py +3 -2
  35. package/src/export/html/tool-views.generated.js +28 -28
  36. package/src/extensibility/extensions/types.ts +7 -0
  37. package/src/hindsight/mental-models.ts +1 -1
  38. package/src/internal-urls/docs-index.generated.txt +1 -1
  39. package/src/internal-urls/history-protocol.ts +8 -3
  40. package/src/irc/bus.ts +8 -0
  41. package/src/lsp/index.ts +2 -2
  42. package/src/main.ts +4 -1
  43. package/src/modes/acp/acp-agent.ts +63 -0
  44. package/src/modes/components/agent-hub.ts +97 -920
  45. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  46. package/src/modes/components/chat-transcript-builder.ts +462 -0
  47. package/src/modes/components/diff.ts +12 -35
  48. package/src/modes/controllers/command-controller.ts +12 -2
  49. package/src/modes/controllers/event-controller.ts +1 -1
  50. package/src/modes/controllers/input-controller.ts +8 -1
  51. package/src/modes/controllers/selector-controller.ts +4 -1
  52. package/src/modes/interactive-mode.ts +4 -2
  53. package/src/modes/types.ts +2 -1
  54. package/src/prompts/tools/read.md +1 -1
  55. package/src/registry/agent-registry.ts +13 -4
  56. package/src/sdk.ts +1 -1
  57. package/src/session/agent-session.ts +92 -3
  58. package/src/session/compact-modes.ts +105 -0
  59. package/src/session/session-dump-format.ts +1 -1
  60. package/src/session/session-history-format.ts +1 -1
  61. package/src/session/streaming-output.ts +5 -5
  62. package/src/slash-commands/builtin-registry.ts +16 -4
  63. package/src/task/executor.ts +1 -1
  64. package/src/task/output-manager.ts +5 -0
  65. package/src/tools/__tests__/json-tree.test.ts +35 -0
  66. package/src/tools/approval.ts +1 -1
  67. package/src/tools/bash.ts +0 -1
  68. package/src/tools/browser.ts +0 -1
  69. package/src/tools/eval.ts +1 -1
  70. package/src/tools/gh.ts +1 -1
  71. package/src/tools/irc.ts +1 -1
  72. package/src/tools/json-tree.ts +22 -5
  73. package/src/tools/read.ts +5 -6
  74. package/src/web/scrapers/firefox-addons.ts +1 -1
  75. package/src/web/scrapers/github.ts +1 -1
  76. package/src/web/scrapers/go-pkg.ts +2 -2
  77. package/src/web/scrapers/metacpan.ts +2 -2
  78. package/src/web/scrapers/nvd.ts +2 -2
  79. package/src/web/scrapers/ollama.ts +1 -1
  80. package/src/web/scrapers/opencorporates.ts +1 -1
  81. package/src/web/scrapers/pub-dev.ts +1 -1
  82. package/src/web/scrapers/repology.ts +1 -1
  83. package/src/web/scrapers/sourcegraph.ts +1 -1
  84. package/src/web/scrapers/terraform.ts +6 -6
  85. package/src/web/scrapers/wikidata.ts +2 -2
  86. package/src/workspace-tree.ts +1 -1
@@ -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, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. <path>:5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content — NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice.
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
- export type AgentKind = "main" | "sub";
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
- * Flat namespace: every agent can see every other agent.
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(ref => ref.id !== id && (ref.status === "running" || ref.status === "idle"));
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, compactionSettings);
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" && compactionSettings.strategy === "snapcompact" && !customInstructions;
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, compactionSettings)
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
- const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`;
499
- return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`;
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 = `[… elided ${elidedBytes} bytes of ${options.label} …]`;
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
- inlineHint: "[focus instructions]",
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(command.args || undefined);
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 customInstructions = command.args || undefined;
1292
+ const parsed = parseCompactArgs(command.args);
1285
1293
  runtime.ctx.editor.setText("");
1286
- await runtime.ctx.handleCompactCommand(customInstructions);
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
  {
@@ -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
+ });
@@ -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)}… (${omitted} chars truncated)`;
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
 
@@ -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… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`;
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(`- ... ${files.length - FILE_PREVIEW_LIMIT} more files`);
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,