@evandu/super-pi 0.1.130 → 0.1.132

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 (88) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-documents/index.ts +92 -0
  3. package/agents/super-pi-feishu/index.ts +88 -0
  4. package/agents/super-pi-knowledge/index.ts +32 -1
  5. package/agents/super-pi-task/core.ts +217 -0
  6. package/agents/super-pi-task/index.ts +74 -1
  7. package/agents/super-pi-web/index.ts +32 -0
  8. package/assets/fonts/LICENSE +92 -0
  9. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  10. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  11. package/dist/client/.super-pi-client-sources.json +13 -10
  12. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  13. package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
  14. package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
  15. package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
  16. package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
  17. package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
  18. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
  19. package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
  20. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
  21. package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
  22. package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
  23. package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
  26. package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
  27. package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
  28. package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
  29. package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
  30. package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
  31. package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
  32. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
  33. package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
  34. package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
  35. package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
  36. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/server/application/document-service.d.ts +68 -0
  39. package/dist/server/application/document-service.js +120 -0
  40. package/dist/server/application/feishu-service.d.ts +132 -0
  41. package/dist/server/application/feishu-service.js +264 -0
  42. package/dist/server/application/run-service.js +49 -7
  43. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  44. package/dist/server/application/scheduled-task-service.js +350 -0
  45. package/dist/server/command-handler.d.ts +25 -1
  46. package/dist/server/command-handler.js +60 -1
  47. package/dist/server/documents.d.ts +152 -0
  48. package/dist/server/documents.js +998 -0
  49. package/dist/server/feishu-cli.d.ts +207 -0
  50. package/dist/server/feishu-cli.js +419 -0
  51. package/dist/server/file-changes.js +6 -4
  52. package/dist/server/files.d.ts +32 -0
  53. package/dist/server/files.js +31 -3
  54. package/dist/server/http-server.js +324 -2
  55. package/dist/server/index.js +41 -2
  56. package/dist/server/inline-thinking.d.ts +90 -0
  57. package/dist/server/inline-thinking.js +181 -0
  58. package/dist/server/knowledge.d.ts +24 -14
  59. package/dist/server/knowledge.js +57 -34
  60. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  61. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  62. package/dist/server/pi-session-pool.js +2 -1
  63. package/dist/server/pi-session.d.ts +4 -0
  64. package/dist/server/pi-session.js +14 -0
  65. package/dist/server/presentations/schema.d.ts +120 -0
  66. package/dist/server/presentations/schema.js +128 -0
  67. package/dist/server/runtime.d.ts +67 -0
  68. package/dist/server/runtime.js +204 -18
  69. package/dist/server/scheduled-tasks.d.ts +101 -0
  70. package/dist/server/scheduled-tasks.js +138 -0
  71. package/dist/server/server-context.d.ts +19 -0
  72. package/dist/server/store.d.ts +30 -0
  73. package/dist/server/store.js +81 -2
  74. package/dist/server/subagents.js +33 -6
  75. package/dist/server/task-tool.d.ts +18 -0
  76. package/dist/server/task-tool.js +18 -0
  77. package/dist/server/types.d.ts +56 -0
  78. package/dist/server/web.d.ts +24 -0
  79. package/dist/server/web.js +180 -0
  80. package/package.json +45 -6
  81. package/skills/presentation/SKILL.md +30 -0
  82. package/skills/presentation/references/data.md +10 -0
  83. package/skills/presentation/references/quality.md +12 -0
  84. package/skills/presentation/references/story.md +12 -0
  85. package/skills/presentation/references/templates-and-editing.md +11 -0
  86. package/skills/presentation/references/visual-system.md +10 -0
  87. package/dist/.super-pi-check-inputs.json +0 -4
  88. package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
