@evandu/super-pi 0.1.84 → 0.1.86
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-knowledge/index.ts +40 -0
- package/agents/super-pi-task/index.ts +2 -2
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +10 -7
- package/dist/client/assets/index-B3QVoYJY.css +1 -0
- package/dist/client/assets/index-C_giUkvT.js +72 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +218 -8
- package/dist/server/index.js +15 -1
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +82 -1
- package/dist/server/runtime.js +128 -7
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/dist/server/types.d.ts +97 -65
- package/package.json +166 -155
- package/dist/client/assets/index-8Pf3MG2d.js +0 -72
- package/dist/client/assets/index-Jnj15dhT.css +0 -1
|
@@ -1,23 +1,163 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
const MAX_LOG_BYTES = 512_000;
|
|
4
7
|
const MAX_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
/** The environment build gets its OWN budget: a cold `pip install` must not eat the tool's timeout. */
|
|
9
|
+
const MAX_ENV_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
const isWindows = process.platform === "win32";
|
|
5
11
|
/** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
|
|
6
12
|
export class ToolRunner {
|
|
13
|
+
dataDir;
|
|
7
14
|
runs = new Map();
|
|
15
|
+
/** Optional so the existing unit tests can build a runner with no data dir; a tool that needs no venv never touches it. */
|
|
16
|
+
constructor(dataDir) {
|
|
17
|
+
this.dataDir = dataDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* THE RESOLUTION ORDER, IN ONE PLACE, AND IT IS AN ORDER ON PURPOSE.
|
|
21
|
+
*
|
|
22
|
+
* 1. the tool's own venv - a tool's dependencies belong to that tool and to nothing else;
|
|
23
|
+
* 2. `SUPER_PI_PYTHON_COMMAND` - the user's explicit choice, already honoured before this existed;
|
|
24
|
+
* 3. the system interpreter - today's behaviour;
|
|
25
|
+
* 4. nothing usable, which is REPORTED as a sentence rather than discovered as an ENOENT.
|
|
26
|
+
*
|
|
27
|
+
* Bundling an interpreter later is a fourth rung on this chain, which is why nothing in a tool's
|
|
28
|
+
* files has to know about it.
|
|
29
|
+
*/
|
|
30
|
+
resolvePython(toolId, needsVenv) {
|
|
31
|
+
if (needsVenv && this.dataDir) {
|
|
32
|
+
const envPython = this.envPython(toolId);
|
|
33
|
+
if (existsSync(envPython))
|
|
34
|
+
return { path: envPython, kind: "tool-env" };
|
|
35
|
+
}
|
|
36
|
+
const override = process.env.SUPER_PI_PYTHON_COMMAND?.trim();
|
|
37
|
+
if (override)
|
|
38
|
+
return { path: override, kind: "override" };
|
|
39
|
+
return { path: isWindows ? "python.exe" : "python3", kind: "system" };
|
|
40
|
+
}
|
|
41
|
+
/** The venv lives BESIDE the tools, not inside one: export walks the tool directory, and the mount route serves everything under it. */
|
|
42
|
+
envDir(toolId) { return join(this.dataDir ?? ".", "tool-envs", toolId); }
|
|
43
|
+
envPython(toolId) { return join(this.envDir(toolId), ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"); }
|
|
44
|
+
/** MEASURE this machine's Python. One spawn; the answer is what every fingerprint is compared to. */
|
|
45
|
+
async describeLocalPython() {
|
|
46
|
+
const choice = this.resolvePython("", false);
|
|
47
|
+
const probe = await runCapture(choice.path, ["-I", "-c", "import json,platform,sys;print(json.dumps({'python':sys.version.split()[0],'platform':platform.system().lower()+'-'+platform.machine()}))"], 10_000);
|
|
48
|
+
if (!probe.ok)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(probe.stdout);
|
|
52
|
+
if (typeof parsed?.python !== "string")
|
|
53
|
+
return undefined;
|
|
54
|
+
return { ...choice, python: parsed.python, platform: parsed.platform };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async environmentState(tool) {
|
|
61
|
+
const environment = tool.environment;
|
|
62
|
+
const requires = environment?.requires ?? [];
|
|
63
|
+
const wants = requires.length > 0 || Boolean(environment?.requiresPython);
|
|
64
|
+
const local = await this.describeLocalPython();
|
|
65
|
+
const base = { python: local?.python, platform: local?.platform, requires };
|
|
66
|
+
if (!wants)
|
|
67
|
+
return { ...base, state: "zero-deps" };
|
|
68
|
+
if (!local)
|
|
69
|
+
return { ...base, state: "needs-python", detail: "这台机器上找不到可用的 Python 解释器。" };
|
|
70
|
+
if (environment?.requiresPython && !satisfiesRange(local.python ?? "", environment.requiresPython))
|
|
71
|
+
return { ...base, state: "needs-python", detail: `这个工具需要 Python ${environment.requiresPython},本机是 ${local.python}。` };
|
|
72
|
+
if (requires.length > 0 && this.dataDir && !existsSync(this.envPython(tool.id)))
|
|
73
|
+
return { ...base, state: "needs-env", detail: "首次运行这个工具时会自动安装这些依赖。" };
|
|
74
|
+
const known = environment?.fingerprint;
|
|
75
|
+
if (known?.python && known.platform && (majorMinor(known.python) !== majorMinor(local.python ?? "") || known.platform !== local.platform))
|
|
76
|
+
return { ...base, state: "differs", detail: `导出方是 Python ${known.python} / ${known.platform},本机是 ${local.python} / ${local.platform}。` };
|
|
77
|
+
return { ...base, state: "ready" };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Starts a run. It RETURNS SYNCHRONOUSLY - so bad parameters still throw at the call site, exactly
|
|
81
|
+
* as before - while the interpreter and, if the tool needs one, its virtual environment are prepared
|
|
82
|
+
* in the background. The run is already `running` at that point and the build's output lands in the
|
|
83
|
+
* run's own `logs`, which is why the tool's existing dialog shows the progress without knowing
|
|
84
|
+
* anything about environments.
|
|
85
|
+
*/
|
|
8
86
|
start(input) {
|
|
9
87
|
validateParams(input.definition.parameters ?? {}, input.params);
|
|
10
|
-
const python = process.env.SUPER_PI_PYTHON_COMMAND?.trim() || (process.platform === "win32" ? "python.exe" : "python3");
|
|
11
88
|
const id = `toolrun_${randomUUID()}`;
|
|
12
89
|
const run = { id, toolId: input.toolId, status: "running", startedAt: new Date().toISOString(), logs: "" };
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const active = { run,
|
|
16
|
-
active.timer.unref();
|
|
90
|
+
const timer = setTimeout(() => this.finish(id, "timed_out", `准备运行环境超过 ${MAX_ENV_TIMEOUT_MS}ms 时限`), MAX_ENV_TIMEOUT_MS);
|
|
91
|
+
timer.unref();
|
|
92
|
+
const active = { run, stdout: "", cancelled: false, timer };
|
|
17
93
|
this.runs.set(id, active);
|
|
18
|
-
|
|
94
|
+
void this.prepare(id, active, input);
|
|
95
|
+
return copy(run);
|
|
96
|
+
}
|
|
97
|
+
async prepare(id, active, input) {
|
|
98
|
+
try {
|
|
99
|
+
const requires = input.environment?.requires ?? [];
|
|
100
|
+
let python = this.resolvePython(input.toolId, false);
|
|
101
|
+
if (requires.length > 0) {
|
|
102
|
+
const built = await this.ensureEnvironment(input.toolId, python, requires, active);
|
|
103
|
+
if (!built.ok) {
|
|
104
|
+
this.finish(id, "failed", built.error);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
python = { path: this.envPython(input.toolId), kind: "tool-env" };
|
|
108
|
+
}
|
|
109
|
+
if (active.cancelled)
|
|
110
|
+
return;
|
|
111
|
+
if (active.timer)
|
|
112
|
+
clearTimeout(active.timer);
|
|
113
|
+
const scriptTimer = setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs));
|
|
114
|
+
scriptTimer.unref();
|
|
115
|
+
active.timer = scriptTimer;
|
|
116
|
+
this.launch(id, active, python, input);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.finish(id, "failed", error instanceof Error ? error.message : "tool environment could not be prepared");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* THE VENV IS PYTHON'S OWN, ON PURPOSE. `python -m venv` finds its site-packages through the
|
|
124
|
+
* pyvenv.cfg next to the interpreter, which is why it survives the `-I` a tool runs under - a
|
|
125
|
+
* PYTHONPATH-based vendor directory does not (verified: `-I` implies `-E`, so PYTHONPATH is
|
|
126
|
+
* ignored, and dropping `-I` would give up the isolation that keeps a hostile PYTHON* environment
|
|
127
|
+
* from influencing a shared tool's script).
|
|
128
|
+
*/
|
|
129
|
+
async ensureEnvironment(toolId, base, requires, active) {
|
|
130
|
+
if (!this.dataDir)
|
|
131
|
+
return { ok: false, error: "这个工具需要依赖,但没有可用的数据目录来存放它的虚拟环境。" };
|
|
132
|
+
const envDir = this.envDir(toolId), envPython = this.envPython(toolId);
|
|
133
|
+
const recorded = await readFile(join(envDir, "installed.json"), "utf8").then((text) => { try {
|
|
134
|
+
return JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
} }).catch(() => undefined);
|
|
139
|
+
if (existsSync(envPython) && Array.isArray(recorded?.requires) && recorded.requires.length === requires.length && recorded.requires.every((item, index) => item === requires[index]))
|
|
140
|
+
return { ok: true };
|
|
141
|
+
await rm(envDir, { recursive: true, force: true });
|
|
142
|
+
await mkdir(envDir, { recursive: true });
|
|
143
|
+
const requirements = join(envDir, "requirements.txt");
|
|
144
|
+
await writeFile(requirements, `${requires.join("\n")}\n`, "utf8");
|
|
145
|
+
logLine(active, `正在准备 Python 虚拟环境(${requires.join("、")})…\n`);
|
|
146
|
+
const created = await runCapture(base.path, ["-m", "venv", join(envDir, ".venv")], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
|
|
147
|
+
if (!created.ok)
|
|
148
|
+
return { ok: false, error: `创建 Python 虚拟环境失败:${created.error || `解释器 ${base.path} 不可用`}` };
|
|
149
|
+
const installed = await runCapture(envPython, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "-r", requirements], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
|
|
150
|
+
if (!installed.ok)
|
|
151
|
+
return { ok: false, error: `安装工具依赖失败:${installed.error || `pip 退出码 ${installed.code}`}(依赖:${requires.join("、")})` };
|
|
152
|
+
await writeFile(join(envDir, "installed.json"), JSON.stringify({ requires, python: base.path, builtAt: new Date().toISOString() }, null, 2), "utf8");
|
|
153
|
+
return { ok: true };
|
|
154
|
+
}
|
|
155
|
+
launch(id, active, python, input) {
|
|
156
|
+
// -I ignores user Python configuration and user site packages. No shell means params never become code.
|
|
157
|
+
const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
|
|
158
|
+
active.child = child;
|
|
19
159
|
child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
|
|
20
|
-
child.stderr.on("data",
|
|
160
|
+
child.stderr.on("data", (data) => logLine(active, data.toString("utf8")));
|
|
21
161
|
child.once("error", (error) => this.finish(id, "failed", error.message));
|
|
22
162
|
child.once("close", (code) => {
|
|
23
163
|
if (active.run.status !== "running")
|
|
@@ -38,7 +178,6 @@ export class ToolRunner {
|
|
|
38
178
|
}
|
|
39
179
|
});
|
|
40
180
|
child.stdin.end(JSON.stringify(input.params));
|
|
41
|
-
return copy(run);
|
|
42
181
|
}
|
|
43
182
|
get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
|
|
44
183
|
cancel(id, toolId) { const active = this.runs.get(id); if (!active || active.run.toolId !== toolId)
|
|
@@ -47,17 +186,78 @@ export class ToolRunner {
|
|
|
47
186
|
this.finish(id, "cancelled");
|
|
48
187
|
} return copy(active.run); }
|
|
49
188
|
finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
|
|
50
|
-
return;
|
|
189
|
+
return; if (active.timer)
|
|
190
|
+
clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
|
|
51
191
|
active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
|
|
52
|
-
active.run.error = error; if (!active.child.killed) {
|
|
192
|
+
active.run.error = error; if (active.child && !active.child.killed) {
|
|
53
193
|
try {
|
|
54
194
|
active.child.kill();
|
|
55
195
|
}
|
|
56
196
|
catch { }
|
|
57
197
|
} }
|
|
58
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Runs a Python process with NO SHELL - the same rule as a tool run, because what is being installed
|
|
201
|
+
* is code too. There is no command string anywhere on this path, only an argv.
|
|
202
|
+
*/
|
|
203
|
+
function runCapture(python, args, timeoutMs, onOutput) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
let stdout = "", stderr = "", settled = false;
|
|
206
|
+
const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1" } });
|
|
207
|
+
const timer = setTimeout(() => { if (settled)
|
|
208
|
+
return; settled = true; try {
|
|
209
|
+
child.kill();
|
|
210
|
+
}
|
|
211
|
+
catch { } resolve({ ok: false, code: null, stdout, error: `timed out after ${timeoutMs}ms` }); }, timeoutMs);
|
|
212
|
+
timer.unref();
|
|
213
|
+
child.stdout.on("data", (data) => { const text = data.toString("utf8"); stdout += text; onOutput?.(text); });
|
|
214
|
+
child.stderr.on("data", (data) => { const text = data.toString("utf8"); stderr += text; onOutput?.(text); });
|
|
215
|
+
child.once("error", (error) => { if (settled)
|
|
216
|
+
return; settled = true; clearTimeout(timer); resolve({ ok: false, code: null, stdout, error: error.message }); });
|
|
217
|
+
child.once("close", (code) => { if (settled)
|
|
218
|
+
return; settled = true; clearTimeout(timer); if (code === 0)
|
|
219
|
+
resolve({ ok: true, stdout, code });
|
|
220
|
+
else
|
|
221
|
+
resolve({ ok: false, code, stdout, error: stderr.split("\n").filter((line) => line.trim()).slice(-4).join("\n") }); });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A SMALL, DELIBERATELY SMALL PEP 440 READER: `>=`, `>`, `<=`, `<`, `==`, comma-separated. Anything it
|
|
226
|
+
* cannot parse counts as NOT satisfied, because a version check that guesses is worse than one that
|
|
227
|
+
* refuses. A tool needing something exotic declares no range at all and relies on the venv, not here.
|
|
228
|
+
*/
|
|
229
|
+
export function satisfiesRange(version, range) {
|
|
230
|
+
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
231
|
+
if (actual.some((part) => Number.isNaN(part)))
|
|
232
|
+
return false;
|
|
233
|
+
for (const term of range.split(",")) {
|
|
234
|
+
const match = /^(>=|<=|==|>|<)?\s*(\d+(?:\.\d+)*)$/.exec(term.trim());
|
|
235
|
+
if (!match)
|
|
236
|
+
return false;
|
|
237
|
+
const comparison = compareVersions(actual, match[2].split(".").map((part) => Number.parseInt(part, 10)));
|
|
238
|
+
const operator = match[1] ?? "==";
|
|
239
|
+
if (operator === ">=" && comparison < 0)
|
|
240
|
+
return false;
|
|
241
|
+
if (operator === ">" && comparison <= 0)
|
|
242
|
+
return false;
|
|
243
|
+
if (operator === "<=" && comparison > 0)
|
|
244
|
+
return false;
|
|
245
|
+
if (operator === "<" && comparison >= 0)
|
|
246
|
+
return false;
|
|
247
|
+
if (operator === "==" && comparison !== 0)
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
function compareVersions(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
253
|
+
const a = left[index] ?? 0, b = right[index] ?? 0;
|
|
254
|
+
if (a !== b)
|
|
255
|
+
return a < b ? -1 : 1;
|
|
256
|
+
} return 0; }
|
|
257
|
+
function majorMinor(version) { return version.split(".").slice(0, 2).join("."); }
|
|
59
258
|
function boundedTimeout(value) { return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Number.isFinite(value) ? Math.floor(value) : 60_000)); }
|
|
60
259
|
function clipped(text) { return text.length > MAX_LOG_BYTES ? text.slice(-MAX_LOG_BYTES) : text; }
|
|
260
|
+
function logLine(active, text) { active.run.logs = clipped(`${active.run.logs}${text}`); }
|
|
61
261
|
function copy(run) { return { ...run, result: run.result ? { ...run.result } : undefined }; }
|
|
62
262
|
export function validateParams(schema, params) {
|
|
63
263
|
if (!params || Array.isArray(params) || typeof params !== "object")
|
package/dist/server/types.d.ts
CHANGED
|
@@ -105,7 +105,39 @@ export type Message = {
|
|
|
105
105
|
id?: string;
|
|
106
106
|
name: string;
|
|
107
107
|
};
|
|
108
|
+
delivery?: MessageDelivery;
|
|
108
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* WHERE A USER MESSAGE IS IN ITS OWN DELIVERY, or `undefined` for every message that is not waiting.
|
|
112
|
+
*
|
|
113
|
+
* A user message is written into the transcript the instant it is sent, BEFORE any turn has started,
|
|
114
|
+
* and it stays in that exact position until the agent has answered it. What changes meanwhile is this
|
|
115
|
+
* field and nothing else - see `docs/QUEUE-UX.md` for why the message must never move.
|
|
116
|
+
*
|
|
117
|
+
* ABSENT means "not waiting": a message the agent is answering right now, one it already answered, one
|
|
118
|
+
* withdrawn, and every `assistant` / `tool` message. Only `role: "user"` ever carries it.
|
|
119
|
+
*
|
|
120
|
+
* - `waiting` - said, not yet handed to the agent. CANCELLED freely, EDITED freely, and REORDERED
|
|
121
|
+
* freely. This is where essentially every waiting message lives, because a message is only given to
|
|
122
|
+
* Pi at the moment it is about to be delivered.
|
|
123
|
+
* - `sending` - written to a live Pi child's stdin as a `steer` and not yet confirmed delivered. NOT
|
|
124
|
+
* cancellable and NOT editable: Pi has no single-item retract (its only queue command is
|
|
125
|
+
* `clear_queue`, which clears everything), so a UI offering either would be a promise the transport
|
|
126
|
+
* cannot keep. It returns to `waiting` exactly when we can prove Pi never delivered it - the turn
|
|
127
|
+
* ended - and stays here when Pi simply has not answered yet.
|
|
128
|
+
*
|
|
129
|
+
* WHY A FIELD AND NOT A SEPARATE LIST. The waiting list used to be its own array of content copies
|
|
130
|
+
* (`QueuedMessage`), and that is what made two bugs structural rather than accidental: a queued entry
|
|
131
|
+
* was not a message, so promoting one to a turn had to CREATE a message - which is why a steered
|
|
132
|
+
* message reached Pi and the model and never reached the transcript at all - and "delivered" could only
|
|
133
|
+
* be expressed by DELETING a row, so every delivery was an opportunity to lose the user's words. A
|
|
134
|
+
* message with a state has an identity to transition and nothing to delete.
|
|
135
|
+
*
|
|
136
|
+
* ORDER IS THE QUEUE. There is no `priority` flag and no second source of truth: the waiting messages
|
|
137
|
+
* are the trailing `role: "user"` messages of the array, and "put this one first" is a move within
|
|
138
|
+
* that block.
|
|
139
|
+
*/
|
|
140
|
+
export type MessageDelivery = "waiting" | "sending";
|
|
109
141
|
/**
|
|
110
142
|
* A piece of context attached to a user message that is shown as its OWN block under the bubble, rather
|
|
111
143
|
* than flattened into the prompt. Today: a skill the person picked from the slash menu - the model gets
|
|
@@ -691,7 +723,6 @@ export type AppState = {
|
|
|
691
723
|
workspaces: Workspace[];
|
|
692
724
|
conversations: Conversation[];
|
|
693
725
|
subagentRuns: SubagentRun[];
|
|
694
|
-
queuedMessages: QueuedMessage[];
|
|
695
726
|
backgroundTasks: BackgroundTaskSummary[];
|
|
696
727
|
todosByConversation?: Record<string, TodoItem[]>;
|
|
697
728
|
workflows?: WorkflowRun[];
|
|
@@ -703,63 +734,21 @@ export type TodoItem = {
|
|
|
703
734
|
status: "pending" | "in_progress" | "completed";
|
|
704
735
|
priority?: "high" | "medium" | "low";
|
|
705
736
|
};
|
|
706
|
-
/**
|
|
707
|
-
* One message the user sent WHILE THE AGENT WAS WORKING, held by Super Pi until a turn boundary can
|
|
708
|
-
* carry it.
|
|
709
|
-
*
|
|
710
|
-
* WHY THE SERVER OWNS THIS LIST AT ALL. Pi's own queues live in the Pi process's memory
|
|
711
|
-
* (`agent-session.ts:394/396`) and Super Pi kills that process unconditionally at the end of every
|
|
712
|
-
* turn (`runtime.ts:539-545`), while Pi never writes a queued message to the session JSONL. A list kept
|
|
713
|
-
* in Pi is therefore guaranteed to be destroyed, with no trace, on every turn boundary. So Pi is used
|
|
714
|
-
* only as a delivery pipe - one steer per turn, handed over at the moment it is allowed to be
|
|
715
|
-
* delivered - and the waiting list is this one.
|
|
716
|
-
*
|
|
717
|
-
* THE ID IS OURS AND IT IS THE POINT. Pi matches a queued message back to its queue entry by
|
|
718
|
-
* `indexOf` on the message TEXT (`agent-session.ts:1123/1129`), so two queued messages with identical
|
|
719
|
-
* text are indistinguishable to it and one of them is left behind in its queue forever. Every entry
|
|
720
|
-
* here therefore carries a Super Pi id that Pi never sees, and cancel/steer address THAT.
|
|
721
|
-
*
|
|
722
|
-
* `state` is the whole lifecycle, and `sending` is the only irreversible step:
|
|
723
|
-
*
|
|
724
|
-
* - `queued` - in the list, not yet given to Pi. CANCELLED freely; this is where essentially every
|
|
725
|
-
* message lives, because a message is only handed to Pi when it is about to be delivered.
|
|
726
|
-
* - `sending` - written to Pi's stdin as a `steer` and not yet confirmed delivered. NOT cancellable:
|
|
727
|
-
* Pi has no single-item retract (its only queue command is `clear_queue`, which clears
|
|
728
|
-
* everything), so claiming otherwise in the UI would be a lie. It goes back to `queued`
|
|
729
|
-
* exactly when we can prove Pi never delivered it - the turn ended - and stays here when
|
|
730
|
-
* Pi simply has not answered yet.
|
|
731
|
-
*
|
|
732
|
-
* A delivered or cancelled message is REMOVED rather than kept with a terminal state: a queue that
|
|
733
|
-
* retains sent messages needs a pruning policy and a way to read it, and a stale row in this list is
|
|
734
|
-
* worse than no row.
|
|
735
|
-
*/
|
|
736
|
-
export type QueuedMessage = {
|
|
737
|
-
/** `q_<uuid>`. Super Pi's own identity for this message, and the only thing cancel/steer name. */
|
|
738
|
-
id: string;
|
|
739
|
-
/**
|
|
740
|
-
* The conversation whose next turn will carry this. Deliberately a CONVERSATION id and never a
|
|
741
|
-
* `sub_*` run id: sub-agent children run in their own Pi processes under their own supervisor
|
|
742
|
-
* (`subagents.ts`), they have their own stop and their own lifecycle, and a queue entry keyed by one
|
|
743
|
-
* would be claimed and delivered by the wrong owner.
|
|
744
|
-
*/
|
|
745
|
-
conversationId: string;
|
|
746
|
-
content: string;
|
|
747
|
-
state: "queued" | "sending";
|
|
748
|
-
/** Chosen for priority delivery. Survives a Pi turn boundary and a server restart. */
|
|
749
|
-
priority?: boolean;
|
|
750
|
-
createdAt: string;
|
|
751
|
-
images?: MessageImage[];
|
|
752
|
-
attachments?: MessageAttachments;
|
|
753
|
-
};
|
|
754
737
|
/**
|
|
755
738
|
* What Pi reports about ITS OWN queue, forwarded to the browser as it arrives.
|
|
756
739
|
*
|
|
757
740
|
* `steering` and `followUp` are Pi's in-memory arrays as of this event (`agent-session.ts:204-208`).
|
|
758
|
-
* They are NOT
|
|
759
|
-
* is Pi's own view of the at most one steer
|
|
760
|
-
* is
|
|
761
|
-
* `followUp` array that is ever non-empty means
|
|
762
|
-
* worth being able to see rather than infer.
|
|
741
|
+
* They are NOT what the user sees: the authoritative list is the trailing `delivery`-bearing messages
|
|
742
|
+
* of the conversation itself (`MessageDelivery`), and this is Pi's own view of the at most one steer
|
|
743
|
+
* that is currently in flight inside the child process. It is forwarded because it is the only signal
|
|
744
|
+
* that proves a steer was really picked up, and because a `followUp` array that is ever non-empty means
|
|
745
|
+
* something outside this server sent a `follow_up` - worth being able to see rather than infer.
|
|
746
|
+
*
|
|
747
|
+
* It no longer decides anything. A delivery used to be inferred from a DROP in `steering.length` and
|
|
748
|
+
* applied by DELETING the oldest `sending` row, which is how a steered message could reach the model
|
|
749
|
+
* and never reach the transcript. Delivery is now announced directly, by the `message_start` Pi emits
|
|
750
|
+
* when it hands the text to the agent loop, and applied by moving a state on a message that was already
|
|
751
|
+
* on screen the whole time.
|
|
763
752
|
*/
|
|
764
753
|
export type PiQueueSnapshot = {
|
|
765
754
|
steering: string[];
|
|
@@ -989,6 +978,14 @@ export type ClientCommandBody =
|
|
|
989
978
|
* one gets a turn. The browser never picks, which is what makes the guard un-bypassable - the old
|
|
990
979
|
* arrangement had the browser decide by disabling itself, and a second WebSocket client (or a stale
|
|
991
980
|
* tab) walked straight past it into two Pi processes writing one session.
|
|
981
|
+
*
|
|
982
|
+
* `clientId` is how the browser's copy and the server's copy become THE SAME MESSAGE instead of two
|
|
983
|
+
* that have to be matched up afterwards. The browser mints the id, renders the message with it in the
|
|
984
|
+
* same frame it pressed send, and sends it here; the store uses it as the message id when it is well
|
|
985
|
+
* formed and not already taken. So when the broadcast arrives there is nothing to reconcile - no
|
|
986
|
+
* content matching, no `knownIds` set, no window in which the same sentence exists twice. It used to
|
|
987
|
+
* be matched by `content === content.trim()`, which cannot tell two identical messages apart and made
|
|
988
|
+
* the tests assert ordinals instead of messages. Omitted means "server, mint one".
|
|
992
989
|
*/
|
|
993
990
|
| {
|
|
994
991
|
type: "message.send";
|
|
@@ -996,6 +993,7 @@ export type ClientCommandBody =
|
|
|
996
993
|
content: string;
|
|
997
994
|
images?: MessageImage[];
|
|
998
995
|
attachments?: MessageAttachments;
|
|
996
|
+
clientId?: string;
|
|
999
997
|
}
|
|
1000
998
|
/**
|
|
1001
999
|
* Revert the conversation to the state right AFTER one message: every message after it is removed from
|
|
@@ -1018,21 +1016,49 @@ export type ClientCommandBody =
|
|
|
1018
1016
|
withdraw?: boolean;
|
|
1019
1017
|
}
|
|
1020
1018
|
/**
|
|
1021
|
-
*
|
|
1019
|
+
* Withdraws ONE waiting message, by its message id.
|
|
1022
1020
|
*
|
|
1023
|
-
* Addressed by `
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
* error, exactly as `subagent.stop` treats a run that has already settled.
|
|
1021
|
+
* Addressed by `messageId` rather than by text or by index because two waiting messages can be the
|
|
1022
|
+
* same string. Refused for a message already in `sending` - Pi holds that text now and cannot be told
|
|
1023
|
+
* to forget one item - and an unknown or already-removed id is a no-op rather than an error, exactly
|
|
1024
|
+
* as `subagent.stop` treats a run that has already settled.
|
|
1028
1025
|
*/
|
|
1029
1026
|
| {
|
|
1030
1027
|
type: "message.cancel";
|
|
1031
1028
|
conversationId: string;
|
|
1032
|
-
|
|
1029
|
+
messageId: string;
|
|
1033
1030
|
}
|
|
1034
1031
|
/**
|
|
1035
|
-
*
|
|
1032
|
+
* Rewrites the text of ONE waiting message that has not been given to Pi yet.
|
|
1033
|
+
*
|
|
1034
|
+
* The one mutation the old waiting list could not express: a row there was a content copy in an array
|
|
1035
|
+
* with no way to say "the user changed their mind about these words", so changing your mind meant
|
|
1036
|
+
* cancelling and retyping. Now the message itself is edited, in place, and it stays exactly where it
|
|
1037
|
+
* is. Refused for `sending`, for the same reason `cancel` is.
|
|
1038
|
+
*/
|
|
1039
|
+
| {
|
|
1040
|
+
type: "message.edit";
|
|
1041
|
+
conversationId: string;
|
|
1042
|
+
messageId: string;
|
|
1043
|
+
content: string;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Moves ONE waiting message to a new position among the waiting messages.
|
|
1047
|
+
*
|
|
1048
|
+
* This is the whole of what the old `priority` boolean meant, expressed as a position instead of a
|
|
1049
|
+
* flag. `toIndex` is counted over the WAITING messages only (`0` is the next one the agent will
|
|
1050
|
+
* start), so moving something to the front does not depend on how many answered messages sit above
|
|
1051
|
+
* it, and moving it back is the same command rather than a separate "un-prioritise" that would be a
|
|
1052
|
+
* second source of truth for one idea.
|
|
1053
|
+
*/
|
|
1054
|
+
| {
|
|
1055
|
+
type: "message.reorder";
|
|
1056
|
+
conversationId: string;
|
|
1057
|
+
messageId: string;
|
|
1058
|
+
toIndex: number;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Promotes one waiting message to a `steer`: Pi takes it at the next turn boundary instead of waiting
|
|
1036
1062
|
* for the whole task to finish.
|
|
1037
1063
|
*
|
|
1038
1064
|
* The timing is Pi's and cannot be finer: `agent-loop.ts:295` polls steering after `turn_end`, which
|
|
@@ -1040,17 +1066,23 @@ export type ClientCommandBody =
|
|
|
1040
1066
|
* this means "after the tools currently running finish", and during a long single tool call there is
|
|
1041
1067
|
* nothing to interrupt - Pi offers no preemption. The UI says so in words rather than implying
|
|
1042
1068
|
* instant delivery.
|
|
1069
|
+
*
|
|
1070
|
+
* What this USED TO DO that it no longer does: it also set a `priority` flag, and on failure it fell
|
|
1071
|
+
* back to setting that flag, so "make this the next one" and "hand this to Pi right now" were one
|
|
1072
|
+
* button and one code path. They are still one button - both move the message to the front of the
|
|
1073
|
+
* waiting block - but the flag is gone, so the fallback is now the same move rather than a second
|
|
1074
|
+
* mechanism that had to be kept in agreement with the first.
|
|
1043
1075
|
*/
|
|
1044
1076
|
| {
|
|
1045
1077
|
type: "message.steer";
|
|
1046
1078
|
conversationId: string;
|
|
1047
|
-
|
|
1079
|
+
messageId: string;
|
|
1048
1080
|
}
|
|
1049
|
-
/** Starts this
|
|
1081
|
+
/** Starts this waiting message as the next turn, only after the current turn has ended. */
|
|
1050
1082
|
| {
|
|
1051
1083
|
type: "message.sendQueued";
|
|
1052
1084
|
conversationId: string;
|
|
1053
|
-
|
|
1085
|
+
messageId: string;
|
|
1054
1086
|
} | {
|
|
1055
1087
|
type: "run.stop";
|
|
1056
1088
|
conversationId: string;
|