@evandu/super-pi 0.1.145 → 0.1.147

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 (93) hide show
  1. package/agents/super-pi-task/core.ts +74 -2
  2. package/agents/super-pi-task/index.ts +7 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +11 -11
  5. package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
  6. package/dist/client/assets/{dist-DM4Olwfx.js → dist-1Innu17E.js} +1 -1
  7. package/dist/client/assets/{dist-bIDce5j-.js → dist-1lynFOc5.js} +1 -1
  8. package/dist/client/assets/{dist-BlxwDnWr.js → dist-B1qLZVMK.js} +1 -1
  9. package/dist/client/assets/{dist-BTVbZigv.js → dist-B4pz_xdd.js} +1 -1
  10. package/dist/client/assets/{dist-UJ46aPS3.js → dist-B6KwDpeI.js} +1 -1
  11. package/dist/client/assets/{dist-CK2KFFtT.js → dist-BJVI1Unv.js} +1 -1
  12. package/dist/client/assets/{dist-CRnm1rnO.js → dist-BWvzD8Pe.js} +1 -1
  13. package/dist/client/assets/{dist-DSHM-PAs.js → dist-BayWssd0.js} +1 -1
  14. package/dist/client/assets/{dist-B9DE7sc5.js → dist-Bk_pkBVH.js} +1 -1
  15. package/dist/client/assets/{dist-D-o7iQ8c.js → dist-BpeClAv0.js} +1 -1
  16. package/dist/client/assets/{dist-CQFASxyb.js → dist-BsmJg5PX.js} +1 -1
  17. package/dist/client/assets/{dist-Ds5gfeAi.js → dist-Bx7s4rni.js} +1 -1
  18. package/dist/client/assets/{dist-UPJPMjWv.js → dist-CDBKM9tL.js} +1 -1
  19. package/dist/client/assets/{dist-CIgjifH-.js → dist-CGzXCzCG.js} +1 -1
  20. package/dist/client/assets/{dist-BAiV8I_O.js → dist-CRxCwn-Z.js} +1 -1
  21. package/dist/client/assets/{dist-Br-AuQUG.js → dist-CTDgUnBG.js} +1 -1
  22. package/dist/client/assets/{dist-4JbFTwg2.js → dist-D1VfMWmf.js} +1 -1
  23. package/dist/client/assets/{dist-BYOpvW02.js → dist-DTZLNEci.js} +1 -1
  24. package/dist/client/assets/{dist-CQV5kOCE.js → dist-DuUi29km.js} +1 -1
  25. package/dist/client/assets/{dist-CjszAoXk.js → dist-F3S0t1oM.js} +1 -1
  26. package/dist/client/assets/{dist-2boAf_pN.js → dist-QZ-FAVJV.js} +1 -1
  27. package/dist/client/assets/{dist-B1p4YF9C.js → dist-u0nBg0fZ.js} +1 -1
  28. package/dist/client/assets/index-BMpqviwU.css +1 -0
  29. package/dist/client/assets/index-Dzyp_7sf.js +102 -0
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/active-memory.d.ts +17 -0
  32. package/dist/server/active-memory.js +39 -0
  33. package/dist/server/agents.d.ts +1 -1
  34. package/dist/server/agents.js +3 -1
  35. package/dist/server/application/conversation-service.d.ts +1 -1
  36. package/dist/server/application/conversation-service.js +12 -4
  37. package/dist/server/application/run-service.d.ts +32 -2
  38. package/dist/server/application/run-service.js +296 -40
  39. package/dist/server/application/scheduled-task-service.d.ts +14 -1
  40. package/dist/server/application/scheduled-task-service.js +27 -2
  41. package/dist/server/application/session-orchestrator-service.d.ts +114 -3
  42. package/dist/server/application/session-orchestrator-service.js +406 -35
  43. package/dist/server/application/session-watchdog.d.ts +159 -0
  44. package/dist/server/application/session-watchdog.js +244 -0
  45. package/dist/server/command-handler.js +1 -1
  46. package/dist/server/conversation-usage.d.ts +19 -0
  47. package/dist/server/conversation-usage.js +40 -0
  48. package/dist/server/file-diff.d.ts +3 -0
  49. package/dist/server/file-diff.js +4 -4
  50. package/dist/server/file-index.d.ts +4 -2
  51. package/dist/server/file-index.js +14 -9
  52. package/dist/server/file-name-index.d.ts +5 -2
  53. package/dist/server/file-name-index.js +22 -5
  54. package/dist/server/git.d.ts +3 -3
  55. package/dist/server/git.js +10 -9
  56. package/dist/server/http-server.js +256 -34
  57. package/dist/server/index.js +3 -0
  58. package/dist/server/knowledge-vectors.d.ts +15 -0
  59. package/dist/server/knowledge-vectors.js +118 -37
  60. package/dist/server/knowledge.d.ts +7 -8
  61. package/dist/server/knowledge.js +133 -55
  62. package/dist/server/length-continuation.d.ts +50 -0
  63. package/dist/server/length-continuation.js +55 -0
  64. package/dist/server/model-context-window.d.ts +46 -0
  65. package/dist/server/model-context-window.js +121 -0
  66. package/dist/server/pi-session-events.js +5 -1
  67. package/dist/server/pi-session-pool.d.ts +2 -0
  68. package/dist/server/pi-session-pool.js +16 -0
  69. package/dist/server/pi-session.d.ts +6 -0
  70. package/dist/server/pi-session.js +15 -1
  71. package/dist/server/runtime.d.ts +3 -0
  72. package/dist/server/runtime.js +23 -7
  73. package/dist/server/session-cwd.d.ts +17 -0
  74. package/dist/server/session-cwd.js +43 -0
  75. package/dist/server/session-ledger.d.ts +7 -0
  76. package/dist/server/session-tree-scope.d.ts +125 -0
  77. package/dist/server/session-tree-scope.js +337 -0
  78. package/dist/server/store.d.ts +35 -0
  79. package/dist/server/store.js +87 -2
  80. package/dist/server/task-tool.d.ts +2 -1
  81. package/dist/server/task-tool.js +4 -1
  82. package/dist/server/terminal.d.ts +1 -0
  83. package/dist/server/terminal.js +5 -3
  84. package/dist/server/turn-failure.d.ts +26 -0
  85. package/dist/server/turn-failure.js +67 -1
  86. package/dist/server/types.d.ts +33 -1
  87. package/dist/server/version.d.ts +25 -2
  88. package/dist/server/version.js +98 -26
  89. package/dist/server/ws-server.d.ts +1 -1
  90. package/dist/server/ws-server.js +7 -5
  91. package/package.json +36 -3
  92. package/dist/client/assets/index-BGsP8pt_.js +0 -100
  93. package/dist/client/assets/index-CiIc734l.css +0 -1
