@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.
Files changed (40) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/agents/super-pi-task/index.ts +2 -2
  3. package/assets/knowledge-model/README.md +37 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  9. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  10. package/dist/.super-pi-check-inputs.json +4 -0
  11. package/dist/client/.super-pi-client-sources.json +10 -7
  12. package/dist/client/assets/index-B3QVoYJY.css +1 -0
  13. package/dist/client/assets/index-C_giUkvT.js +72 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/server/application/run-service.d.ts +50 -11
  16. package/dist/server/application/run-service.js +206 -77
  17. package/dist/server/command-handler.js +16 -4
  18. package/dist/server/http-server.js +218 -8
  19. package/dist/server/index.js +15 -1
  20. package/dist/server/knowledge-vectors.d.ts +133 -0
  21. package/dist/server/knowledge-vectors.js +302 -0
  22. package/dist/server/knowledge.d.ts +263 -0
  23. package/dist/server/knowledge.js +743 -0
  24. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  25. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  26. package/dist/server/runtime.d.ts +82 -1
  27. package/dist/server/runtime.js +128 -7
  28. package/dist/server/server-context.d.ts +3 -0
  29. package/dist/server/store.d.ts +153 -68
  30. package/dist/server/store.js +405 -187
  31. package/dist/server/tool-llm.d.ts +65 -0
  32. package/dist/server/tool-llm.js +139 -0
  33. package/dist/server/tool-registry.d.ts +65 -0
  34. package/dist/server/tool-registry.js +129 -6
  35. package/dist/server/tool-runner.d.ts +70 -0
  36. package/dist/server/tool-runner.js +210 -10
  37. package/dist/server/types.d.ts +97 -65
  38. package/package.json +166 -155
  39. package/dist/client/assets/index-8Pf3MG2d.js +0 -72
  40. 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, "queuedMessages" | "workflows" | "backgroundTasks">>;
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
- if (!Array.isArray(conversation.messages))
39
- conversation.messages = [];
40
- conversation.messages = conversation.messages.map(message => overrides.get(message.id) ?? message);
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));
@@ -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.
@@ -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
- const memoryEnabled = this.memory ? (await this.memory.configuration()).enabled : false;
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
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : 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"] : [])] });
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
  *