@evandu/super-pi 0.1.84 → 0.1.85
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/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 +8 -7
- package/dist/client/assets/index-B_jOwjyp.js +72 -0
- package/dist/client/assets/index-CNNGl4eF.css +1 -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 +59 -1
- package/dist/server/index.js +7 -0
- 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 +48 -1
- package/dist/server/runtime.js +83 -7
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/types.d.ts +97 -65
- package/package.json +163 -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
|
*
|
|
@@ -279,6 +311,19 @@ export declare class WelcomeRuntime implements AgentRuntime {
|
|
|
279
311
|
* drain loop can keep using it as the recogniser.
|
|
280
312
|
*/
|
|
281
313
|
export declare function describePiQueue(record: RpcRecord): PiQueueSnapshot | undefined;
|
|
314
|
+
/**
|
|
315
|
+
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
316
|
+
*
|
|
317
|
+
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
318
|
+
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
319
|
+
*
|
|
320
|
+
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
321
|
+
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
322
|
+
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
323
|
+
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
324
|
+
* position and not by comparing this.
|
|
325
|
+
*/
|
|
326
|
+
export declare function describeDeliveredUserMessage(record: RpcRecord): string | undefined;
|
|
282
327
|
/** A line to write back to Pi's stdin. Mirrors Pi's RpcExtensionUIResponse union (rpc-types.ts:294-297). */
|
|
283
328
|
export type ExtensionUiResponse = {
|
|
284
329
|
type: "extension_ui_response";
|
|
@@ -385,6 +430,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
385
430
|
private readonly options;
|
|
386
431
|
private memory?;
|
|
387
432
|
setMemory(memory: ActiveMemory): void;
|
|
433
|
+
private knowledge?;
|
|
434
|
+
setKnowledge(knowledge: KnowledgeBase): void;
|
|
388
435
|
private readonly runs;
|
|
389
436
|
/**
|
|
390
437
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
@@ -495,7 +542,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
495
542
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
496
543
|
*/
|
|
497
544
|
private taskExtensionEnv;
|
|
498
|
-
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
545
|
+
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }: AnswerInput): AsyncIterable<string>;
|
|
499
546
|
/**
|
|
500
547
|
* Reads back the id of the user message this turn just created, and hands it to the caller.
|
|
501
548
|
*
|
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
|