@@ -0,0 +1,46 @@
1
+ /** One model's declared window, as the person's file states it. */
2
+ export type DeclaredWindow = {
3
+ provider: string;
4
+ modelId: string;
5
+ contextWindow: number;
6
+ };
7
+ /**
8
+ * The windows the person's `models.json` declares, keyed by `provider/modelId`.
9
+ *
10
+ * Pi's file shape is `{ providers: { <providerId>: { models: [{ id, contextWindow, ... }] } } }`,
11
+ * and a model without a `contextWindow` is simply not declared - it does not become a zero, because
12
+ * a zero would be a claim ("this model has no window") rather than an absence.
13
+ */
14
+ export declare function declaredWindows(modelsJson: unknown): Map<string, number>;
15
+ /**
16
+ * The window to USE for a model, given what the person's file says.
17
+ *
18
+ * `undefined` means "say nothing" - the caller keeps whatever Pi believes. That is the honest
19
+ * answer for a model the person's file does not mention: Super Pi has no opinion about a model
20
+ * somebody else configured, and inventing one would be the same mistake in the other direction.
21
+ *
22
+ * TWO LOOKUPS, AND THE SECOND ONE IS THE ONE THAT MATTERS:
23
+ *
24
+ * 1. `provider/modelId` - the exact pair. This is the common case.
25
+ * 2. `modelId` ALONE, across every provider the person's file declares.
26
+ *
27
+ * The second lookup exists because a CONTEXT WINDOW IS A PROPERTY OF THE MODEL, NOT OF THE
28
+ * ENDPOINT. Measured on this machine: the person's `models.json` declares the model `MiniMax-M3`
29
+ * under the provider id `minimax` (the OpenAI-compatible endpoint) with `contextWindow: 128000`,
30
+ * while Pi's remote catalogue declares the SAME model under a DIFFERENT provider id,
31
+ * `minimax-cn` (the Anthropic-compatible endpoint), with `contextWindow: 1000000`. The session runs
32
+ * on `minimax-cn`, so the exact-pair lookup finds nothing and the bogus number survives.
33
+ *
34
+ * When several providers declare the same model with DIFFERENT windows, the SMALLEST wins: a model
35
+ * can never really have more context than its own limit, and a window that is too large is exactly
36
+ * the failure this exists to prevent - it pushes compaction past the point where the model refuses.
37
+ */
38
+ export declare function effectiveContextWindow(declared: Map<string, number>, provider: string, modelId: string, reported?: number): number | undefined;
39
+ /**
40
+ * Reads the person's `models.json` and returns its declared windows.
41
+ *
42
+ * A missing or unreadable file is an empty map, not a throw: Super Pi must not refuse to start
43
+ * because a person has not configured a custom provider, and the consequence of an empty map is
44
+ * simply "Super Pi has no opinion about this model's window" - which is where it started.
45
+ */
46
+ export declare function readDeclaredWindows(agentDir: string): Map<string, number>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * THE CONTEXT WINDOW A MODEL REALLY HAS.
3
+ *
4
+ * Pi reads its model list from two places and one of them wins:
5
+ *
6
+ * - `~/.pi/agent/models.json` - the file a PERSON writes, or that Super Pi writes from its own
7
+ * registry. Its `contextWindow` is a deliberate statement about the model.
8
+ * - `~/.pi/agent/models-store.json` - the REMOTE CATALOGUE Pi fetches from the provider, with
9
+ * an etag and a timestamp. Its `contextWindow` is the PROVIDER'S SELF-REPORT.
10
+ *
11
+ * Pi's own merge lets the remote catalogue overwrite the person's file (`merged[index] = model`),
12
+ * so a provider that reports a generous window wins over the one the person configured.
13
+ *
14
+ * WHY THAT MATTERS, AND HOW IT BREAKS A SESSION:
15
+ *
16
+ * Pi's auto-compaction is `shouldCompact(contextTokens, contextWindow, settings)`, i.e.
17
+ * `contextTokens > contextWindow - reserveTokens`. With `reserveTokens = 16384` and a reported
18
+ * window of 1,000,000, compaction waits for 983,616 tokens. The real window of that model is
19
+ * 128,000. So the model refuses the request at 128K, and the OVERFLOW RECOVERY - which asks the
20
+ * SAME model to summarise the same too-long conversation - fails the same way. The session is
21
+ * then a session that cannot answer anything, and the reader sees a dead conversation.
22
+ *
23
+ * Measured on this machine: `models.json` says `MiniMax-M3` has `contextWindow: 128000`; the
24
+ * remote catalogue says `1000000`; Pi ends up believing 1,000,000.
25
+ *
26
+ * SO: THE PERSON'S FILE WINS. A number somebody typed is a decision; a number a catalogue fetched
27
+ * is a claim, and a claim that is too generous does not fail loudly - it fails by never compacting.
28
+ *
29
+ * This module is deliberately narrow: it reads the person's file, and it says what the model
30
+ * window SHOULD be. It does not touch Pi, does not decide anything else, and is a pure function of
31
+ * two plain objects so it can be tested without a server, a socket, a browser or a model.
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ /**
36
+ * The windows the person's `models.json` declares, keyed by `provider/modelId`.
37
+ *
38
+ * Pi's file shape is `{ providers: { <providerId>: { models: [{ id, contextWindow, ... }] } } }`,
39
+ * and a model without a `contextWindow` is simply not declared - it does not become a zero, because
40
+ * a zero would be a claim ("this model has no window") rather than an absence.
41
+ */
42
+ export function declaredWindows(modelsJson) {
43
+ const out = new Map();
44
+ if (!modelsJson || typeof modelsJson !== "object")
45
+ return out;
46
+ const providers = modelsJson.providers;
47
+ if (!providers || typeof providers !== "object")
48
+ return out;
49
+ for (const [providerId, provider] of Object.entries(providers)) {
50
+ if (!provider || typeof provider !== "object")
51
+ continue;
52
+ const models = provider.models;
53
+ if (!Array.isArray(models))
54
+ continue;
55
+ for (const model of models) {
56
+ if (!model || typeof model !== "object")
57
+ continue;
58
+ const id = model.id;
59
+ const window = model.contextWindow;
60
+ if (typeof id !== "string" || !id.trim())
61
+ continue;
62
+ if (typeof window !== "number" || !Number.isSafeInteger(window) || window <= 0)
63
+ continue;
64
+ out.set(`${providerId}/${id}`, window);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * The window to USE for a model, given what the person's file says.
71
+ *
72
+ * `undefined` means "say nothing" - the caller keeps whatever Pi believes. That is the honest
73
+ * answer for a model the person's file does not mention: Super Pi has no opinion about a model
74
+ * somebody else configured, and inventing one would be the same mistake in the other direction.
75
+ *
76
+ * TWO LOOKUPS, AND THE SECOND ONE IS THE ONE THAT MATTERS:
77
+ *
78
+ * 1. `provider/modelId` - the exact pair. This is the common case.
79
+ * 2. `modelId` ALONE, across every provider the person's file declares.
80
+ *
81
+ * The second lookup exists because a CONTEXT WINDOW IS A PROPERTY OF THE MODEL, NOT OF THE
82
+ * ENDPOINT. Measured on this machine: the person's `models.json` declares the model `MiniMax-M3`
83
+ * under the provider id `minimax` (the OpenAI-compatible endpoint) with `contextWindow: 128000`,
84
+ * while Pi's remote catalogue declares the SAME model under a DIFFERENT provider id,
85
+ * `minimax-cn` (the Anthropic-compatible endpoint), with `contextWindow: 1000000`. The session runs
86
+ * on `minimax-cn`, so the exact-pair lookup finds nothing and the bogus number survives.
87
+ *
88
+ * When several providers declare the same model with DIFFERENT windows, the SMALLEST wins: a model
89
+ * can never really have more context than its own limit, and a window that is too large is exactly
90
+ * the failure this exists to prevent - it pushes compaction past the point where the model refuses.
91
+ */
92
+ export function effectiveContextWindow(declared, provider, modelId, reported) {
93
+ const exact = declared.get(`${provider}/${modelId}`);
94
+ if (exact !== undefined)
95
+ return exact;
96
+ let smallest;
97
+ for (const [key, window] of declared) {
98
+ const slash = key.indexOf("/");
99
+ if (slash < 0 || key.slice(slash + 1) !== modelId)
100
+ continue;
101
+ if (smallest === undefined || window < smallest)
102
+ smallest = window;
103
+ }
104
+ return smallest;
105
+ }
106
+ /**
107
+ * Reads the person's `models.json` and returns its declared windows.
108
+ *
109
+ * A missing or unreadable file is an empty map, not a throw: Super Pi must not refuse to start
110
+ * because a person has not configured a custom provider, and the consequence of an empty map is
111
+ * simply "Super Pi has no opinion about this model's window" - which is where it started.
112
+ */
113
+ export function readDeclaredWindows(agentDir) {
114
+ try {
115
+ const raw = readFileSync(join(agentDir, "models.json"), "utf8");
116
+ return declaredWindows(JSON.parse(raw));
117
+ }
118
+ catch {
119
+ return new Map();
120
+ }
121
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { describeToolCall, describeToolCallArgs, extractPartialResultText, readQuestionCall } from "./tool-calls.js";
20
20
  import { toolTargetPath } from "./file-changes.js";
21
+ import { LengthExceededError } from "./turn-failure.js";
21
22
  /** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
22
23
  const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
23
24
  /**
@@ -337,7 +338,10 @@ export function handleSessionEvent(event, queue, handlers, report, failure) {
337
338
  const held = failure?.current;
338
339
  if (held) {
339
340
  failure.current = undefined;
340
- queue.push({ kind: "error", error: new Error(held.message) });
341
+ const err = held.stopReason === "length"
342
+ ? new LengthExceededError(held.message)
343
+ : new Error(held.message);
344
+ queue.push({ kind: "error", error: err });
341
345
  return true;
342
346
  }
343
347
  queue.push({ kind: "end" });
@@ -165,6 +165,8 @@ export declare class PiSessionPool {
165
165
  * tool allowlist has changed.
166
166
  */
167
167
  acquire(request: SessionRequest): Promise<PiSessionLike>;
168
+ /** Keep an accidental session fan-out from retaining one full transcript context per conversation. */
169
+ private evictOverflow;
168
170
  private build;
169
171
  /**
170
172
  * Puts a live session on the model and level the conversation is asking for.
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import { createPiSession, sameFingerprint, superPiExtensionPaths, superPiSkillPaths, } from "./pi-session.js";
26
26
  /** How long an idle session survives before it is evicted. */
27
27
  const IDLE_EVICTION_MS = 10 * 60_000;
28
+ const MAX_LIVE_SESSIONS = Math.max(4, Number.parseInt(process.env.SUPER_PI_MAX_LIVE_SESSIONS ?? "32", 10) || 32);
28
29
  /**
29
30
  * The environment variable the session's credential travels in.
30
31
  *
@@ -119,6 +120,7 @@ export class PiSessionPool {
119
120
  * tool allowlist has changed.
120
121
  */
121
122
  async acquire(request) {
123
+ await this.evictOverflow(request.conversationId);
122
124
  const fingerprint = this.fingerprintOf(request);
123
125
  const existing = this.sessions.get(request.conversationId);
124
126
  if (existing) {
@@ -182,6 +184,20 @@ export class PiSessionPool {
182
184
  throw error;
183
185
  }
184
186
  }
187
+ /** Keep an accidental session fan-out from retaining one full transcript context per conversation. */
188
+ async evictOverflow(exceptConversationId) {
189
+ if (this.sessions.size < MAX_LIVE_SESSIONS)
190
+ return;
191
+ const candidates = [...this.sessions.entries()]
192
+ .filter(([id, entry]) => id !== exceptConversationId && !entry.building)
193
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
194
+ while (this.sessions.size >= MAX_LIVE_SESSIONS && candidates.length) {
195
+ const [conversationId] = candidates.shift();
196
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
197
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=session.max-live-evict`);
198
+ await this.evict(conversationId);
199
+ }
200
+ }
185
201
  async build(request, fingerprint) {
186
202
  const creation = {
187
203
  cwd: request.cwd,
@@ -349,6 +349,12 @@ export interface PiSessionLike {
349
349
  provider?: string;
350
350
  id?: string;
351
351
  } | undefined;
352
+ /** Pi's estimate for the current context, including its post-compaction unknown state. */
353
+ getContextUsage?(): {
354
+ tokens: number | null;
355
+ contextWindow: number;
356
+ percent: number | null;
357
+ } | undefined;
352
358
  /**
353
359
  * Switches the model on a LIVE session.
354
360
  *
@@ -27,6 +27,7 @@ import { existsSync, statSync } from "node:fs";
27
27
  import { open, readdir } from "node:fs/promises";
28
28
  import { StringDecoder } from "node:string_decoder";
29
29
  import { dirname, join, resolve } from "node:path";
30
+ import { effectiveContextWindow, readDeclaredWindows } from "./model-context-window.js";
30
31
  import { fileURLToPath, pathToFileURL } from "node:url";
31
32
  const traceSession = (conversationId, phase, startedAt, extra = "") => {
32
33
  if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
@@ -326,11 +327,24 @@ export async function createPiSession(creation) {
326
327
  // It is attached to the session rather than kept beside it because the session is what outlives a
327
328
  // turn: a model change applies to a LIVE session, and the live session is the thing that has to be
328
329
  // able to answer "give me this model whole".
330
+ // THE WINDOW THE MODEL REALLY HAS. Pi's remote catalogue can overwrite the `contextWindow` the
331
+ // PERSON configured in `models.json` (Pi's own merge lets the catalogue win), and a provider
332
+ // that reports a million tokens makes Pi's auto-compaction wait for 983,616 tokens - while the
333
+ // model refuses at its real 128,000. The overflow recovery then asks the same too-long
334
+ // conversation of the same too-small model, and the session stops working entirely.
335
+ //
336
+ // So the person's file wins: a number somebody typed is a decision, and a number a catalogue
337
+ // fetched is a claim. See `model-context-window.ts` for the whole reasoning.
338
+ const declared = readDeclaredWindows(agentDir);
329
339
  live.resolveModel = (provider, modelId) => {
330
340
  const found = modelRuntime.getModel?.(provider, modelId);
331
341
  if (!found || typeof found !== "object")
332
342
  return undefined;
333
- return found;
343
+ const model = found;
344
+ const window = effectiveContextWindow(declared, provider, modelId, model.contextWindow);
345
+ // The model is handed to Pi WHOLE - it is what the agent runs on - so this is where the
346
+ // corrected window has to be. A model Super Pi has no opinion about is handed over untouched.
347
+ return window === undefined ? model : { ...model, contextWindow: window };
334
348
  };
335
349
  return live;
336
350
  })();
@@ -2,6 +2,7 @@ import { type RpcRecord } from "./rpc-channel.js";
2
2
  export { RpcChannel, type RpcRecord } from "./rpc-channel.js";
3
3
  import type { ActiveMemory } from "./active-memory.js";
4
4
  import { type KnowledgeBase } from "./knowledge.js";
5
+ import { type TurnUsageReadout } from "./conversation-usage.js";
5
6
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
6
7
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
7
8
  import { type PiSessionFactory } from "./pi-session.js";
@@ -171,6 +172,8 @@ export interface AnswerInput {
171
172
  onThinking?(delta: string): void | Promise<void>;
172
173
  /** Called when the reasoning phase ends - the only signal that it has. */
173
174
  onThinkingEnd?(): void;
175
+ /** One turn's completed-message usage and final context snapshot, also reported on abort/failure. */
176
+ onUsage?(readout: TurnUsageReadout): void | Promise<void>;
174
177
  }
175
178
  /**
176
179
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -7,6 +7,8 @@ import { DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME } from "./documents.j
7
7
  import { FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME } from "./feishu-cli.js";
8
8
  import { WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./web.js";
9
9
  import { existsSync } from "node:fs";
10
+ import { ConversationUsageCollector } from "./conversation-usage.js";
11
+ import { defaultAgentDir } from "./pi-session.js";
10
12
  import { mkdir, stat } from "node:fs/promises";
11
13
  import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
12
14
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
@@ -16,12 +18,13 @@ import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capabi
16
18
  import { resolvePiCommand, shellQuote } from "./pi-command.js";
17
19
  import { PiSessionPool, repositoryRootFrom } from "./pi-session-pool.js";
18
20
  import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, TurnQueue } from "./pi-session-events.js";
21
+ import { LengthExceededError } from "./turn-failure.js";
19
22
  // The queue/steer recognisers live in pi-session-events.ts, where the SESSION path's bridge can use
20
23
  // them; the one-shot drain loop below reads the same records through the same two functions, so both
21
24
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
22
25
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
23
26
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
24
- import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, rosterPayload, } from "./task-tool.js";
27
+ import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, ORCHESTRATOR_WORKSPACE_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, rosterPayload, } from "./task-tool.js";
25
28
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
26
29
  import { loadConversationImages } from "./upload.js";
27
30
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -696,7 +699,7 @@ export class PiRpcRuntime {
696
699
  * its environment once, when it is loaded) and a token minted per turn would be replaced before the
697
700
  * second turn of the conversation, leaving the extension holding a value the server had forgotten.
698
701
  */
699
- async taskExtensionEnv(conversationId, parentConversationId) {
702
+ async taskExtensionEnv(conversationId, workspaceId, parentConversationId) {
700
703
  const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
701
704
  for (const problem of problems)
702
705
  console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
@@ -711,6 +714,7 @@ export class PiRpcRuntime {
711
714
  [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
712
715
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
713
716
  [ORCHESTRATOR_ENDPOINT_ENV]: this.options.orchestratorEndpoint ?? "",
717
+ [ORCHESTRATOR_WORKSPACE_ENV]: workspaceId,
714
718
  SUPER_PI_ORCHESTRATOR_CONVERSATION: conversationId,
715
719
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
716
720
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
@@ -738,9 +742,9 @@ export class PiRpcRuntime {
738
742
  * here. A session lives for many turns, so its credential is minted when the session is created (see
739
743
  * `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
740
744
  */
741
- async oneShotExtensionEnv(conversationId, capability, parentConversationId) {
745
+ async oneShotExtensionEnv(conversationId, workspaceId, capability, parentConversationId) {
742
746
  return {
743
- ...(await this.taskExtensionEnv(conversationId, parentConversationId)),
747
+ ...(await this.taskExtensionEnv(conversationId, workspaceId, parentConversationId)),
744
748
  [TASK_CAPABILITY_ENV]: capability,
745
749
  SUPER_PI_TOOLS_CAPABILITY: capability,
746
750
  };
@@ -801,7 +805,7 @@ export class PiRpcRuntime {
801
805
  ...(systemPromptFile ? { systemPromptFile } : {}),
802
806
  ...(agent?.promptMode ? { systemPromptMode: agent.promptMode } : {}),
803
807
  extensionPaths: superPiExtensionPaths(repositoryRootFrom()),
804
- extensionEnv: await this.taskExtensionEnv(conversation.id, conversation.parentConversationId),
808
+ extensionEnv: await this.taskExtensionEnv(conversation.id, conversation.workspaceId, conversation.parentConversationId),
805
809
  ...(conversation.model ? { model: { provider: conversation.model.provider, modelId: conversation.model.modelId } } : {}),
806
810
  ...(conversation.thinkingLevel ? { thinkingLevel: conversation.thinkingLevel } : {}),
807
811
  };
@@ -902,8 +906,12 @@ export class PiRpcRuntime {
902
906
  // turn failed. The bridge clears it when a new assistant message starts, and raises it when Pi
903
907
  // settles.
904
908
  const failure = { current: undefined };
909
+ const usage = new ConversationUsageCollector();
905
910
  const unsubscribe = session.subscribe((event) => {
906
911
  try {
912
+ const record = event;
913
+ if (record?.type === "message_end" && record.message?.role === "assistant")
914
+ usage.add(record.message.usage);
907
915
  handleSessionEvent(event, queue, handlers, report, failure);
908
916
  }
909
917
  catch (error) {
@@ -1086,6 +1094,14 @@ export class PiRpcRuntime {
1086
1094
  if (input.host)
1087
1095
  this.sessionHosts.delete(conversation.id);
1088
1096
  await finished.catch(() => undefined);
1097
+ if (input.onUsage) {
1098
+ let context;
1099
+ try {
1100
+ context = session.getContextUsage?.();
1101
+ }
1102
+ catch { /* Unavailable context must not discard usage or keep a stale occupancy. */ }
1103
+ await input.onUsage(usage.readout(context, session.model, defaultAgentDir()));
1104
+ }
1089
1105
  }
1090
1106
  }
1091
1107
  /**
@@ -1477,7 +1493,7 @@ export class PiRpcRuntime {
1477
1493
  // every turn would silently replace the child's environment with whatever THIS process has at
1478
1494
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
1479
1495
  // but the four documented additions.
1480
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability, conversation.parentConversationId) : {}), ...(memoryEnabled ? {
1496
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, conversation.workspaceId, oneShotCapability, conversation.parentConversationId) : {}), ...(memoryEnabled ? {
1481
1497
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
1482
1498
  SUPER_PI_MEMORY_TOKEN: memoryToken,
1483
1499
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
@@ -1589,7 +1605,7 @@ export class PiRpcRuntime {
1589
1605
  if (reason === "aborted")
1590
1606
  throw new Error(outcome.errorMessage || "Pi 意外中止了本轮回答。");
1591
1607
  if (reason === "length")
1592
- throw new Error(outcome.errorMessage || "模型输出达到本轮长度上限,回答未完成。");
1608
+ throw new LengthExceededError(outcome.errorMessage || "模型输出达到本轮长度上限,回答未完成。");
1593
1609
  };
1594
1610
  let firstPiRecord = true;
1595
1611
  // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Resolves a conversation's optional relative cwd without ever letting it leave the workspace.
3
+ * An absent value preserves the workspace root; an invalid or escaping value is rejected back to
4
+ * that same root rather than thrown or allowed to select another directory.
5
+ */
6
+ export declare function resolveSessionCwd(workspaceRoot: string, cwd?: string | null): Promise<string>;
7
+ /**
8
+ * Validates an explicitly requested session cwd at creation time. Unlike the request-time fallback
9
+ * above, an invalid value is an error: the caller must not receive a session rooted somewhere else.
10
+ */
11
+ export declare function assertSessionCwdInsideWorkspace(workspaceRoot: string, cwd?: string | null): Promise<void>;
12
+ /** The canonical, workspace-relative spelling persisted on a conversation. */
13
+ export declare function normalizeSessionCwd(workspaceRoot: string, cwd?: string | null): Promise<string | undefined>;
14
+ /** Converts a path relative to the effective session root into a workspace-relative Git path. */
15
+ export declare function workspacePathFromSession(workspaceRoot: string, sessionRoot: string, path: string): Promise<string>;
16
+ /** One map identity per workspace/cwd pair. The root-session key remains the bare workspace id. */
17
+ export declare function sessionScopeKey(workspaceId: string, cwd?: string): string;
@@ -0,0 +1,43 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { resolveInsideRoot } from "./files.js";
3
+ /**
4
+ * Resolves a conversation's optional relative cwd without ever letting it leave the workspace.
5
+ * An absent value preserves the workspace root; an invalid or escaping value is rejected back to
6
+ * that same root rather than thrown or allowed to select another directory.
7
+ */
8
+ export async function resolveSessionCwd(workspaceRoot, cwd) {
9
+ if (!cwd?.trim())
10
+ return resolveInsideRoot(workspaceRoot, "");
11
+ try {
12
+ return await resolveInsideRoot(workspaceRoot, cwd);
13
+ }
14
+ catch {
15
+ return resolveInsideRoot(workspaceRoot, "");
16
+ }
17
+ }
18
+ /**
19
+ * Validates an explicitly requested session cwd at creation time. Unlike the request-time fallback
20
+ * above, an invalid value is an error: the caller must not receive a session rooted somewhere else.
21
+ */
22
+ export async function assertSessionCwdInsideWorkspace(workspaceRoot, cwd) {
23
+ if (!cwd?.trim())
24
+ return;
25
+ await resolveInsideRoot(workspaceRoot, cwd);
26
+ }
27
+ /** The canonical, workspace-relative spelling persisted on a conversation. */
28
+ export async function normalizeSessionCwd(workspaceRoot, cwd) {
29
+ if (!cwd?.trim())
30
+ return undefined;
31
+ const effective = await resolveSessionCwd(workspaceRoot, cwd);
32
+ const normalized = relative(resolve(workspaceRoot), effective).split(sep).join("/");
33
+ return normalized ? normalized : undefined;
34
+ }
35
+ /** Converts a path relative to the effective session root into a workspace-relative Git path. */
36
+ export async function workspacePathFromSession(workspaceRoot, sessionRoot, path) {
37
+ const absolute = await resolveInsideRoot(sessionRoot, path);
38
+ return relative(resolve(workspaceRoot), absolute).split(sep).join("/");
39
+ }
40
+ /** One map identity per workspace/cwd pair. The root-session key remains the bare workspace id. */
41
+ export function sessionScopeKey(workspaceId, cwd) {
42
+ return cwd ? `${workspaceId}\0${cwd}` : workspaceId;
43
+ }
@@ -19,6 +19,13 @@ export interface SessionLedgerEntry {
19
19
  updatedAt: number;
20
20
  /** High-level progress snippet or latest outcome */
21
21
  lastProgress?: string;
22
+ /** Currently executing tool, if any */
23
+ activeTool?: {
24
+ label: string;
25
+ detail?: string;
26
+ };
27
+ /** Error message if execution failed */
28
+ error?: string;
22
29
  }
23
30
  /**
24
31
  * In-memory relationship ledger for sibling and child sessions.
@@ -0,0 +1,125 @@
1
+ import type { Store } from "./store.js";
2
+ /**
3
+ * THE SESSION TREE'S SCOPE RULE.
4
+ *
5
+ * A session may only manage its DIRECT children: the sessions whose `parentConversationId` is
6
+ * exactly its own id. Nothing else. A grandchild is a level away, a peer sits in somebody else's
7
+ * branch, an ancestor is above - none of them is this session's subordinate, so none of them may be
8
+ * dispatched to, read, stopped or resumed by it. A workspace is NOT an organisation chart: two
9
+ * supervisors can sit in the same workspace without being each other's subordinates.
10
+ *
11
+ * WHY THE RULE IS ONE LINE. The tree edge is written on the conversation itself at birth
12
+ * (`parentConversationId`), so "is this session my child?" is a comparison, not a walk. The walk up
13
+ * the chain exists for ONE purpose only: writing a refusal that says WHY, so the reader knows
14
+ * whether it asked for a peer, a grandchild or somebody else's department. The walk NEVER decides
15
+ * anything - a refusal is a refusal because the parent is not the caller, never because the walk
16
+ * came back empty.
17
+ *
18
+ * WHY THIS MATTERS AT ALL. The result callback of a dispatched session goes back to the session that
19
+ * OWNS it - the one whose id is the target's `parentConversationId`. If a session could dispatch to
20
+ * a session it does not own, that session's result would report to somebody else, and the tree the
21
+ * user sees in the sidebar would stop being the tree the work actually flows through. Making the
22
+ * target's parent be the caller is what keeps "who gave the order is who gets the report" true by
23
+ * construction.
24
+ */
25
+ /** How the caller and the target relate in the session tree. Computed for the MESSAGE only. */
26
+ export type SessionRelation = "self" | "direct-child" | "descendant" | "ancestor" | "peer" | "other-root" | "unrelated";
27
+ /** The outcome of a scope check: allowed, or a refusal with the status code and the reason. */
28
+ export type ScopeVerdict = {
29
+ ok: true;
30
+ } | {
31
+ ok: false;
32
+ status: number;
33
+ error: string;
34
+ };
35
+ /** The verb a refusal is about, so the message reads like the action that was refused. */
36
+ export type ScopeVerb = "调度" | "读取" | "停止" | "恢复" | "列出" | "看护";
37
+ /** What the caller was trying to reach: a session to manage, a session's children, or a parent to claim. */
38
+ export type ScopeSubject = "manage" | "list-children" | "declare-parent";
39
+ /** The narrow slice of the store this file needs. */
40
+ type ConversationReader = Pick<Store, "conversation">;
41
+ /**
42
+ * The relation between two sessions, in the tree.
43
+ *
44
+ * READ ONLY. This classifies a pair for the sake of the refusal message; it never grants access.
45
+ */
46
+ export declare const classifySessionRelation: (store: ConversationReader, callerConversationId: string, targetConversationId: string) => SessionRelation;
47
+ /**
48
+ * Writes the refusal for a pair that is not allowed to be reached.
49
+ *
50
+ * The three shapes, because the three failures are different failures:
51
+ * - managing a session -> 「不能调度/读取/停止/恢复会话 X」
52
+ * - listing a session's kids -> 「不能列出会话 X 的下属」
53
+ * - claiming a parent -> 「不能把会话 X 声明为自己的下属」
54
+ */
55
+ export declare const describeScopeRefusal: (options: {
56
+ store: ConversationReader;
57
+ callerConversationId: string;
58
+ targetConversationId: string;
59
+ verb: ScopeVerb;
60
+ subject: ScopeSubject;
61
+ }) => string;
62
+ /**
63
+ * Checks that the caller may manage the target: the target must be the caller's DIRECT child.
64
+ *
65
+ * The workspace check is NOT part of this rule and is NOT removed by it - a conversation has to be
66
+ * in the caller's workspace AND in the caller's own subtree. The workspace refusal comes first
67
+ * because it is the older, coarser boundary, and its message names the two workspaces.
68
+ */
69
+ export declare const verifyDirectChildScope: (options: {
70
+ store: ConversationReader;
71
+ callerConversationId: string;
72
+ targetConversationId: string;
73
+ verb?: ScopeVerb;
74
+ }) => ScopeVerdict;
75
+ /**
76
+ * Checks that the caller may list a parent's children.
77
+ *
78
+ * The rule is EQUALITY, not parenthood: the parent whose children are listed must be the caller
79
+ * itself, so the only sessions on the list are the caller's own direct children. Listing the
80
+ * children of the caller's own child would be listing grandchildren, which is the cross-level reach
81
+ * this whole rule exists to refuse.
82
+ */
83
+ export declare const verifyListParentScope: (options: {
84
+ store: ConversationReader;
85
+ callerConversationId: string;
86
+ requestedParentId?: string;
87
+ }) => ScopeVerdict;
88
+ /**
89
+ * Checks that the caller may REPORT to the target: the target must be the caller's DIRECT PARENT.
90
+ *
91
+ * THE REPORTING LINE IS THE TREE EDGE, READ BACKWARDS. Every other rule in this file asks "may this
92
+ * session manage that one?" - and the answer is always about a session's subordinates. A report is the
93
+ * one message that travels UP, so the rule is inverted: a child reports to the session that owns it,
94
+ * the one whose id is the child's `parentConversationId`, and to nobody else.
95
+ *
96
+ * WHY THE INVERSION IS NOT A LOOPHOLE. A peer is somebody else's child, an ancestor is above the
97
+ * caller's own parent, a grandchild is below the caller, and a session in another workspace is another
98
+ * department. Every one of them would put one branch's news into another branch's inbox, and the inbox
99
+ * is where a parent's NEXT ACTION is decided - so a report that crossed a branch would be a stranger's
100
+ * news steering the wrong supervisor.
101
+ *
102
+ * THE RECIPIENT IS DERIVED, NOT CHOSEN. This check exists for the case where a caller NAMES a
103
+ * recipient at all: the server derives the parent from the tree and this refuses anything else, so a
104
+ * model cannot redirect its own report up or sideways.
105
+ */
106
+ export declare const verifyReportTargetScope: (options: {
107
+ store: ConversationReader;
108
+ callerConversationId: string;
109
+ targetConversationId: string;
110
+ }) => ScopeVerdict;
111
+ /**
112
+ * Checks the parent a new session is created under.
113
+ *
114
+ * The parent edge is written onto the new conversation at birth, and the result callback follows
115
+ * that edge home. A caller that names somebody ELSE as the parent would therefore rewrite that
116
+ * session's reporting line - putting the victim's result into the caller's inbox - so the parent
117
+ * must be the caller. An empty parent means a top-level session, which is what the user's own "+"
118
+ * creates and what a session with no owner legitimately is.
119
+ */
120
+ export declare const verifySpawnParentScope: (options: {
121
+ store: ConversationReader;
122
+ callerConversationId: string;
123
+ requestedParentId?: string;
124
+ }) => ScopeVerdict;
125
+ export {};