@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.
- package/agents/super-pi-task/core.ts +74 -2
- package/agents/super-pi-task/index.ts +7 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +11 -11
- package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
- package/dist/client/assets/{dist-DM4Olwfx.js → dist-1Innu17E.js} +1 -1
- package/dist/client/assets/{dist-bIDce5j-.js → dist-1lynFOc5.js} +1 -1
- package/dist/client/assets/{dist-BlxwDnWr.js → dist-B1qLZVMK.js} +1 -1
- package/dist/client/assets/{dist-BTVbZigv.js → dist-B4pz_xdd.js} +1 -1
- package/dist/client/assets/{dist-UJ46aPS3.js → dist-B6KwDpeI.js} +1 -1
- package/dist/client/assets/{dist-CK2KFFtT.js → dist-BJVI1Unv.js} +1 -1
- package/dist/client/assets/{dist-CRnm1rnO.js → dist-BWvzD8Pe.js} +1 -1
- package/dist/client/assets/{dist-DSHM-PAs.js → dist-BayWssd0.js} +1 -1
- package/dist/client/assets/{dist-B9DE7sc5.js → dist-Bk_pkBVH.js} +1 -1
- package/dist/client/assets/{dist-D-o7iQ8c.js → dist-BpeClAv0.js} +1 -1
- package/dist/client/assets/{dist-CQFASxyb.js → dist-BsmJg5PX.js} +1 -1
- package/dist/client/assets/{dist-Ds5gfeAi.js → dist-Bx7s4rni.js} +1 -1
- package/dist/client/assets/{dist-UPJPMjWv.js → dist-CDBKM9tL.js} +1 -1
- package/dist/client/assets/{dist-CIgjifH-.js → dist-CGzXCzCG.js} +1 -1
- package/dist/client/assets/{dist-BAiV8I_O.js → dist-CRxCwn-Z.js} +1 -1
- package/dist/client/assets/{dist-Br-AuQUG.js → dist-CTDgUnBG.js} +1 -1
- package/dist/client/assets/{dist-4JbFTwg2.js → dist-D1VfMWmf.js} +1 -1
- package/dist/client/assets/{dist-BYOpvW02.js → dist-DTZLNEci.js} +1 -1
- package/dist/client/assets/{dist-CQV5kOCE.js → dist-DuUi29km.js} +1 -1
- package/dist/client/assets/{dist-CjszAoXk.js → dist-F3S0t1oM.js} +1 -1
- package/dist/client/assets/{dist-2boAf_pN.js → dist-QZ-FAVJV.js} +1 -1
- package/dist/client/assets/{dist-B1p4YF9C.js → dist-u0nBg0fZ.js} +1 -1
- package/dist/client/assets/index-BMpqviwU.css +1 -0
- package/dist/client/assets/index-Dzyp_7sf.js +102 -0
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +17 -0
- package/dist/server/active-memory.js +39 -0
- package/dist/server/agents.d.ts +1 -1
- package/dist/server/agents.js +3 -1
- package/dist/server/application/conversation-service.d.ts +1 -1
- package/dist/server/application/conversation-service.js +12 -4
- package/dist/server/application/run-service.d.ts +32 -2
- package/dist/server/application/run-service.js +296 -40
- package/dist/server/application/scheduled-task-service.d.ts +14 -1
- package/dist/server/application/scheduled-task-service.js +27 -2
- package/dist/server/application/session-orchestrator-service.d.ts +114 -3
- package/dist/server/application/session-orchestrator-service.js +406 -35
- package/dist/server/application/session-watchdog.d.ts +159 -0
- package/dist/server/application/session-watchdog.js +244 -0
- package/dist/server/command-handler.js +1 -1
- package/dist/server/conversation-usage.d.ts +19 -0
- package/dist/server/conversation-usage.js +40 -0
- package/dist/server/file-diff.d.ts +3 -0
- package/dist/server/file-diff.js +4 -4
- package/dist/server/file-index.d.ts +4 -2
- package/dist/server/file-index.js +14 -9
- package/dist/server/file-name-index.d.ts +5 -2
- package/dist/server/file-name-index.js +22 -5
- package/dist/server/git.d.ts +3 -3
- package/dist/server/git.js +10 -9
- package/dist/server/http-server.js +256 -34
- package/dist/server/index.js +3 -0
- package/dist/server/knowledge-vectors.d.ts +15 -0
- package/dist/server/knowledge-vectors.js +118 -37
- package/dist/server/knowledge.d.ts +7 -8
- package/dist/server/knowledge.js +133 -55
- package/dist/server/length-continuation.d.ts +50 -0
- package/dist/server/length-continuation.js +55 -0
- package/dist/server/model-context-window.d.ts +46 -0
- package/dist/server/model-context-window.js +121 -0
- package/dist/server/pi-session-events.js +5 -1
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +16 -0
- package/dist/server/pi-session.d.ts +6 -0
- package/dist/server/pi-session.js +15 -1
- package/dist/server/runtime.d.ts +3 -0
- package/dist/server/runtime.js +23 -7
- package/dist/server/session-cwd.d.ts +17 -0
- package/dist/server/session-cwd.js +43 -0
- package/dist/server/session-ledger.d.ts +7 -0
- package/dist/server/session-tree-scope.d.ts +125 -0
- package/dist/server/session-tree-scope.js +337 -0
- package/dist/server/store.d.ts +35 -0
- package/dist/server/store.js +87 -2
- package/dist/server/task-tool.d.ts +2 -1
- package/dist/server/task-tool.js +4 -1
- package/dist/server/terminal.d.ts +1 -0
- package/dist/server/terminal.js +5 -3
- package/dist/server/turn-failure.d.ts +26 -0
- package/dist/server/turn-failure.js +67 -1
- package/dist/server/types.d.ts +33 -1
- package/dist/server/version.d.ts +25 -2
- package/dist/server/version.js +98 -26
- package/dist/server/ws-server.d.ts +1 -1
- package/dist/server/ws-server.js +7 -5
- package/package.json +36 -3
- package/dist/client/assets/index-BGsP8pt_.js +0 -100
- 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
|
-
|
|
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
|
-
|
|
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
|
})();
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -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
|
package/dist/server/runtime.js
CHANGED
|
@@ -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
|
|
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 {};
|