@@ -0,0 +1,90 @@
1
+ /**
2
+ * THE REASONING A MODEL PUTS INSIDE ITS OWN ANSWER.
3
+ *
4
+ * Some models do not have a reasoning channel. They answer in one stream of text and put their
5
+ * thinking in it, marked up with `<think>…</think>` tags - MiniMax's M2 series is the one this
6
+ * file exists for, and every gateway that proxies it behaves the same way. The tags arrive as
7
+ * ordinary text, so nothing downstream can tell them from an answer.
8
+ *
9
+ * WHY THIS IS SUPER PI'S PROBLEM AND NOT THE USER'S. Pi's OpenAI-compatible adapter only treats
10
+ * `reasoning_content`, `reasoning`, `reasoning_text` and `reasoning_details` as reasoning; it
11
+ * never parses tags out of `content`. A provider that inlines its thinking therefore hands Pi one
12
+ * long text block, Pi hands Super Pi one long text block, and Super Pi would draw the reasoning as
13
+ * part of the answer - in the reader's face, unlabelled, with the tags still in it. The obvious
14
+ * "fix" is to tell the user to go and set a provider-specific switch in Pi's `models.json`, which
15
+ * is a private API of a program Super Pi ships, for a quirk of one vendor. A user who never
16
+ * hears of it sees a broken transcript and has no way to know why.
17
+ *
18
+ * SO THE SPLIT HAPPENS HERE, ON SUPER PI'S OWN SIDE, FOR EVERY PROVIDER. Whatever the endpoint
19
+ * does with its reasoning, the transcript reads the same way: the reasoning goes into a thinking
20
+ * block, the answer goes into the answer, and the tags are never shown to anyone.
21
+ *
22
+ * THE ORDER IS THE WHOLE TRICK. A tag can straddle two deltas - the model writes `</thi` and then
23
+ * `nk>` in the next one - so the splitter holds back the tail that might be the start of a tag
24
+ * until the next delta settles it. And a delta can hold an answer, a thought and another answer,
25
+ * so the splitter returns SEGMENTS IN ORDER rather than "the text" and "the thinking": the caller
26
+ * has to be able to put the answer that came before a thought after it, and has to be able to say
27
+ * where that thought began.
28
+ *
29
+ * THE RULES, because each of them has a case that would otherwise be a bug:
30
+ *
31
+ * - A PROPERLY CLOSED region is reasoning. It leaves the answer and enters the thinking block.
32
+ * - AN UNCLOSED region is reasoning too. The model said it was thinking and then the turn
33
+ * ended - an abort, a truncation, a provider that stopped mid-thought. The reasoning goes
34
+ * into the block, and the answer keeps whatever came before the tag. Nothing is thrown away
35
+ * either way: the fold is expandable, so the words are still there to be read.
36
+ * - A CLOSING TAG WITH NOTHING OPEN is dropped. The model wrote it by mistake, and showing it
37
+ * would be showing a fragment of markup as if it were content.
38
+ * - TEXT IS NEVER LOST. A `<` that turns out not to begin a tag is held for one delta and then
39
+ * released as text, so a comparison like `a < b` survives intact.
40
+ */
41
+ /**
42
+ * One piece of a turn's text, in the order the model wrote it.
43
+ *
44
+ * `thinking-end` is a segment of its own because the end of a reasoning region is a FACT the
45
+ * browser needs and not text: it is what closes the "正在思考" indicator and what tells the
46
+ * transcript that the next fold is a new one.
47
+ */
48
+ export type InlineThinkingSegment = {
49
+ kind: "text";
50
+ text: string;
51
+ } | {
52
+ kind: "thinking";
53
+ text: string;
54
+ } | {
55
+ kind: "thinking-end";
56
+ };
57
+ /**
58
+ * Splits a model's text into the answer and the reasoning the model wrote inside it.
59
+ *
60
+ * ONE SPLITTER PER TURN. The state it keeps - the held-back tail and whether the stream is
61
+ * inside a reasoning region - belongs to a turn and to nothing else, so a splitter is created
62
+ * when a turn starts and dropped when it ends.
63
+ */
64
+ export declare class InlineThinkingSplitter {
65
+ /** The tail that might be the start of a tag, held until the next delta settles it. */
66
+ private held;
67
+ /** True while the stream is inside a reasoning region. */
68
+ private inside;
69
+ /**
70
+ * Feeds one delta in and gets back the segments it produced, in order.
71
+ *
72
+ * A delta that carries nothing but text produces one text segment. A delta that opens a
73
+ * reasoning region produces the text before it, then the reasoning, then the end of the
74
+ * reasoning when the closing tag arrives - and the text after it, if there is any.
75
+ */
76
+ push(delta: string): InlineThinkingSegment[];
77
+ /**
78
+ * Closes the turn: whatever is still held is released, and a reasoning region that never
79
+ * closed is finished as one.
80
+ */
81
+ flush(): InlineThinkingSegment[];
82
+ }
83
+ /**
84
+ * The answer inside a model's text, with the reasoning taken out of it.
85
+ *
86
+ * For the paths that want a STRING rather than a stream of segments: the title generator, the
87
+ * tool LLM, a sub-agent's result. A title with a `<think>` prefix in it is not a title, and a
88
+ * sub-agent's card whose summary starts with markup reads as broken.
89
+ */
90
+ export declare function stripInlineThinking(text: string): string;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * THE REASONING A MODEL PUTS INSIDE ITS OWN ANSWER.
3
+ *
4
+ * Some models do not have a reasoning channel. They answer in one stream of text and put their
5
+ * thinking in it, marked up with `<think>…</think>` tags - MiniMax's M2 series is the one this
6
+ * file exists for, and every gateway that proxies it behaves the same way. The tags arrive as
7
+ * ordinary text, so nothing downstream can tell them from an answer.
8
+ *
9
+ * WHY THIS IS SUPER PI'S PROBLEM AND NOT THE USER'S. Pi's OpenAI-compatible adapter only treats
10
+ * `reasoning_content`, `reasoning`, `reasoning_text` and `reasoning_details` as reasoning; it
11
+ * never parses tags out of `content`. A provider that inlines its thinking therefore hands Pi one
12
+ * long text block, Pi hands Super Pi one long text block, and Super Pi would draw the reasoning as
13
+ * part of the answer - in the reader's face, unlabelled, with the tags still in it. The obvious
14
+ * "fix" is to tell the user to go and set a provider-specific switch in Pi's `models.json`, which
15
+ * is a private API of a program Super Pi ships, for a quirk of one vendor. A user who never
16
+ * hears of it sees a broken transcript and has no way to know why.
17
+ *
18
+ * SO THE SPLIT HAPPENS HERE, ON SUPER PI'S OWN SIDE, FOR EVERY PROVIDER. Whatever the endpoint
19
+ * does with its reasoning, the transcript reads the same way: the reasoning goes into a thinking
20
+ * block, the answer goes into the answer, and the tags are never shown to anyone.
21
+ *
22
+ * THE ORDER IS THE WHOLE TRICK. A tag can straddle two deltas - the model writes `</thi` and then
23
+ * `nk>` in the next one - so the splitter holds back the tail that might be the start of a tag
24
+ * until the next delta settles it. And a delta can hold an answer, a thought and another answer,
25
+ * so the splitter returns SEGMENTS IN ORDER rather than "the text" and "the thinking": the caller
26
+ * has to be able to put the answer that came before a thought after it, and has to be able to say
27
+ * where that thought began.
28
+ *
29
+ * THE RULES, because each of them has a case that would otherwise be a bug:
30
+ *
31
+ * - A PROPERLY CLOSED region is reasoning. It leaves the answer and enters the thinking block.
32
+ * - AN UNCLOSED region is reasoning too. The model said it was thinking and then the turn
33
+ * ended - an abort, a truncation, a provider that stopped mid-thought. The reasoning goes
34
+ * into the block, and the answer keeps whatever came before the tag. Nothing is thrown away
35
+ * either way: the fold is expandable, so the words are still there to be read.
36
+ * - A CLOSING TAG WITH NOTHING OPEN is dropped. The model wrote it by mistake, and showing it
37
+ * would be showing a fragment of markup as if it were content.
38
+ * - TEXT IS NEVER LOST. A `<` that turns out not to begin a tag is held for one delta and then
39
+ * released as text, so a comparison like `a < b` survives intact.
40
+ */
41
+ /** The tag that opens a reasoning region. */
42
+ const OPEN_TAG = "<think>";
43
+ /** The tag that closes one. */
44
+ const CLOSE_TAG = "</think>";
45
+ /** Appends a piece of text, skipping the empty ones: an empty segment is not an event. */
46
+ function append(segments, kind, text) {
47
+ if (text)
48
+ segments.push({ kind, text });
49
+ }
50
+ /**
51
+ * How much of the tail of `text` could be the beginning of `tag`.
52
+ *
53
+ * The whole tag is not a candidate - a complete tag would have been found by `indexOf` - so this
54
+ * returns the length of the longest PROPER prefix of the tag that the tail ends with. It is what
55
+ * makes `<thi` wait for the next delta instead of being published as text and then having to be
56
+ * taken back.
57
+ */
58
+ function partialTagLength(text, tag) {
59
+ const max = Math.min(tag.length - 1, text.length);
60
+ for (let length = max; length > 0; length -= 1) {
61
+ if (text.endsWith(tag.slice(0, length)))
62
+ return length;
63
+ }
64
+ return 0;
65
+ }
66
+ /**
67
+ * Splits a model's text into the answer and the reasoning the model wrote inside it.
68
+ *
69
+ * ONE SPLITTER PER TURN. The state it keeps - the held-back tail and whether the stream is
70
+ * inside a reasoning region - belongs to a turn and to nothing else, so a splitter is created
71
+ * when a turn starts and dropped when it ends.
72
+ */
73
+ export class InlineThinkingSplitter {
74
+ /** The tail that might be the start of a tag, held until the next delta settles it. */
75
+ held = "";
76
+ /** True while the stream is inside a reasoning region. */
77
+ inside = false;
78
+ /**
79
+ * Feeds one delta in and gets back the segments it produced, in order.
80
+ *
81
+ * A delta that carries nothing but text produces one text segment. A delta that opens a
82
+ * reasoning region produces the text before it, then the reasoning, then the end of the
83
+ * reasoning when the closing tag arrives - and the text after it, if there is any.
84
+ */
85
+ push(delta) {
86
+ const segments = [];
87
+ if (!delta)
88
+ return segments;
89
+ const buffer = this.held + delta;
90
+ this.held = "";
91
+ let index = 0;
92
+ while (index < buffer.length) {
93
+ if (this.inside) {
94
+ const close = buffer.indexOf(CLOSE_TAG, index);
95
+ if (close === -1) {
96
+ // No closing tag yet. Everything that cannot be the start of one is reasoning, and the
97
+ // rest waits for the next delta.
98
+ const keep = partialTagLength(buffer.slice(index), CLOSE_TAG);
99
+ append(segments, "thinking", buffer.slice(index, buffer.length - keep));
100
+ this.held = buffer.slice(buffer.length - keep);
101
+ break;
102
+ }
103
+ append(segments, "thinking", buffer.slice(index, close));
104
+ index = close + CLOSE_TAG.length;
105
+ this.inside = false;
106
+ segments.push({ kind: "thinking-end" });
107
+ continue;
108
+ }
109
+ const open = buffer.indexOf(OPEN_TAG, index);
110
+ const close = buffer.indexOf(CLOSE_TAG, index);
111
+ if (open === -1 && close === -1) {
112
+ // BOTH TAGS CAN START HERE, SO BOTH TAILS ARE HELD. The tail is held so that a tag the
113
+ // tokenizer cut in half is still recognized as a tag; which tag it might be is exactly the
114
+ // question, and outside a reasoning region it can be EITHER one: a stray `</think>` is markup
115
+ // the model wrote by mistake and must be dropped, exactly like a `<think>` that opens a
116
+ // region. Holding only the OPEN prefix published the CLOSE prefix as text at once, so the
117
+ // same stray tag was dropped when it arrived whole and survived in the answer when the
118
+ // tokenizer cut it - `["答案</thi", "nk>还是答案"]` read as `答案</think>还是答案`
119
+ // (measured by scripts/inline-thinking-close-tag.test.mjs, ledger TEST-021). The longer of
120
+ // the two prefixes is held; a tail that turns out to be neither is released as text on the
121
+ // next delta, which is the "text is never lost" rule.
122
+ const rest = buffer.slice(index);
123
+ const keep = Math.max(partialTagLength(rest, OPEN_TAG), partialTagLength(rest, CLOSE_TAG));
124
+ append(segments, "text", rest.slice(0, rest.length - keep));
125
+ this.held = rest.slice(rest.length - keep);
126
+ break;
127
+ }
128
+ if (open !== -1 && (close === -1 || open < close)) {
129
+ append(segments, "text", buffer.slice(index, open));
130
+ index = open + OPEN_TAG.length;
131
+ this.inside = true;
132
+ continue;
133
+ }
134
+ // A CLOSING TAG WITH NOTHING OPEN. Dropped: it is markup the model wrote by mistake, and a
135
+ // reader would take it for content. The text BEFORE it is ordinary text and is kept - the tag
136
+ // is dropped, not everything up to it.
137
+ append(segments, "text", buffer.slice(index, close));
138
+ index = close + CLOSE_TAG.length;
139
+ }
140
+ return segments;
141
+ }
142
+ /**
143
+ * Closes the turn: whatever is still held is released, and a reasoning region that never
144
+ * closed is finished as one.
145
+ */
146
+ flush() {
147
+ const segments = [];
148
+ if (this.inside) {
149
+ if (this.held)
150
+ segments.push({ kind: "thinking", text: this.held });
151
+ this.held = "";
152
+ this.inside = false;
153
+ segments.push({ kind: "thinking-end" });
154
+ return segments;
155
+ }
156
+ if (this.held) {
157
+ segments.push({ kind: "text", text: this.held });
158
+ this.held = "";
159
+ }
160
+ return segments;
161
+ }
162
+ }
163
+ /**
164
+ * The answer inside a model's text, with the reasoning taken out of it.
165
+ *
166
+ * For the paths that want a STRING rather than a stream of segments: the title generator, the
167
+ * tool LLM, a sub-agent's result. A title with a `<think>` prefix in it is not a title, and a
168
+ * sub-agent's card whose summary starts with markup reads as broken.
169
+ */
170
+ export function stripInlineThinking(text) {
171
+ if (!text)
172
+ return "";
173
+ const splitter = new InlineThinkingSplitter();
174
+ const segments = [...splitter.push(text), ...splitter.flush()];
175
+ let answer = "";
176
+ for (const segment of segments) {
177
+ if (segment.kind === "text")
178
+ answer += segment.text;
179
+ }
180
+ return answer;
181
+ }
@@ -82,6 +82,8 @@ export type KnowledgeRuntime = {
82
82
  export declare function parseRanking(output: string): number[] | null;
83
83
  /** Duplicated as a literal in `agents/super-pi-knowledge/index.ts`, the way `TASK_TOOL_NAME` is. */
84
84
  export declare const KNOWLEDGE_TOOL_NAME = "knowledge_search";
85
+ /** The import half of the same extension. Duplicated as a literal there, for the same reason. */
86
+ export declare const KNOWLEDGE_IMPORT_TOOL_NAME = "knowledge_import";
85
87
  /**
86
88
  * Index terms for one string: CJK bigrams plus Latin words.
87
89
  *
@@ -152,17 +154,6 @@ export declare class KnowledgeBase {
152
154
  private load;
153
155
  private invalidate;
154
156
  list(workspaceId: string): Promise<Array<Omit<KnowledgeDocument, "text">>>;
155
- /**
156
- * Whether this workspace has any document at all, which is what decides whether the turn mounts the
157
- * extension.
158
- *
159
- * WHY A DIRECTORY LISTING AND NOT `list()`. This runs once per turn, before every spawn. `list()`
160
- * would read and parse every document's full text to answer a yes/no question, so a workspace with
161
- * a dozen real documents would pay for parsing all of them on every message - including the
162
- * catalogue fetch the extension makes the moment it loads. `readdir` answers the same question for
163
- * the price of one directory entry, and a name is all this needs.
164
- */
165
- hasDocuments(workspaceId: string): Promise<boolean>;
166
157
  /**
167
158
  * Adds a document, either from a workspace file or from pasted text.
168
159
  *
@@ -171,6 +162,10 @@ export declare class KnowledgeBase {
171
162
  * newer version that diverges from the one the workspace serves. The path therefore goes through
172
163
  * the same `resolveInsideRoot` guard the file editor uses, so symlink and `..` escapes are refused
173
164
  * by construction instead of by a check someone has to remember.
165
+ *
166
+ * WHO CALLS THIS. The settings window (manage, action "add") AND, since the import tool exists,
167
+ * the primary agent mid-turn on the user's say-so - through the same guard, the same whitelist and
168
+ * the same caps, so an agent import can never do more than a person's import could.
174
169
  */
175
170
  add(workspaceId: string, body: {
176
171
  path?: string;
@@ -213,27 +208,42 @@ export declare class KnowledgeBase {
213
208
  */
214
209
  private rerank;
215
210
  /**
216
- * Mints a read-only token for one turn. The agent can retrieve, never write: everything a person
217
- * puts in a knowledge base is theirs to curate from the settings window.
211
+ * Mints a token for one turn. The agent can retrieve and IMPORT (a document the user just asked it
212
+ * to add rides through the same `add` guard the settings window uses), but never remove: curation
213
+ * that deletes a person's documents stays a settings-window act.
218
214
  */
219
215
  issue(conversationId: string): string;
220
216
  release(token: string): void;
221
217
  private identity;
222
- /** The agent-facing endpoint: a title catalogue and a search. Nothing that writes. */
218
+ /**
219
+ * The agent-facing endpoint: a title catalogue, a search, and an import. The import is the SAME
220
+ * `add` the settings window calls - path guard, format whitelist, size caps, dedupe - so an agent
221
+ * cannot do anything a person could not, and the one thing it cannot do is `remove`.
222
+ */
223
223
  tool(token: string, body: {
224
224
  kind?: string;
225
225
  query?: string;
226
226
  limit?: number;
227
+ path?: string;
228
+ title?: string;
229
+ text?: string;
227
230
  }, signal?: AbortSignal): Promise<{
228
231
  documents: {
229
232
  title: string;
230
233
  source: string;
231
234
  characters: number;
232
235
  }[];
236
+ imported?: undefined;
237
+ hits?: undefined;
238
+ reranked?: undefined;
239
+ } | {
240
+ documents?: undefined;
241
+ imported: Omit<KnowledgeDocument, "text">;
233
242
  hits?: undefined;
234
243
  reranked?: undefined;
235
244
  } | {
236
245
  documents?: undefined;
246
+ imported?: undefined;
237
247
  hits: KnowledgeHit[];
238
248
  reranked: boolean;
239
249
  }>;
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
3
- import { basename, extname, join } from "node:path";
3
+ import { basename, extname, join, relative, sep } from "node:path";
4
4
  import { looksBinary, resolveInsideRoot } from "./files.js";
5
+ import { documentFormatOf, DocumentError, extractText } from "./documents.js";
5
6
  /** 1 MB. Big enough for a design doc or a spec, small enough that chunking stays instant. */
6
7
  export const MAX_DOCUMENT_BYTES = 1024 * 1024;
7
8
  /** A pasted document rides inside a JSON body, so the request cap leaves room for escaping overhead. */
@@ -53,6 +54,8 @@ const HEADING = /^#{1,6}\s+(.*\S)\s*$/;
53
54
  const PASTED_SOURCE = "pasted";
54
55
  /** Duplicated as a literal in `agents/super-pi-knowledge/index.ts`, the way `TASK_TOOL_NAME` is. */
55
56
  export const KNOWLEDGE_TOOL_NAME = "knowledge_search";
57
+ /** The import half of the same extension. Duplicated as a literal there, for the same reason. */
58
+ export const KNOWLEDGE_IMPORT_TOOL_NAME = "knowledge_import";
56
59
  /** Extensions a document may be imported from. Mirrors `files.TEXT_EXTENSIONS`, minus the source languages. */
57
60
  const IMPORTABLE = new Set([
58
61
  ".md", ".markdown", ".mdx", ".txt", ".text", ".log", ".rst", ".org",
@@ -369,30 +372,6 @@ export class KnowledgeBase {
369
372
  const documents = (await this.readAll(workspaceId)).map(document => this.summarise(document));
370
373
  return documents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
371
374
  }
372
- /**
373
- * Whether this workspace has any document at all, which is what decides whether the turn mounts the
374
- * extension.
375
- *
376
- * WHY A DIRECTORY LISTING AND NOT `list()`. This runs once per turn, before every spawn. `list()`
377
- * would read and parse every document's full text to answer a yes/no question, so a workspace with
378
- * a dozen real documents would pay for parsing all of them on every message - including the
379
- * catalogue fetch the extension makes the moment it loads. `readdir` answers the same question for
380
- * the price of one directory entry, and a name is all this needs.
381
- */
382
- async hasDocuments(workspaceId) {
383
- if (!this.enabled())
384
- return false;
385
- if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
386
- return false;
387
- try {
388
- return (await readdir(this.folder(workspaceId))).some(name => name.endsWith(".json"));
389
- }
390
- catch (error) {
391
- if (error.code === "ENOENT")
392
- return false;
393
- throw error;
394
- }
395
- }
396
375
  /**
397
376
  * Adds a document, either from a workspace file or from pasted text.
398
377
  *
@@ -401,6 +380,10 @@ export class KnowledgeBase {
401
380
  * newer version that diverges from the one the workspace serves. The path therefore goes through
402
381
  * the same `resolveInsideRoot` guard the file editor uses, so symlink and `..` escapes are refused
403
382
  * by construction instead of by a check someone has to remember.
383
+ *
384
+ * WHO CALLS THIS. The settings window (manage, action "add") AND, since the import tool exists,
385
+ * the primary agent mid-turn on the user's say-so - through the same guard, the same whitelist and
386
+ * the same caps, so an agent import can never do more than a person's import could.
404
387
  */
405
388
  async add(workspaceId, body) {
406
389
  this.requireWorkspace(workspaceId);
@@ -417,17 +400,50 @@ export class KnowledgeBase {
417
400
  if (!info.isFile())
418
401
  throw new Error("只能导入文件");
419
402
  format = extname(absolute).toLocaleLowerCase();
420
- if (!IMPORTABLE.has(format))
403
+ const documentFormat = documentFormatOf(format);
404
+ if (!IMPORTABLE.has(format) && !documentFormat) {
421
405
  throw new Error(`不支持的文件类型 ${format || "(无扩展名)"};请先转换为 Markdown 或纯文本`);
406
+ }
422
407
  if (info.size > MAX_DOCUMENT_BYTES)
423
408
  throw new Error(`文件过大:${info.size} 字节超过 ${MAX_DOCUMENT_BYTES} 的上限`);
424
- const buffer = await readFile(absolute);
425
- if (looksBinary(buffer.subarray(0, 4096)))
426
- throw new Error("看起来是二进制文件,无法索引");
427
- text = buffer.toString("utf8");
428
- source = body.path.trim();
409
+ // THE DOCUMENT'S IDENTITY IS THE FILE, NOT THE SPELLING OF ITS PATH. `resolveInsideRoot` has
410
+ // already resolved the caller's path to a canonical one, so the identity is derived from THAT -
411
+ // the workspace-relative path - instead of from whatever string the caller typed. Taking the raw
412
+ // string made `./notes.md` and `notes.md` two different documents: re-importing the same file
413
+ // under another spelling added a second document, and the REPLACED version stayed in the index
414
+ // where the agent could still cite it (measured by
415
+ // scripts/knowledge-import-identity.test.mjs, ledger TEST-027). The contract says a file whose
416
+ // content changed is an update, keeping its id, and only one document per file may exist.
417
+ source = relative(workspace.path, absolute).split(sep).join("/");
429
418
  if (!title)
430
419
  title = basename(absolute);
420
+ if (documentFormat) {
421
+ // A DOCUMENT IS PARSED, NOT DECODED. The bytes on disk are a container - a PDF's or a
422
+ // docx's - and what goes into the knowledge base is the TEXT they contain. The format
423
+ // recorded on the document stays the SOURCE format (`.pdf`), because that is what the next
424
+ // re-import of this path has to parse it as: the extracted text is not a PDF, and treating
425
+ // it as one would make an update fail to parse at all.
426
+ //
427
+ // A DOCUMENT THAT COULD NOT BE READ IS A REFUSAL, NOT AN EMPTY DOCUMENT. The parser's own
428
+ // reasons are already written for a person - a scan with no text layer, an encrypted file,
429
+ // a missing package - and they are passed through unchanged, because a person who is told
430
+ // "读取失败" learns nothing about what to do next.
431
+ try {
432
+ const extracted = await extractText(absolute, documentFormat);
433
+ text = extracted.text;
434
+ }
435
+ catch (error) {
436
+ if (error instanceof DocumentError)
437
+ throw new Error(error.message);
438
+ throw error;
439
+ }
440
+ }
441
+ else {
442
+ const buffer = await readFile(absolute);
443
+ if (looksBinary(buffer.subarray(0, 4096)))
444
+ throw new Error("看起来是二进制文件,无法索引");
445
+ text = buffer.toString("utf8");
446
+ }
431
447
  }
432
448
  const trimmed = text.trim();
433
449
  if (!trimmed)
@@ -677,8 +693,9 @@ export class KnowledgeBase {
677
693
  }
678
694
  }
679
695
  /**
680
- * Mints a read-only token for one turn. The agent can retrieve, never write: everything a person
681
- * puts in a knowledge base is theirs to curate from the settings window.
696
+ * Mints a token for one turn. The agent can retrieve and IMPORT (a document the user just asked it
697
+ * to add rides through the same `add` guard the settings window uses), but never remove: curation
698
+ * that deletes a person's documents stays a settings-window act.
682
699
  */
683
700
  issue(conversationId) {
684
701
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
@@ -697,13 +714,19 @@ export class KnowledgeBase {
697
714
  throw new Error("知识库授权已失效");
698
715
  return identity;
699
716
  }
700
- /** The agent-facing endpoint: a title catalogue and a search. Nothing that writes. */
717
+ /**
718
+ * The agent-facing endpoint: a title catalogue, a search, and an import. The import is the SAME
719
+ * `add` the settings window calls - path guard, format whitelist, size caps, dedupe - so an agent
720
+ * cannot do anything a person could not, and the one thing it cannot do is `remove`.
721
+ */
701
722
  async tool(token, body, signal) {
702
723
  const identity = this.identity(token);
703
724
  if (!this.enabled())
704
725
  throw new Error("知识库已关闭");
705
726
  if (body.kind === "catalogue")
706
727
  return { documents: (await this.list(identity.workspaceId)).map(document => ({ title: document.title, source: document.source, characters: document.characters })) };
728
+ if (body.kind === "import")
729
+ return { imported: await this.add(identity.workspaceId, { path: body.path, title: body.title, text: body.text }) };
707
730
  if (body.kind !== "search")
708
731
  throw new Error("未知知识库操作");
709
732
  // The reranker needs the conversation it is ranking FOR: that is whose model answers, whose
@@ -10,7 +10,19 @@ export type StateTransaction = {
10
10
  run: SubagentRun;
11
11
  } | {
12
12
  kind: "collections.replace";
13
- values: Partial<Pick<AppState, "workflows" | "backgroundTasks">>;
13
+ values: Partial<Pick<AppState, "workflows" | "backgroundTasks" | "scheduledTasks">>;
14
+ }
15
+ /**
16
+ * Removes one collection row entirely.
17
+ *
18
+ * It exists because "this collection is empty" is stored as the ABSENCE of the key, not as an empty
19
+ * array: a fresh install must not claim the user considered a schedule and found none. A repository
20
+ * whose `collections.replace` can only write rows therefore cannot express "there are none" - it can
21
+ * only write `[]`, which is a different claim.
22
+ */
23
+ | {
24
+ kind: "collections.remove";
25
+ name: "workflows" | "backgroundTasks" | "scheduledTasks";
14
26
  } | {
15
27
  kind: "conversation.metadata";
16
28
  conversationId: string;
@@ -118,6 +118,9 @@ export class SqliteStateRepository {
118
118
  for (const [name, value] of Object.entries(transaction.values))
119
119
  write.run(name, JSON.stringify(value));
120
120
  }
121
+ else if (transaction.kind === "collections.remove") {
122
+ this.db.prepare("DELETE FROM collections WHERE name=?").run(transaction.name);
123
+ }
121
124
  else if (transaction.kind === "conversation.messages") {
122
125
  // A DIFF, NOT A REWRITE. This used to DELETE the conversation's rows and re-INSERT every
123
126
  // message - stringified twice, once per table - on every single send, under
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
- import { createPiSession, sameFingerprint, superPiExtensionPaths, } from "./pi-session.js";
25
+ import { createPiSession, sameFingerprint, superPiExtensionPaths, superPiSkillPaths, } from "./pi-session.js";
26
26
  /** How long an idle session survives before it is evicted. */
27
27
  const IDLE_EVICTION_MS = 10 * 60_000;
28
28
  /**
@@ -170,6 +170,7 @@ export class PiSessionPool {
170
170
  sessionId: request.conversationId,
171
171
  sessionDir: this.sessionDir,
172
172
  extensionPaths: superPiExtensionPaths(this.repositoryRoot),
173
+ skillPaths: superPiSkillPaths(this.repositoryRoot),
173
174
  extensionEnv: request.extensionEnv,
174
175
  ...(request.systemPromptFile ? { systemPromptFile: request.systemPromptFile } : {}),
175
176
  ...(request.systemPromptMode ? { systemPromptMode: request.systemPromptMode } : {}),
@@ -343,6 +343,8 @@ export interface SessionCreation {
343
343
  sessionDir: string;
344
344
  /** Super Pi's extensions to mount, as absolute paths. */
345
345
  extensionPaths: string[];
346
+ /** Super Pi's built-in skills to expose beside the user's and workspace's skills. */
347
+ skillPaths?: string[];
346
348
  /** The environment those extensions read when they are loaded. */
347
349
  extensionEnv: Record<string, string>;
348
350
  /** The agent's system prompt file, or undefined for Pi's own defaults. */
@@ -420,3 +422,5 @@ export interface PromptTemplateSummary {
420
422
  }
421
423
  export declare function loadPromptTemplateSummaries(cwd: string): Promise<PromptTemplateSummary[]>;
422
424
  export declare function superPiExtensionPaths(repositoryRoot: string): string[];
425
+ /** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
426
+ export declare function superPiSkillPaths(repositoryRoot: string): string[];
@@ -269,6 +269,7 @@ export async function createPiSession(creation) {
269
269
  cwd: creation.cwd,
270
270
  agentDir,
271
271
  ...(creation.extensionPaths.length ? { additionalExtensionPaths: creation.extensionPaths } : {}),
272
+ ...(creation.skillPaths?.length ? { additionalSkillPaths: creation.skillPaths } : {}),
272
273
  ...(creation.systemPromptFile && creation.systemPromptMode === "replace"
273
274
  ? { systemPrompt: creation.systemPromptFile }
274
275
  : creation.systemPromptFile
@@ -413,6 +414,19 @@ export function superPiExtensionPaths(repositoryRoot) {
413
414
  join(repositoryRoot, "agents", "super-pi-task", "index.ts"),
414
415
  join(repositoryRoot, "agents", "super-pi-memory", "index.ts"),
415
416
  join(repositoryRoot, "agents", "super-pi-knowledge", "index.ts"),
417
+ join(repositoryRoot, "agents", "super-pi-web", "index.ts"),
418
+ // The document tools. They are mounted for every session, and the extension itself decides
419
+ // whether to register anything: with no endpoint in its environment it returns without
420
+ // registering a tool, so a runtime without a document endpoint mounts nothing harmful.
421
+ join(repositoryRoot, "agents", "super-pi-documents", "index.ts"),
422
+ // The Feishu tools, mounted for every session on the same terms: the extension registers
423
+ // nothing without an endpoint in its environment.
424
+ join(repositoryRoot, "agents", "super-pi-feishu", "index.ts"),
416
425
  ];
417
426
  return paths.filter((path) => existsSync(path));
418
427
  }
428
+ /** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
429
+ export function superPiSkillPaths(repositoryRoot) {
430
+ const paths = [join(repositoryRoot, "skills", "presentation")];
431
+ return paths.filter((path) => existsSync(join(path, "SKILL.md")));
432
+ }