@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.
- package/README.md +18 -0
- package/agents/super-pi-documents/index.ts +92 -0
- package/agents/super-pi-feishu/index.ts +88 -0
- package/agents/super-pi-knowledge/index.ts +32 -1
- package/agents/super-pi-task/core.ts +217 -0
- package/agents/super-pi-task/index.ts +74 -1
- package/agents/super-pi-web/index.ts +32 -0
- package/assets/fonts/LICENSE +92 -0
- package/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/dist/client/.super-pi-client-sources.json +13 -10
- package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
- package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
- package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
- package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
- package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
- package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
- package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
- package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
- package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
- package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
- package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
- package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
- package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
- package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
- package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
- package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
- package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
- package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
- package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
- package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
- package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
- package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
- package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
- package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
- package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/document-service.d.ts +68 -0
- package/dist/server/application/document-service.js +120 -0
- package/dist/server/application/feishu-service.d.ts +132 -0
- package/dist/server/application/feishu-service.js +264 -0
- package/dist/server/application/run-service.js +49 -7
- package/dist/server/application/scheduled-task-service.d.ts +164 -0
- package/dist/server/application/scheduled-task-service.js +350 -0
- package/dist/server/command-handler.d.ts +25 -1
- package/dist/server/command-handler.js +60 -1
- package/dist/server/documents.d.ts +152 -0
- package/dist/server/documents.js +998 -0
- package/dist/server/feishu-cli.d.ts +207 -0
- package/dist/server/feishu-cli.js +419 -0
- package/dist/server/file-changes.js +6 -4
- package/dist/server/files.d.ts +32 -0
- package/dist/server/files.js +31 -3
- package/dist/server/http-server.js +324 -2
- package/dist/server/index.js +41 -2
- package/dist/server/inline-thinking.d.ts +90 -0
- package/dist/server/inline-thinking.js +181 -0
- package/dist/server/knowledge.d.ts +24 -14
- package/dist/server/knowledge.js +57 -34
- package/dist/server/persistence/app-state-repository.d.ts +13 -1
- package/dist/server/persistence/sqlite-state-repository.js +3 -0
- package/dist/server/pi-session-pool.js +2 -1
- package/dist/server/pi-session.d.ts +4 -0
- package/dist/server/pi-session.js +14 -0
- package/dist/server/presentations/schema.d.ts +120 -0
- package/dist/server/presentations/schema.js +128 -0
- package/dist/server/runtime.d.ts +67 -0
- package/dist/server/runtime.js +204 -18
- package/dist/server/scheduled-tasks.d.ts +101 -0
- package/dist/server/scheduled-tasks.js +138 -0
- package/dist/server/server-context.d.ts +19 -0
- package/dist/server/store.d.ts +30 -0
- package/dist/server/store.js +81 -2
- package/dist/server/subagents.js +33 -6
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +18 -0
- package/dist/server/types.d.ts +56 -0
- package/dist/server/web.d.ts +24 -0
- package/dist/server/web.js +180 -0
- package/package.json +45 -6
- package/skills/presentation/SKILL.md +30 -0
- package/skills/presentation/references/data.md +10 -0
- package/skills/presentation/references/quality.md +12 -0
- package/skills/presentation/references/story.md +12 -0
- package/skills/presentation/references/templates-and-editing.md +11 -0
- package/skills/presentation/references/visual-system.md +10 -0
- package/dist/.super-pi-check-inputs.json +0 -4
- 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
|
|
217
|
-
*
|
|
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
|
-
/**
|
|
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
|
}>;
|
package/dist/server/knowledge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
681
|
-
*
|
|
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
|
-
/**
|
|
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
|
+
}
|