@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
|
@@ -10,11 +10,35 @@ export type StateTransaction = {
|
|
|
10
10
|
run: SubagentRun;
|
|
11
11
|
} | {
|
|
12
12
|
kind: "collections.replace";
|
|
13
|
-
values: Partial<Pick<AppState, "
|
|
13
|
+
values: Partial<Pick<AppState, "workflows" | "backgroundTasks">>;
|
|
14
14
|
} | {
|
|
15
15
|
kind: "conversation.metadata";
|
|
16
16
|
conversationId: string;
|
|
17
17
|
metadata: Omit<AppState["conversations"][number], "messages">;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A rewrite of one conversation's whole message array, for the changes that are about the ARRAY rather
|
|
21
|
+
* than about one message's content: a waiting message being added, claimed, released, reordered,
|
|
22
|
+
* edited or withdrawn, and an assistant message being spliced in after the message it answers.
|
|
23
|
+
*
|
|
24
|
+
* It exists because of where the waiting list moved. Those used to be `collections.replace` writes
|
|
25
|
+
* against a `queuedMessages` array that sat outside every conversation, so they never touched the
|
|
26
|
+
* transcript - which is precisely why a steered message could be delivered to the model and leave no
|
|
27
|
+
* trace in the conversation the user was reading. A waiting message IS a message now, so the write is
|
|
28
|
+
* about messages, and it is still its own kind because the array is rewritten as a unit while
|
|
29
|
+
* `message.replace` deliberately never sees a list.
|
|
30
|
+
*
|
|
31
|
+
* `updatedAt` travels WITH the array because opening a turn changes it, and this is the only write that
|
|
32
|
+
* could record that. Leaving it out and clearing the timestamp overlay instead - the shape
|
|
33
|
+
* `conversation.metadata` uses, where the metadata is the payload - would make a reopened conversation
|
|
34
|
+
* report the timestamp of whatever the base snapshot happened to hold, which is a value the server
|
|
35
|
+
* already decided was wrong.
|
|
36
|
+
*/
|
|
37
|
+
| {
|
|
38
|
+
kind: "conversation.messages";
|
|
39
|
+
conversationId: string;
|
|
40
|
+
messages: Message[];
|
|
41
|
+
updatedAt: string;
|
|
18
42
|
};
|
|
19
43
|
/** Durable backing store for the application's in-memory state snapshot. */
|
|
20
44
|
export interface AppStateRepository {
|
|
@@ -7,6 +7,19 @@ export class SqliteStateRepository {
|
|
|
7
7
|
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
8
8
|
CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), json TEXT NOT NULL);
|
|
9
9
|
CREATE TABLE IF NOT EXISTS messages (conversation TEXT NOT NULL, id TEXT NOT NULL, json TEXT NOT NULL, PRIMARY KEY(conversation,id));
|
|
10
|
+
/**
|
|
11
|
+
* Explicit transcript ORDER for a conversation, written by the conversation.messages transaction.
|
|
12
|
+
* Every other write path leaves message order in the base snapshot's array, which is fine as long as
|
|
13
|
+
* nothing ever REORDERS or REMOVES a message - and now something does: a waiting message can be
|
|
14
|
+
* moved within the group, or withdrawn, and both change which messages exist and in what sequence.
|
|
15
|
+
* Neither can be expressed by upserting a row per message id.
|
|
16
|
+
*
|
|
17
|
+
* It is AUTHORITATIVE once present, not a hint: load rebuilds that conversation's whole array from
|
|
18
|
+
* these rows rather than from the base json, because a withdrawn message is precisely one the base
|
|
19
|
+
* snapshot still contains and must NOT bring back. A conversation with no rows here is untouched and
|
|
20
|
+
* keeps reading its array from the snapshot, so this adds a code path rather than replacing one.
|
|
21
|
+
*/
|
|
22
|
+
CREATE TABLE IF NOT EXISTS message_order (conversation TEXT NOT NULL, id TEXT NOT NULL, position INTEGER NOT NULL, json TEXT NOT NULL, PRIMARY KEY(conversation,id));
|
|
10
23
|
CREATE TABLE IF NOT EXISTS timestamps (conversation TEXT PRIMARY KEY, stamp TEXT NOT NULL);
|
|
11
24
|
CREATE TABLE IF NOT EXISTS collections (name TEXT PRIMARY KEY, json TEXT NOT NULL);
|
|
12
25
|
CREATE TABLE IF NOT EXISTS metadata (conversation TEXT PRIMARY KEY, json TEXT NOT NULL);
|
|
@@ -35,9 +48,18 @@ export class SqliteStateRepository {
|
|
|
35
48
|
const message = JSON.parse(String(r.json));
|
|
36
49
|
return [message.id, message];
|
|
37
50
|
}));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
// An authoritative order row set means this conversation's transcript is rebuilt from it, rather
|
|
52
|
+
// than from the base snapshot's array. That is what makes a WITHDRAWN message stay withdrawn: the
|
|
53
|
+
// snapshot still lists it, and reading the array back would resurrect words the user took back.
|
|
54
|
+
const ordered = this.db.prepare("SELECT id,position,json FROM message_order WHERE conversation=? ORDER BY position").all(conversation.id);
|
|
55
|
+
if (ordered.length) {
|
|
56
|
+
conversation.messages = ordered.map(row => JSON.parse(String(row.json)));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (!Array.isArray(conversation.messages))
|
|
60
|
+
conversation.messages = [];
|
|
61
|
+
conversation.messages = conversation.messages.map(message => overrides.get(message.id) ?? message);
|
|
62
|
+
}
|
|
41
63
|
// Timestamp overlays are stored beside the base conversation metadata.
|
|
42
64
|
const stamp = this.db.prepare("SELECT stamp FROM timestamps WHERE conversation=?").get(conversation.id);
|
|
43
65
|
if (stamp?.stamp)
|
|
@@ -71,7 +93,7 @@ export class SqliteStateRepository {
|
|
|
71
93
|
async save(state) {
|
|
72
94
|
this.atomic(() => {
|
|
73
95
|
this.db.prepare("INSERT INTO state VALUES(1,?) ON CONFLICT(id) DO UPDATE SET json=excluded.json").run(JSON.stringify(state));
|
|
74
|
-
this.db.exec("DELETE FROM messages; DELETE FROM runs; DELETE FROM timestamps; DELETE FROM collections; DELETE FROM metadata;");
|
|
96
|
+
this.db.exec("DELETE FROM messages; DELETE FROM message_order; DELETE FROM runs; DELETE FROM timestamps; DELETE FROM collections; DELETE FROM metadata;");
|
|
75
97
|
});
|
|
76
98
|
}
|
|
77
99
|
async commit(transaction) {
|
|
@@ -91,6 +113,27 @@ export class SqliteStateRepository {
|
|
|
91
113
|
for (const [name, value] of Object.entries(transaction.values))
|
|
92
114
|
write.run(name, JSON.stringify(value));
|
|
93
115
|
}
|
|
116
|
+
else if (transaction.kind === "conversation.messages") {
|
|
117
|
+
// Rewrite the WHOLE array, order included. A per-id upsert cannot express this transaction: it
|
|
118
|
+
// both reorders (a waiting message moving within its group) and removes (a withdrawn one), and
|
|
119
|
+
// both change which messages exist. Replacing the set is the honest write, and it is one
|
|
120
|
+
// transaction because a reader that saw the order without the content, or the content without
|
|
121
|
+
// the order, would see a transcript that was never true.
|
|
122
|
+
this.db.prepare("DELETE FROM message_order WHERE conversation=?").run(transaction.conversationId);
|
|
123
|
+
this.db.prepare("DELETE FROM messages WHERE conversation=?").run(transaction.conversationId);
|
|
124
|
+
const upsertMessage = this.db.prepare("INSERT INTO messages VALUES(?,?,?) ON CONFLICT(conversation,id) DO UPDATE SET json=excluded.json");
|
|
125
|
+
const insertOrdered = this.db.prepare("INSERT INTO message_order VALUES(?,?,?,?)");
|
|
126
|
+
transaction.messages.forEach((message, position) => {
|
|
127
|
+
const json = JSON.stringify(message);
|
|
128
|
+
upsertMessage.run(transaction.conversationId, message.id, json);
|
|
129
|
+
insertOrdered.run(transaction.conversationId, message.id, position, json);
|
|
130
|
+
});
|
|
131
|
+
// The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
|
|
132
|
+
// that can record it; clearing the overlay instead would leave the reopened conversation reporting
|
|
133
|
+
// whatever the base snapshot held, which is the value the server had already decided was wrong.
|
|
134
|
+
this.db.prepare("INSERT INTO timestamps VALUES(?,?) ON CONFLICT(conversation) DO UPDATE SET stamp=excluded.stamp")
|
|
135
|
+
.run(transaction.conversationId, transaction.updatedAt);
|
|
136
|
+
}
|
|
94
137
|
else {
|
|
95
138
|
this.db.prepare("INSERT INTO metadata VALUES(?,?) ON CONFLICT(conversation) DO UPDATE SET json=excluded.json")
|
|
96
139
|
.run(transaction.conversationId, JSON.stringify(transaction.metadata));
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { type KnowledgeBase } from "./knowledge.js";
|
|
4
5
|
export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
5
6
|
import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice, type ToolCallPreparing } from "./types.js";
|
|
6
7
|
/**
|
|
@@ -20,6 +21,16 @@ export interface AnswerInput {
|
|
|
20
21
|
/** Internal isolated review: no built-in tools, delegation or user-session writes. */
|
|
21
22
|
memoryReview?: boolean;
|
|
22
23
|
memorySourceConversationId?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Internal isolated one-shot: the knowledge reranker asking the model to order candidate passages.
|
|
26
|
+
*
|
|
27
|
+
* WHY IT IS A SEPARATE FLAG AND NOT A REUSE OF `memoryReview`. The two differ in what they must NOT
|
|
28
|
+
* have. A review legitimately needs the memory tools to write down what it finds; a reranker must
|
|
29
|
+
* have no tool surface at all - it only has to emit a JSON array, and any tool it reaches for is a
|
|
30
|
+
* wasted round trip inside somebody else's turn. Folding one into the other would hand the reranker
|
|
31
|
+
* the memory surface, which is exactly the wrong privilege for a read-only ranking task.
|
|
32
|
+
*/
|
|
33
|
+
knowledgeRerank?: boolean;
|
|
23
34
|
conversation: Conversation;
|
|
24
35
|
prompt: string;
|
|
25
36
|
images?: MessageImage[];
|
|
@@ -44,6 +55,27 @@ export interface AnswerInput {
|
|
|
44
55
|
* unhandled and dropped is now read.
|
|
45
56
|
*/
|
|
46
57
|
onPiQueue?(queue: PiQueueSnapshot): void;
|
|
58
|
+
/**
|
|
59
|
+
* Called when Pi STARTS A NEW USER MESSAGE of its own, mid-turn, carrying the text it is answering.
|
|
60
|
+
*
|
|
61
|
+
* This is the delivered-steer signal, and it is what makes a steered message visible in the
|
|
62
|
+
* transcript. Pi drains its steering queue at a turn boundary (`agent-loop.ts:295`) and announces the
|
|
63
|
+
* delivery by emitting `message_start` with the user message it just picked up
|
|
64
|
+
* (`agent-session.ts:1116-1136`) - so the boundary where a steer stops waiting and starts being
|
|
65
|
+
* answered is an EVENT, not something to infer.
|
|
66
|
+
*
|
|
67
|
+
* It used to be inferred, from a drop in `steering.length` on the `queue_update` above, and applied
|
|
68
|
+
* by deleting the oldest `sending` row. Two consequences, both bad: the message the user could no
|
|
69
|
+
* longer find in the queue was never written into the transcript at all, so the agent answered words
|
|
70
|
+
* that existed only in Pi's session file; and a count cannot say WHICH row, so an out-of-order
|
|
71
|
+
* snapshot could drop a row that had not been delivered. The text is passed so a host MAY match on
|
|
72
|
+
* it, but the honest primary key remains the oldest `sending` message, for the same reason Pi
|
|
73
|
+
* itself dequeues by `indexOf` on text and cannot tell two identical messages apart.
|
|
74
|
+
*
|
|
75
|
+
* Only fires for a steer this turn delivered, never for the prompt that started the turn: the prompt
|
|
76
|
+
* is not re-announced, and a caller's own state decides whether there was anything waiting at all.
|
|
77
|
+
*/
|
|
78
|
+
onSteerDelivered?(text: string): void;
|
|
47
79
|
/**
|
|
48
80
|
* Called ONCE per turn with the Pi entry id of the user message that started it, or never.
|
|
49
81
|
*
|
|
@@ -213,6 +245,30 @@ export interface AgentRuntime {
|
|
|
213
245
|
* Absent on the welcome runtime, which has no Pi behind it.
|
|
214
246
|
*/
|
|
215
247
|
summarizeConversation?(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
|
|
248
|
+
/**
|
|
249
|
+
* ONE-SHOT COMPLETION, FOR A MOUNTED TOOL. A throwaway Pi child, one prompt, one answer, then gone.
|
|
250
|
+
*
|
|
251
|
+
* WHY IT IS A SEPARATE CHILD, LIKE `summarizeConversation`: a turn's child is the one reading the
|
|
252
|
+
* user's stream. Writing a tool's prompt at it would land in the conversation the user is watching.
|
|
253
|
+
*
|
|
254
|
+
* WHY NOTHING IS ATTACHED BUT Pi ITSELF. `--no-extensions --no-skills --no-mcp --no-context-files
|
|
255
|
+
* --no-prompt-templates`, the same line the knowledge reranker uses: a tool asking the model a question
|
|
256
|
+
* must not be able to reach the user's files, the task tools, or a session history. A model that
|
|
257
|
+
* spends the turn on a tool call anyway gets no answer and the call times out - the same accepted
|
|
258
|
+
* failure mode the reranker documents.
|
|
259
|
+
*
|
|
260
|
+
* WHY IT CHOOSES NO MODEL. Pi's own default is the user's own default, and a sandboxed frame is the
|
|
261
|
+
* last place that should get to pick which model spends the user's quota.
|
|
262
|
+
*
|
|
263
|
+
* Absent on a runtime with no Pi behind it, which the caller reports as a refusal rather than
|
|
264
|
+
* pretending a model answered.
|
|
265
|
+
*/
|
|
266
|
+
askOnce?(input: {
|
|
267
|
+
prompt: string;
|
|
268
|
+
timeoutMs: number;
|
|
269
|
+
cwd: string;
|
|
270
|
+
signal?: AbortSignal;
|
|
271
|
+
}): Promise<string>;
|
|
216
272
|
/**
|
|
217
273
|
* The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
|
|
218
274
|
* no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
|
|
@@ -279,6 +335,19 @@ export declare class WelcomeRuntime implements AgentRuntime {
|
|
|
279
335
|
* drain loop can keep using it as the recogniser.
|
|
280
336
|
*/
|
|
281
337
|
export declare function describePiQueue(record: RpcRecord): PiQueueSnapshot | undefined;
|
|
338
|
+
/**
|
|
339
|
+
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
340
|
+
*
|
|
341
|
+
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
342
|
+
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
343
|
+
*
|
|
344
|
+
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
345
|
+
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
346
|
+
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
347
|
+
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
348
|
+
* position and not by comparing this.
|
|
349
|
+
*/
|
|
350
|
+
export declare function describeDeliveredUserMessage(record: RpcRecord): string | undefined;
|
|
282
351
|
/** A line to write back to Pi's stdin. Mirrors Pi's RpcExtensionUIResponse union (rpc-types.ts:294-297). */
|
|
283
352
|
export type ExtensionUiResponse = {
|
|
284
353
|
type: "extension_ui_response";
|
|
@@ -385,6 +454,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
385
454
|
private readonly options;
|
|
386
455
|
private memory?;
|
|
387
456
|
setMemory(memory: ActiveMemory): void;
|
|
457
|
+
private knowledge?;
|
|
458
|
+
setKnowledge(knowledge: KnowledgeBase): void;
|
|
388
459
|
private readonly runs;
|
|
389
460
|
/**
|
|
390
461
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
@@ -495,7 +566,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
495
566
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
496
567
|
*/
|
|
497
568
|
private taskExtensionEnv;
|
|
498
|
-
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
569
|
+
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }: AnswerInput): AsyncIterable<string>;
|
|
499
570
|
/**
|
|
500
571
|
* Reads back the id of the user message this turn just created, and hands it to the caller.
|
|
501
572
|
*
|
|
@@ -622,6 +693,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
622
693
|
* model; in the second the outcome is genuinely unknown and a `sending` state says so.
|
|
623
694
|
*/
|
|
624
695
|
summarizeConversation(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
|
|
696
|
+
/**
|
|
697
|
+
* A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
|
|
698
|
+
* why nothing is attached but Pi, and why it picks no model.
|
|
699
|
+
*/
|
|
700
|
+
askOnce(input: {
|
|
701
|
+
prompt: string;
|
|
702
|
+
timeoutMs: number;
|
|
703
|
+
cwd: string;
|
|
704
|
+
signal?: AbortSignal;
|
|
705
|
+
}): Promise<string>;
|
|
625
706
|
steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
626
707
|
/**
|
|
627
708
|
* The model/thinking catalog, from Pi, cached.
|
package/dist/server/runtime.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { RpcChannel } from "./rpc-channel.js";
|
|
3
3
|
export { RpcChannel } from "./rpc-channel.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
|
|
5
6
|
import { existsSync } from "node:fs";
|
|
6
7
|
import { mkdir, stat } from "node:fs/promises";
|
|
7
8
|
import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
@@ -64,6 +65,32 @@ export function describePiQueue(record) {
|
|
|
64
65
|
return undefined;
|
|
65
66
|
return { steering: textList(record.steering) ?? [], followUp: textList(record.followUp) ?? [] };
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
70
|
+
*
|
|
71
|
+
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
72
|
+
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
73
|
+
*
|
|
74
|
+
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
75
|
+
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
76
|
+
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
77
|
+
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
78
|
+
* position and not by comparing this.
|
|
79
|
+
*/
|
|
80
|
+
export function describeDeliveredUserMessage(record) {
|
|
81
|
+
if (record.type !== "message_start")
|
|
82
|
+
return undefined;
|
|
83
|
+
const message = record.message;
|
|
84
|
+
if (!message || typeof message !== "object" || message.role !== "user")
|
|
85
|
+
return undefined;
|
|
86
|
+
const content = message.content;
|
|
87
|
+
if (!Array.isArray(content))
|
|
88
|
+
return undefined;
|
|
89
|
+
const parts = content
|
|
90
|
+
.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : undefined))
|
|
91
|
+
.filter((part) => part !== undefined);
|
|
92
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
93
|
+
}
|
|
67
94
|
const NOTIFY_TYPES = new Set(["info", "warning", "error"]);
|
|
68
95
|
const PLACEMENTS = new Set(["aboveEditor", "belowEditor"]);
|
|
69
96
|
const text = (value) => (typeof value === "string" ? value : undefined);
|
|
@@ -257,6 +284,8 @@ export class PiRpcRuntime {
|
|
|
257
284
|
options;
|
|
258
285
|
memory;
|
|
259
286
|
setMemory(memory) { this.memory = memory; }
|
|
287
|
+
knowledge;
|
|
288
|
+
setKnowledge(knowledge) { this.knowledge = knowledge; }
|
|
260
289
|
runs = new Map();
|
|
261
290
|
/**
|
|
262
291
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
@@ -411,7 +440,7 @@ export class PiRpcRuntime {
|
|
|
411
440
|
[TASK_CAPABILITY_ENV]: capability,
|
|
412
441
|
};
|
|
413
442
|
}
|
|
414
|
-
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }) {
|
|
443
|
+
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
|
|
415
444
|
const latencyStartedAt = performance.now();
|
|
416
445
|
traceLatency(conversation.id, "answer.begin", latencyStartedAt);
|
|
417
446
|
await mkdir(this.sessionDir, { recursive: true });
|
|
@@ -426,7 +455,7 @@ export class PiRpcRuntime {
|
|
|
426
455
|
// them: its RPC command union (rpc-types.ts:20-74) has no prompt and no tools command, so there is
|
|
427
456
|
// nothing to send after the process is already running. Fetched before the spawn so a definition
|
|
428
457
|
// error fails the turn loudly instead of starting a Pi that silently ignores it.
|
|
429
|
-
const agent = memoryReview ? undefined : await this.agentFor(conversation);
|
|
458
|
+
const agent = memoryReview || knowledgeRerank ? undefined : await this.agentFor(conversation);
|
|
430
459
|
const validationId = memorySourceConversationId ?? conversation.id;
|
|
431
460
|
if (agent?.model)
|
|
432
461
|
await this.modelValidator?.(validationId, { ...agent.model, label: agent.model.modelId });
|
|
@@ -443,24 +472,42 @@ export class PiRpcRuntime {
|
|
|
443
472
|
// declared - broken with no error. An agent that says `task: false` still wins: `toolFlags` treats an
|
|
444
473
|
// explicit false as outranking a host extra, which is what makes "this agent does not delegate" true
|
|
445
474
|
// rather than advisory.
|
|
446
|
-
const extensionArgs = memoryReview ? [] : await this.taskExtensionArgs();
|
|
447
|
-
|
|
475
|
+
const extensionArgs = memoryReview || knowledgeRerank ? [] : await this.taskExtensionArgs();
|
|
476
|
+
// A reranker gets no memory capability at all. `memoryReview` mints one deliberately, because a
|
|
477
|
+
// review is supposed to write memory; a reranker must not be able to, so the token is simply
|
|
478
|
+
// never issued rather than issued with a flag.
|
|
479
|
+
const memoryEnabled = !knowledgeRerank && this.memory ? (await this.memory.configuration()).enabled : false;
|
|
448
480
|
const memoryToken = memoryEnabled ? this.memory.issue(validationId, { review: memoryReview }) : undefined;
|
|
449
481
|
const memoryExtension = resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts");
|
|
450
482
|
const memoryArgs = memoryEnabled ? ["--extension", memoryExtension] : [];
|
|
483
|
+
// Mounted only when the workspace actually HAS documents. An empty knowledge base would otherwise
|
|
484
|
+
// cost a catalogue round trip and one declared tool on every turn to tell the model there is
|
|
485
|
+
// nothing to find. Review turns never mount it: a background reflection has no business
|
|
486
|
+
// retrieving documents it was not asked about, and its `--tools` line is a closed list. A
|
|
487
|
+
// reranker never mounts it either - it must not be able to call the search it is ranking.
|
|
488
|
+
const knowledgeEnabled = !memoryReview && !knowledgeRerank && !!this.knowledge && await this.knowledge.hasDocuments(conversation.workspaceId).catch(() => false);
|
|
489
|
+
const knowledgeToken = knowledgeEnabled ? this.knowledge.issue(conversation.id) : undefined;
|
|
490
|
+
const knowledgeExtension = resolve(import.meta.dirname, "../../agents/super-pi-knowledge/index.ts");
|
|
491
|
+
const knowledgeArgs = knowledgeEnabled ? ["--extension", knowledgeExtension] : [];
|
|
451
492
|
// BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
|
|
452
493
|
// different direction: Pi filters its registry through `--tools`, so an agent that declares
|
|
453
494
|
// `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
|
|
454
495
|
// other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
|
|
455
496
|
// `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
|
|
456
497
|
// the other is not.
|
|
457
|
-
|
|
498
|
+
// A RERANK SPAWNS WITH NOTHING ATTACHED BUT Pi ITSELF. No extensions, no skills, no MCP, no agent
|
|
499
|
+
// definition, no session-scoped tools - it has one job, which is to turn a list of passages into a
|
|
500
|
+
// list of indices. Note that Pi's BUILT-IN tools are not removed here: `--tools` needs a real
|
|
501
|
+
// allowlist and naming a tool Pi has not registered would change the spawn line for every other
|
|
502
|
+
// mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
|
|
503
|
+
// tool call anyway the reranker times out and falls back to the lexical order.
|
|
504
|
+
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME] : [])] });
|
|
458
505
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
459
506
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
460
507
|
if (!this.pi.found)
|
|
461
508
|
throw new Error(this.pi.reason);
|
|
462
509
|
// npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
|
|
463
|
-
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...agentArgs], {
|
|
510
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...agentArgs], {
|
|
464
511
|
cwd: childCwd,
|
|
465
512
|
stdio: ["pipe", "pipe", "pipe"],
|
|
466
513
|
shell: process.platform === "win32",
|
|
@@ -468,10 +515,13 @@ export class PiRpcRuntime {
|
|
|
468
515
|
// every turn would silently replace the child's environment with whatever THIS process has at
|
|
469
516
|
// spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
|
|
470
517
|
// but the four documented additions.
|
|
471
|
-
...((extensionArgs.length || memoryEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.taskExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
|
|
518
|
+
...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.taskExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
|
|
472
519
|
SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
|
|
473
520
|
SUPER_PI_MEMORY_TOKEN: memoryToken,
|
|
474
521
|
SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
|
|
522
|
+
} : {}), ...(knowledgeEnabled ? {
|
|
523
|
+
SUPER_PI_KNOWLEDGE_ENDPOINT: process.env.SUPER_PI_KNOWLEDGE_ENDPOINT,
|
|
524
|
+
SUPER_PI_KNOWLEDGE_TOKEN: knowledgeToken,
|
|
475
525
|
} : {}) } } : {}),
|
|
476
526
|
});
|
|
477
527
|
const channel = new RpcChannel(child);
|
|
@@ -616,6 +666,30 @@ export class PiRpcRuntime {
|
|
|
616
666
|
}
|
|
617
667
|
continue;
|
|
618
668
|
}
|
|
669
|
+
// A STEER THIS TURN DELIVERED. Pi announces the moment it stops waiting and starts being
|
|
670
|
+
// answered by emitting `message_start` with the user message it picked up
|
|
671
|
+
// (`agent-session.ts:1116-1136`) - on the same stdout stream, immediately after the
|
|
672
|
+
// `queue_update` above that shows the entry gone.
|
|
673
|
+
//
|
|
674
|
+
// READ, not dropped, and this is the fix rather than a nicety. It used to fall through to
|
|
675
|
+
// `noteUnhandled`, which is why a steered message reached the model and never reached the
|
|
676
|
+
// transcript: nothing here told the host that a new user message had begun, so the answer to
|
|
677
|
+
// it was appended to the assistant message that was already streaming and the user's own words
|
|
678
|
+
// were never written anywhere they could be read. A host can now split the turn at this
|
|
679
|
+
// boundary and put the answer under the message it answers.
|
|
680
|
+
//
|
|
681
|
+
// It is consumed silently and NOT announced as unhandled, because `message_start` for a user
|
|
682
|
+
// role is a delivery notice, not something a reader needs to be told about in a log.
|
|
683
|
+
const steeredText = describeDeliveredUserMessage(record);
|
|
684
|
+
if (steeredText !== undefined) {
|
|
685
|
+
try {
|
|
686
|
+
onSteerDelivered?.(steeredText);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
690
|
+
}
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
619
693
|
// THE PRIMARY AGENT'S TOOL CALLS. Consumed, not announced - the same routing the sub-agent
|
|
620
694
|
// supervisor already does for its child (subagents.ts:532-559), on the same two records.
|
|
621
695
|
//
|
|
@@ -824,6 +898,8 @@ export class PiRpcRuntime {
|
|
|
824
898
|
releaseCapability(conversation.id);
|
|
825
899
|
if (memoryToken)
|
|
826
900
|
this.memory?.release(memoryToken);
|
|
901
|
+
if (knowledgeToken)
|
|
902
|
+
this.knowledge?.release(knowledgeToken);
|
|
827
903
|
// A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
|
|
828
904
|
// is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
|
|
829
905
|
// promise: the caller would wait for a timeout to learn something this process already knows. The
|
|
@@ -1284,6 +1360,51 @@ export class PiRpcRuntime {
|
|
|
1284
1360
|
await killTree(child.pid);
|
|
1285
1361
|
}
|
|
1286
1362
|
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
|
|
1365
|
+
* why nothing is attached but Pi, and why it picks no model.
|
|
1366
|
+
*/
|
|
1367
|
+
async askOnce(input) {
|
|
1368
|
+
if (!this.pi.found)
|
|
1369
|
+
throw new Error(this.pi.reason);
|
|
1370
|
+
// A throwaway session id, for the same reason the autotitle child gets one: this exchange is not
|
|
1371
|
+
// part of any conversation's transcript and must never be filed under one.
|
|
1372
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `tool-llm-${randomUUID()}`, "--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"], {
|
|
1373
|
+
cwd: resolve(input.cwd),
|
|
1374
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1375
|
+
shell: process.platform === "win32",
|
|
1376
|
+
});
|
|
1377
|
+
const channel = new RpcChannel(child);
|
|
1378
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
1379
|
+
try {
|
|
1380
|
+
channel.write({ id: "tool_llm", type: "prompt", message: input.prompt });
|
|
1381
|
+
let settled = false, text = "";
|
|
1382
|
+
while (!settled && Date.now() < deadline) {
|
|
1383
|
+
if (input.signal?.aborted)
|
|
1384
|
+
throw new Error("已取消");
|
|
1385
|
+
const record = await channel.next(100);
|
|
1386
|
+
if (!record)
|
|
1387
|
+
continue;
|
|
1388
|
+
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta")
|
|
1389
|
+
text += record.assistantMessageEvent.delta ?? "";
|
|
1390
|
+
if (record.type === "agent_settled")
|
|
1391
|
+
settled = true;
|
|
1392
|
+
if (record.type === "response" && record.id === "tool_llm" && record.success === false)
|
|
1393
|
+
throw new Error(record.error ?? "Pi 拒绝了这次请求");
|
|
1394
|
+
if (child.exitCode !== null)
|
|
1395
|
+
throw new Error("模型进程意外退出");
|
|
1396
|
+
}
|
|
1397
|
+
if (!settled)
|
|
1398
|
+
throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
|
|
1399
|
+
if (!text.trim())
|
|
1400
|
+
throw new Error("模型没有返回任何内容");
|
|
1401
|
+
return text;
|
|
1402
|
+
}
|
|
1403
|
+
finally {
|
|
1404
|
+
if (child.exitCode === null && child.pid !== undefined)
|
|
1405
|
+
await killTree(child.pid);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1287
1408
|
async steer(conversationId, message, images) {
|
|
1288
1409
|
const run = this.runs.get(conversationId);
|
|
1289
1410
|
if (!run || !this.canSteer(conversationId))
|
|
@@ -37,6 +37,7 @@ export interface EventBroadcaster {
|
|
|
37
37
|
*/
|
|
38
38
|
export interface ServerContext {
|
|
39
39
|
memory?: import("./active-memory.js").ActiveMemory;
|
|
40
|
+
knowledge?: import("./knowledge.js").KnowledgeBase;
|
|
40
41
|
paths: ServerPaths;
|
|
41
42
|
store: Store;
|
|
42
43
|
runtime: AgentRuntime;
|
|
@@ -51,6 +52,8 @@ export interface ServerContext {
|
|
|
51
52
|
execService: ExecService;
|
|
52
53
|
toolRegistry: ToolRegistry;
|
|
53
54
|
toolRunner: ToolRunner;
|
|
55
|
+
/** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
|
|
56
|
+
toolLlm?: import("./tool-llm.js").ToolLlmService;
|
|
54
57
|
/**
|
|
55
58
|
* Whether the right rail's terminal tab should exist, and why not when it should not.
|
|
56
59
|
*
|