@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
package/dist/server/store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppStateRepository } from "./persistence/app-state-repository.js";
|
|
2
2
|
import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
|
|
3
|
+
import type { ScheduledTask } from "./scheduled-tasks.js";
|
|
3
4
|
import type { WorkflowRun } from "./workflow.js";
|
|
4
5
|
import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
|
|
5
6
|
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
@@ -45,6 +46,14 @@ export declare const SUBAGENT_RUN_RETENTION = 100;
|
|
|
45
46
|
* request that needed several specialists rather than a unit of work.
|
|
46
47
|
*/
|
|
47
48
|
export declare const WORKFLOW_RETENTION = 50;
|
|
49
|
+
/**
|
|
50
|
+
* How many scheduled tasks are kept.
|
|
51
|
+
*
|
|
52
|
+
* A task is a standing arrangement, not a record of something that happened, so the bound here is much
|
|
53
|
+
* looser than the run retention: a person who keeps a daily task for a year has one task, and the
|
|
54
|
+
* hundredth is already a sign that the list is being used as a log rather than as a list of intentions.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SCHEDULED_TASK_RETENTION = 100;
|
|
48
57
|
/**
|
|
49
58
|
* Everything a revert decided, before anything is changed.
|
|
50
59
|
*
|
|
@@ -688,6 +697,27 @@ export declare class Store {
|
|
|
688
697
|
setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
|
|
689
698
|
removeBackgroundTask(id: string): Promise<void>;
|
|
690
699
|
clearBackgroundTasks(): Promise<void>;
|
|
700
|
+
/**
|
|
701
|
+
* Writes a scheduled task - new or changed - and persists it.
|
|
702
|
+
*
|
|
703
|
+
* ONE METHOD FOR BOTH, because the two are the same write. A task that has not run and a task that
|
|
704
|
+
* has run are the same kind of thing with different field values, and splitting them into
|
|
705
|
+
* "create" and "update" would leave the caller to decide which of two paths a given change takes -
|
|
706
|
+
* which is exactly the kind of decision that ends up made twice, differently, in two places.
|
|
707
|
+
*
|
|
708
|
+
* The value is cloned on the way in and on the way out, like every other store method that hands a
|
|
709
|
+
* record back: the caller mutates its own copy freely, and the copy on disk is the copy that was
|
|
710
|
+
* written.
|
|
711
|
+
*/
|
|
712
|
+
setScheduledTask(task: ScheduledTask): Promise<ScheduledTask>;
|
|
713
|
+
/**
|
|
714
|
+
* Removes a scheduled task, and persists the removal.
|
|
715
|
+
*
|
|
716
|
+
* AN EMPTY LIST IS STORED AS ABSENCE, for the same reason `setTodos` stores an empty todo list as
|
|
717
|
+
* the absence of the key: a state file that says "scheduledTasks: []" claims the user considered a
|
|
718
|
+
* schedule and found none, and a fresh install has never considered anything.
|
|
719
|
+
*/
|
|
720
|
+
removeScheduledTask(id: string): Promise<void>;
|
|
691
721
|
/**
|
|
692
722
|
* Applies the fields a run's life can change, and nothing else.
|
|
693
723
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -245,6 +245,27 @@ function trimWorkflows(workflows) {
|
|
|
245
245
|
}
|
|
246
246
|
workflows.splice(0, workflows.length, ...workflows.filter((plan) => !doomed.has(plan.id)));
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* How many scheduled tasks are kept.
|
|
250
|
+
*
|
|
251
|
+
* A task is a standing arrangement, not a record of something that happened, so the bound here is much
|
|
252
|
+
* looser than the run retention: a person who keeps a daily task for a year has one task, and the
|
|
253
|
+
* hundredth is already a sign that the list is being used as a log rather than as a list of intentions.
|
|
254
|
+
*/
|
|
255
|
+
export const SCHEDULED_TASK_RETENTION = 100;
|
|
256
|
+
/** Evicts the OLDEST scheduled tasks once there are more than `SCHEDULED_TASK_RETENTION`. */
|
|
257
|
+
function trimScheduledTasks(tasks) {
|
|
258
|
+
const excess = tasks.length - SCHEDULED_TASK_RETENTION;
|
|
259
|
+
if (excess <= 0)
|
|
260
|
+
return;
|
|
261
|
+
const doomed = new Set();
|
|
262
|
+
for (const task of [...tasks].sort((a, b) => a.createdAt - b.createdAt)) {
|
|
263
|
+
if (doomed.size >= excess)
|
|
264
|
+
break;
|
|
265
|
+
doomed.add(task.id);
|
|
266
|
+
}
|
|
267
|
+
tasks.splice(0, tasks.length, ...tasks.filter((task) => !doomed.has(task.id)));
|
|
268
|
+
}
|
|
248
269
|
/**
|
|
249
270
|
* Why a plan the previous process left `running` is stopped rather than resumed, in the same spirit and
|
|
250
271
|
* for the same audience as `ORPHANED_RUN_ERROR`.
|
|
@@ -576,9 +597,15 @@ export class Store {
|
|
|
576
597
|
// it is repaired again on every single boot - and until it is, anything reading the file sees the
|
|
577
598
|
// un-normalised shape rather than the one the store is actually running on.
|
|
578
599
|
const defaulted = ["workspaces", "conversations", "subagentRuns"].filter((key) => !Array.isArray(state[key])).length > 0
|
|
579
|
-
|| !Array.isArray(state.backgroundTasks)
|
|
600
|
+
|| !Array.isArray(state.backgroundTasks)
|
|
601
|
+
|| (state.scheduledTasks !== undefined && !Array.isArray(state.scheduledTasks));
|
|
580
602
|
if (!Array.isArray(state.backgroundTasks))
|
|
581
603
|
state.backgroundTasks = [];
|
|
604
|
+
// A scheduled-task list that is not a list is not a list of tasks. The key is dropped rather than
|
|
605
|
+
// coerced, so a corrupted file reads as "this install has no scheduled tasks" - the same thing a
|
|
606
|
+
// fresh install reads - instead of crashing the panel on every poll.
|
|
607
|
+
if (state.scheduledTasks !== undefined && !Array.isArray(state.scheduledTasks))
|
|
608
|
+
delete state.scheduledTasks;
|
|
582
609
|
// Every sweep runs before the store is constructed, so a single persist below publishes them together.
|
|
583
610
|
// Reconciling them in separate writes would put a file on disk in which one kind of orphan was fixed
|
|
584
611
|
// and the other was not - a half-repaired state that still needs a second restart to finish.
|
|
@@ -1970,6 +1997,50 @@ export class Store {
|
|
|
1970
1997
|
this.state.backgroundTasks = [];
|
|
1971
1998
|
await this.persistCollections("backgroundTasks");
|
|
1972
1999
|
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Writes a scheduled task - new or changed - and persists it.
|
|
2002
|
+
*
|
|
2003
|
+
* ONE METHOD FOR BOTH, because the two are the same write. A task that has not run and a task that
|
|
2004
|
+
* has run are the same kind of thing with different field values, and splitting them into
|
|
2005
|
+
* "create" and "update" would leave the caller to decide which of two paths a given change takes -
|
|
2006
|
+
* which is exactly the kind of decision that ends up made twice, differently, in two places.
|
|
2007
|
+
*
|
|
2008
|
+
* The value is cloned on the way in and on the way out, like every other store method that hands a
|
|
2009
|
+
* record back: the caller mutates its own copy freely, and the copy on disk is the copy that was
|
|
2010
|
+
* written.
|
|
2011
|
+
*/
|
|
2012
|
+
async setScheduledTask(task) {
|
|
2013
|
+
if (!this.state.scheduledTasks)
|
|
2014
|
+
this.state.scheduledTasks = [];
|
|
2015
|
+
const stored = structuredClone(task);
|
|
2016
|
+
const index = this.state.scheduledTasks.findIndex((item) => item?.id === stored.id);
|
|
2017
|
+
if (index < 0)
|
|
2018
|
+
this.state.scheduledTasks.push(stored);
|
|
2019
|
+
else
|
|
2020
|
+
this.state.scheduledTasks[index] = stored;
|
|
2021
|
+
trimScheduledTasks(this.state.scheduledTasks);
|
|
2022
|
+
await this.persistCollections("scheduledTasks");
|
|
2023
|
+
return structuredClone(stored);
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Removes a scheduled task, and persists the removal.
|
|
2027
|
+
*
|
|
2028
|
+
* AN EMPTY LIST IS STORED AS ABSENCE, for the same reason `setTodos` stores an empty todo list as
|
|
2029
|
+
* the absence of the key: a state file that says "scheduledTasks: []" claims the user considered a
|
|
2030
|
+
* schedule and found none, and a fresh install has never considered anything.
|
|
2031
|
+
*/
|
|
2032
|
+
async removeScheduledTask(id) {
|
|
2033
|
+
if (!this.state.scheduledTasks)
|
|
2034
|
+
return;
|
|
2035
|
+
const remaining = this.state.scheduledTasks.filter((item) => item?.id !== id);
|
|
2036
|
+
if (remaining.length === this.state.scheduledTasks.length)
|
|
2037
|
+
return;
|
|
2038
|
+
if (remaining.length)
|
|
2039
|
+
this.state.scheduledTasks = remaining;
|
|
2040
|
+
else
|
|
2041
|
+
delete this.state.scheduledTasks;
|
|
2042
|
+
await this.persistCollections("scheduledTasks");
|
|
2043
|
+
}
|
|
1973
2044
|
/**
|
|
1974
2045
|
* Applies the fields a run's life can change, and nothing else.
|
|
1975
2046
|
*
|
|
@@ -2063,7 +2134,15 @@ export class Store {
|
|
|
2063
2134
|
await this.persist();
|
|
2064
2135
|
}
|
|
2065
2136
|
async persistCollections(key) {
|
|
2066
|
-
|
|
2137
|
+
const value = this.state[key];
|
|
2138
|
+
// A collection that is EMPTY is absent from the state, and an absent collection has no row to
|
|
2139
|
+
// write. The row is removed instead, because a repository that can only write rows would have to
|
|
2140
|
+
// store `[]` - which is a different claim from "the user never made one".
|
|
2141
|
+
if (value === undefined) {
|
|
2142
|
+
await this.persistTransaction({ kind: "collections.remove", name: key });
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
await this.persistTransaction({ kind: "collections.replace", values: { [key]: structuredClone(value) } });
|
|
2067
2146
|
}
|
|
2068
2147
|
async persistMetadata(conversationId) {
|
|
2069
2148
|
const { messages: _messages, ...metadata } = this.requireConversation(conversationId);
|
package/dist/server/subagents.js
CHANGED
|
@@ -6,6 +6,7 @@ import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
|
6
6
|
import { resolvePiCommand, shellQuote } from "./pi-command.js";
|
|
7
7
|
import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, } from "./runtime.js";
|
|
8
8
|
import { RpcChannel } from "./rpc-channel.js";
|
|
9
|
+
import { InlineThinkingSplitter, stripInlineThinking } from "./inline-thinking.js";
|
|
9
10
|
import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
|
|
10
11
|
/**
|
|
11
12
|
* The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
|
|
@@ -284,6 +285,15 @@ export class SubagentSupervisor {
|
|
|
284
285
|
let current = "";
|
|
285
286
|
/** The last assistant message's text, which is the run's output. */
|
|
286
287
|
let finalText = "";
|
|
288
|
+
/**
|
|
289
|
+
* THE CHILD'S OWN REASONING, taken out of its answer.
|
|
290
|
+
*
|
|
291
|
+
* A model that has no reasoning channel writes its thinking into the answer, marked up with
|
|
292
|
+
* `<think>…</think>`. This supervisor's contract is `finalText` - the child's OUTPUT - and the
|
|
293
|
+
* reasoning is not part of it, so the tags and what they wrap never reach the card. The splitter
|
|
294
|
+
* is per child, because what it holds back belongs to one child's stream and to nothing else.
|
|
295
|
+
*/
|
|
296
|
+
const inlineThinking = new InlineThinkingSplitter();
|
|
287
297
|
const usage = { ...NO_USAGE };
|
|
288
298
|
/** Set rather than thrown so a child's own failure is reported AFTER its output has been drained. */
|
|
289
299
|
let failed;
|
|
@@ -420,10 +430,18 @@ export class SubagentSupervisor {
|
|
|
420
430
|
// three messages would report all three concatenated as one answer.
|
|
421
431
|
if (event?.type === "text_delta") {
|
|
422
432
|
const delta = event.delta ?? "";
|
|
423
|
-
|
|
424
|
-
|
|
433
|
+
// THE REASONING COMES OUT FIRST. What the card shows is the child's answer, and a model
|
|
434
|
+
// that thinks inside its answer would otherwise hand it a string that opens with
|
|
435
|
+
// `<think>` - the markup of a reasoning trace where the child's result belongs.
|
|
436
|
+
const answer = inlineThinking.push(delta)
|
|
437
|
+
.filter((segment) => segment.kind === "text")
|
|
438
|
+
.map((segment) => segment.text)
|
|
439
|
+
.join("");
|
|
440
|
+
streamed += answer;
|
|
441
|
+
current += answer;
|
|
425
442
|
enter("turn");
|
|
426
|
-
|
|
443
|
+
if (answer)
|
|
444
|
+
onTextDelta?.(answer);
|
|
427
445
|
}
|
|
428
446
|
else if (event?.type === "start") {
|
|
429
447
|
current = "";
|
|
@@ -432,16 +450,18 @@ export class SubagentSupervisor {
|
|
|
432
450
|
else if (event?.type === "done") {
|
|
433
451
|
// `done` carries the AUTHORITATIVE message, so its text wins over what the deltas added up
|
|
434
452
|
// to; the accumulated text stays as the fallback for a child that streams without a `done`.
|
|
435
|
-
|
|
453
|
+
// The authoritative text is stripped the same way the deltas were: a child whose answer
|
|
454
|
+
// ends in a reasoning region must not turn that region into its result.
|
|
455
|
+
current = stripInlineThinking(assistantText(event.message)) || current;
|
|
436
456
|
finalText = current;
|
|
437
457
|
addUsage(usage, record.usage);
|
|
438
458
|
enter("turn");
|
|
439
459
|
const reason = event.message?.stopReason ?? event.reason;
|
|
440
460
|
if (reason === "error" || reason === "aborted")
|
|
441
|
-
failed ??= `sub-agent ${agentId} (${id}) stopped with reason ${reason}: ${assistantText(event.message) || "no output"}`;
|
|
461
|
+
failed ??= `sub-agent ${agentId} (${id}) stopped with reason ${reason}: ${stripInlineThinking(assistantText(event.message)) || "no output"}`;
|
|
442
462
|
}
|
|
443
463
|
else if (event?.type === "error") {
|
|
444
|
-
failed ??= `sub-agent ${agentId} (${id}) reported ${event.reason ?? "an error"}: ${assistantText(event.message) || "no detail from Pi"}`;
|
|
464
|
+
failed ??= `sub-agent ${agentId} (${id}) reported ${event.reason ?? "an error"}: ${stripInlineThinking(assistantText(event.message)) || "no detail from Pi"}`;
|
|
445
465
|
}
|
|
446
466
|
else if (event?.type === "thinking_delta") {
|
|
447
467
|
// CONSUMED, not discarded. A reasoning model spends the first seconds of every turn here,
|
|
@@ -524,6 +544,13 @@ export class SubagentSupervisor {
|
|
|
524
544
|
throw new Error(failed);
|
|
525
545
|
// No `done` at all is a child that streamed and vanished; the deltas are then the best answer there
|
|
526
546
|
// is, and returning them beats returning "" for work that demonstrably happened.
|
|
547
|
+
//
|
|
548
|
+
// THE SPLITTER IS RELEASED FIRST. It may still be holding back the beginning of a tag, and that
|
|
549
|
+
// text belongs to the child: a run that ended mid-word must not lose the word.
|
|
550
|
+
for (const segment of inlineThinking.flush()) {
|
|
551
|
+
if (segment.kind === "text")
|
|
552
|
+
streamed += segment.text;
|
|
553
|
+
}
|
|
527
554
|
return { id, sessionId: id, finalText: finalText || streamed, usage };
|
|
528
555
|
}
|
|
529
556
|
finally {
|
|
@@ -173,6 +173,24 @@ export declare const TODO_TOOL_NAME = "todowrite";
|
|
|
173
173
|
* tool then refuses in words instead of posting somewhere that does not exist.
|
|
174
174
|
*/
|
|
175
175
|
export declare const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
|
|
176
|
+
/**
|
|
177
|
+
* The name the model calls to manage the SCHEDULED TASKS.
|
|
178
|
+
*
|
|
179
|
+
* `scheduled_task`, with an underscore, because this is not one of OpenCode's tools and there is no
|
|
180
|
+
* prior name to match. The name says what it is: a task that runs on a schedule.
|
|
181
|
+
*/
|
|
182
|
+
export declare const SCHEDULED_TASK_TOOL_NAME = "scheduled_task";
|
|
183
|
+
/**
|
|
184
|
+
* The endpoint the `scheduled_task` tool dials. Another name in the same wire, written out on both
|
|
185
|
+
* sides for the same reason `TODO_ENDPOINT_ENV` states: `agents/super-pi-task/core.ts` is loaded by
|
|
186
|
+
* Pi's module loader and this file by tsc, so the two copies have to agree by hand -
|
|
187
|
+
* `scripts/scheduled-task-tool.test.mjs` asserts that they do.
|
|
188
|
+
*
|
|
189
|
+
* A SEPARATE variable rather than a path derived from `TODO_ENDPOINT_ENV`, for the reason that one
|
|
190
|
+
* states: a Pi can legitimately have one and not the other, and a derived path could not tell those
|
|
191
|
+
* apart from a misconfiguration.
|
|
192
|
+
*/
|
|
193
|
+
export declare const SCHEDULED_TASK_ENDPOINT_ENV = "SUPER_PI_SCHEDULED_TASK_ENDPOINT";
|
|
176
194
|
/**
|
|
177
195
|
* How many todos may be stored, and how long one may be.
|
|
178
196
|
*
|
package/dist/server/task-tool.js
CHANGED
|
@@ -230,6 +230,24 @@ export const TODO_TOOL_NAME = "todowrite";
|
|
|
230
230
|
* tool then refuses in words instead of posting somewhere that does not exist.
|
|
231
231
|
*/
|
|
232
232
|
export const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
|
|
233
|
+
/**
|
|
234
|
+
* The name the model calls to manage the SCHEDULED TASKS.
|
|
235
|
+
*
|
|
236
|
+
* `scheduled_task`, with an underscore, because this is not one of OpenCode's tools and there is no
|
|
237
|
+
* prior name to match. The name says what it is: a task that runs on a schedule.
|
|
238
|
+
*/
|
|
239
|
+
export const SCHEDULED_TASK_TOOL_NAME = "scheduled_task";
|
|
240
|
+
/**
|
|
241
|
+
* The endpoint the `scheduled_task` tool dials. Another name in the same wire, written out on both
|
|
242
|
+
* sides for the same reason `TODO_ENDPOINT_ENV` states: `agents/super-pi-task/core.ts` is loaded by
|
|
243
|
+
* Pi's module loader and this file by tsc, so the two copies have to agree by hand -
|
|
244
|
+
* `scripts/scheduled-task-tool.test.mjs` asserts that they do.
|
|
245
|
+
*
|
|
246
|
+
* A SEPARATE variable rather than a path derived from `TODO_ENDPOINT_ENV`, for the reason that one
|
|
247
|
+
* states: a Pi can legitimately have one and not the other, and a derived path could not tell those
|
|
248
|
+
* apart from a misconfiguration.
|
|
249
|
+
*/
|
|
250
|
+
export const SCHEDULED_TASK_ENDPOINT_ENV = "SUPER_PI_SCHEDULED_TASK_ENDPOINT";
|
|
233
251
|
/**
|
|
234
252
|
* How many todos may be stored, and how long one may be.
|
|
235
253
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type ModelFilterRule = {
|
|
|
5
5
|
label?: string;
|
|
6
6
|
};
|
|
7
7
|
import type { WorkflowRun } from "./workflow.js";
|
|
8
|
+
import type { ScheduledTask } from "./scheduled-tasks.js";
|
|
8
9
|
/**
|
|
9
10
|
* The lifecycle states a `Conversation.status` moves through while a turn is in flight. `waiting` is the
|
|
10
11
|
* gap between "prompt handed to the LLM" and "the first response token arrived" - holding it apart from
|
|
@@ -835,6 +836,7 @@ export type AppState = {
|
|
|
835
836
|
backgroundTasks: BackgroundTaskSummary[];
|
|
836
837
|
todosByConversation?: Record<string, TodoItem[]>;
|
|
837
838
|
workflows?: WorkflowRun[];
|
|
839
|
+
scheduledTasks?: ScheduledTask[];
|
|
838
840
|
customAgentDirs?: string[];
|
|
839
841
|
};
|
|
840
842
|
export type TodoItem = {
|
|
@@ -1372,6 +1374,60 @@ export type ClientCommandBody =
|
|
|
1372
1374
|
/** Restarts the Super Pi server by spawning the same restart script used by the auto-updater. */
|
|
1373
1375
|
| {
|
|
1374
1376
|
type: "super_pi.restart";
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* A scheduled task is created.
|
|
1380
|
+
*
|
|
1381
|
+
* The schedule arrives as an opaque value and is validated on the SERVER, in the one place that owns
|
|
1382
|
+
* what a schedule means. The browser cannot send a half-valid schedule, because it does not know
|
|
1383
|
+
* what a half-valid schedule is; it sends the fields the panel collected and the server decides
|
|
1384
|
+
* whether they describe a schedule.
|
|
1385
|
+
*/
|
|
1386
|
+
| {
|
|
1387
|
+
type: "scheduledTask.create";
|
|
1388
|
+
name?: string;
|
|
1389
|
+
prompt: string;
|
|
1390
|
+
schedule: unknown;
|
|
1391
|
+
workspaceId: string;
|
|
1392
|
+
model?: ModelSelection | null;
|
|
1393
|
+
agentId?: string | null;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* A scheduled task is changed. Every field is optional, and the ones that are absent are the ones
|
|
1397
|
+
* that do not change - a panel that sends the whole task back would fight the scheduler's own
|
|
1398
|
+
* bookkeeping over `nextRunAt`, which is not the panel's to write.
|
|
1399
|
+
*/
|
|
1400
|
+
| {
|
|
1401
|
+
type: "scheduledTask.update";
|
|
1402
|
+
id: string;
|
|
1403
|
+
name?: string;
|
|
1404
|
+
prompt?: string;
|
|
1405
|
+
schedule?: unknown;
|
|
1406
|
+
model?: ModelSelection | null;
|
|
1407
|
+
agentId?: string | null;
|
|
1408
|
+
}
|
|
1409
|
+
/** A scheduled task is removed. Its conversation and the answers it produced stay. */
|
|
1410
|
+
| {
|
|
1411
|
+
type: "scheduledTask.delete";
|
|
1412
|
+
id: string;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* A scheduled task runs NOW, once, without touching its schedule.
|
|
1416
|
+
*
|
|
1417
|
+
* It is a separate command rather than a flag on `update` because it is a different act: `update`
|
|
1418
|
+
* changes what the task IS, and this changes what the task DID. The one thing it shares with the
|
|
1419
|
+
* clock is the run itself - it goes through the same path, so a manual run and a scheduled run
|
|
1420
|
+
* behave identically.
|
|
1421
|
+
*/
|
|
1422
|
+
| {
|
|
1423
|
+
type: "scheduledTask.trigger";
|
|
1424
|
+
id: string;
|
|
1425
|
+
}
|
|
1426
|
+
/** A scheduled task is turned on or off. */
|
|
1427
|
+
| {
|
|
1428
|
+
type: "scheduledTask.toggle";
|
|
1429
|
+
id: string;
|
|
1430
|
+
enabled: boolean;
|
|
1375
1431
|
} | ({
|
|
1376
1432
|
type: "extension.ui.answer";
|
|
1377
1433
|
conversationId: string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
2
|
+
export declare const WEB_FETCH_TOOL_NAME = "web_fetch";
|
|
3
|
+
export declare const MAX_WEB_REQUEST_BYTES: number;
|
|
4
|
+
export type WebSearchResult = {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
snippet: string;
|
|
8
|
+
};
|
|
9
|
+
export type WebFetchResult = {
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
text: string;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
contentType: string;
|
|
15
|
+
};
|
|
16
|
+
/** Parse Bing's ordinary result page. Kept separate so captured fixtures can guard markup changes. */
|
|
17
|
+
export declare function parseBingResults(html: string, limit?: number): WebSearchResult[];
|
|
18
|
+
export declare class WebService {
|
|
19
|
+
search(query: string, limit?: number, signal?: AbortSignal): Promise<{
|
|
20
|
+
query: string;
|
|
21
|
+
results: WebSearchResult[];
|
|
22
|
+
}>;
|
|
23
|
+
fetch(url: string, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
6
|
+
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
7
|
+
export const WEB_FETCH_TOOL_NAME = "web_fetch";
|
|
8
|
+
export const MAX_WEB_REQUEST_BYTES = 64 * 1024;
|
|
9
|
+
const SEARCH_LIMIT = 10;
|
|
10
|
+
const RESPONSE_LIMIT = 2 * 1024 * 1024;
|
|
11
|
+
const TEXT_LIMIT = 30_000;
|
|
12
|
+
const REDIRECT_LIMIT = 5;
|
|
13
|
+
const TIMEOUT_MS = 15_000;
|
|
14
|
+
const USER_AGENT = "Mozilla/5.0 (compatible; SuperPi/1.0; +https://www.npmjs.com/package/@evandu/super-pi)";
|
|
15
|
+
function decodeEntities(value) {
|
|
16
|
+
const named = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " " };
|
|
17
|
+
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (_all, entity) => {
|
|
18
|
+
if (entity[0] === "#") {
|
|
19
|
+
const code = entity[1]?.toLowerCase() === "x" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
20
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
|
|
21
|
+
}
|
|
22
|
+
return named[entity.toLowerCase()] ?? `&${entity};`;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function plainText(html) {
|
|
26
|
+
return decodeEntities(html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "))
|
|
27
|
+
.replace(/\s+/g, " ").trim();
|
|
28
|
+
}
|
|
29
|
+
/** Parse Bing's ordinary result page. Kept separate so captured fixtures can guard markup changes. */
|
|
30
|
+
export function parseBingResults(html, limit = 5) {
|
|
31
|
+
const results = [];
|
|
32
|
+
const blocks = html.match(/<li\b[^>]*class=["'][^"']*\bb_algo\b[^"']*["'][^>]*>[\s\S]*?<\/li>/gi) ?? [];
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
const anchor = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i.exec(block);
|
|
35
|
+
if (!anchor)
|
|
36
|
+
continue;
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(decodeEntities(anchor[1]));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!/^https?:$/.test(url.protocol))
|
|
45
|
+
continue;
|
|
46
|
+
const caption = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block)?.[1] ?? "";
|
|
47
|
+
results.push({ title: plainText(anchor[2]), url: url.href, snippet: plainText(caption) });
|
|
48
|
+
if (results.length >= Math.min(SEARCH_LIMIT, Math.max(1, limit)))
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
function blockedAddress(address) {
|
|
54
|
+
if (address === "::" || address === "::1")
|
|
55
|
+
return true;
|
|
56
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
57
|
+
if (isIP(v4) === 4) {
|
|
58
|
+
const [a, b] = v4.split(".").map(Number);
|
|
59
|
+
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
60
|
+
}
|
|
61
|
+
const normalized = address.toLowerCase();
|
|
62
|
+
return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("ff");
|
|
63
|
+
}
|
|
64
|
+
async function publicAddress(hostname) {
|
|
65
|
+
if (hostname.toLowerCase() === "localhost" || hostname.endsWith(".localhost"))
|
|
66
|
+
throw new Error("不允许访问本机或私网地址");
|
|
67
|
+
const records = isIP(hostname) ? [{ address: hostname, family: isIP(hostname) }] : await lookup(hostname, { all: true, verbatim: true });
|
|
68
|
+
const record = records.find(item => !blockedAddress(item.address));
|
|
69
|
+
if (!record || records.some(item => blockedAddress(item.address)))
|
|
70
|
+
throw new Error("不允许访问本机或私网地址");
|
|
71
|
+
return record;
|
|
72
|
+
}
|
|
73
|
+
async function download(input, signal, redirects = 0) {
|
|
74
|
+
const url = new URL(input);
|
|
75
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password)
|
|
76
|
+
throw new Error("只允许无凭据的 HTTP/HTTPS URL");
|
|
77
|
+
if (redirects > REDIRECT_LIMIT)
|
|
78
|
+
throw new Error("网页重定向次数过多");
|
|
79
|
+
const resolved = await publicAddress(url.hostname);
|
|
80
|
+
const requester = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
81
|
+
// Node may ask a custom lookup for one address or an array (`all: true`). Honor both overloads;
|
|
82
|
+
// returning the wrong shape makes net.connect report the misleading "Invalid IP address: undefined".
|
|
83
|
+
const pinnedLookup = ((_hostname, options, callback) => {
|
|
84
|
+
if (options?.all)
|
|
85
|
+
callback(null, [resolved]);
|
|
86
|
+
else
|
|
87
|
+
callback(null, resolved.address, resolved.family);
|
|
88
|
+
});
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const req = requester(url, {
|
|
91
|
+
headers: { "user-agent": USER_AGENT, accept: "text/html,text/plain,application/xhtml+xml", "accept-encoding": "gzip, deflate, br" },
|
|
92
|
+
lookup: pinnedLookup,
|
|
93
|
+
servername: url.hostname,
|
|
94
|
+
timeout: TIMEOUT_MS,
|
|
95
|
+
signal,
|
|
96
|
+
}, response => {
|
|
97
|
+
const status = response.statusCode ?? 0;
|
|
98
|
+
const location = response.headers.location;
|
|
99
|
+
if (status >= 300 && status < 400 && location) {
|
|
100
|
+
response.resume();
|
|
101
|
+
download(new URL(location, url).href, signal, redirects + 1).then(resolve, reject);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (status < 200 || status >= 300) {
|
|
105
|
+
response.resume();
|
|
106
|
+
reject(new Error(`网页请求失败(HTTP ${status})`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
response.on("data", chunk => {
|
|
112
|
+
bytes += chunk.length;
|
|
113
|
+
if (bytes > RESPONSE_LIMIT) {
|
|
114
|
+
req.destroy(new Error("网页响应超过 2 MB 限制"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
chunks.push(chunk);
|
|
118
|
+
});
|
|
119
|
+
response.on("end", () => {
|
|
120
|
+
try {
|
|
121
|
+
let body = Buffer.concat(chunks);
|
|
122
|
+
const encoding = String(response.headers["content-encoding"] ?? "").toLowerCase();
|
|
123
|
+
if (encoding === "gzip")
|
|
124
|
+
body = gunzipSync(body);
|
|
125
|
+
else if (encoding === "deflate")
|
|
126
|
+
body = inflateSync(body);
|
|
127
|
+
else if (encoding === "br")
|
|
128
|
+
body = brotliDecompressSync(body);
|
|
129
|
+
resolve({ url: url.href, body, contentType: String(response.headers["content-type"] ?? "application/octet-stream") });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
reject(error);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
req.on("timeout", () => req.destroy(new Error("网页请求超时")));
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function extractHtml(html) {
|
|
142
|
+
const title = plainText(/<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "");
|
|
143
|
+
let body = /<article\b[^>]*>([\s\S]*?)<\/article>/i.exec(html)?.[1]
|
|
144
|
+
?? /<main\b[^>]*>([\s\S]*?)<\/main>/i.exec(html)?.[1]
|
|
145
|
+
?? /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html)?.[1]
|
|
146
|
+
?? html;
|
|
147
|
+
body = body.replace(/<(nav|aside|footer|form|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
148
|
+
.replace(/<(br|p|div|li|h[1-6]|tr)\b[^>]*>/gi, "\n")
|
|
149
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
150
|
+
.replace(/<[^>]+>/g, " ");
|
|
151
|
+
const text = decodeEntities(body).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
152
|
+
return { title, text };
|
|
153
|
+
}
|
|
154
|
+
export class WebService {
|
|
155
|
+
async search(query, limit = 5, signal) {
|
|
156
|
+
const clean = query.trim();
|
|
157
|
+
if (!clean)
|
|
158
|
+
throw new Error("搜索词不能为空");
|
|
159
|
+
if (clean.length > 500)
|
|
160
|
+
throw new Error("搜索词不能超过 500 个字符");
|
|
161
|
+
const url = new URL("https://www.bing.com/search");
|
|
162
|
+
url.searchParams.set("q", clean);
|
|
163
|
+
url.searchParams.set("count", String(Math.min(SEARCH_LIMIT, Math.max(1, Math.floor(limit)))));
|
|
164
|
+
url.searchParams.set("setlang", "zh-Hans");
|
|
165
|
+
const response = await download(url.href, signal);
|
|
166
|
+
const results = parseBingResults(response.body.toString("utf8"), limit);
|
|
167
|
+
if (!results.length)
|
|
168
|
+
throw new Error("搜索引擎没有返回可解析的结果,可能暂时限流或页面格式已变化");
|
|
169
|
+
return { query: clean, results };
|
|
170
|
+
}
|
|
171
|
+
async fetch(url, signal) {
|
|
172
|
+
const response = await download(url, signal);
|
|
173
|
+
if (!/^(text\/html|application\/xhtml\+xml|text\/plain)\b/i.test(response.contentType))
|
|
174
|
+
throw new Error(`不支持的网页类型:${response.contentType}`);
|
|
175
|
+
const decoded = response.body.toString("utf8");
|
|
176
|
+
const extracted = /^text\/plain\b/i.test(response.contentType) ? { title: "", text: decoded.trim() } : extractHtml(decoded);
|
|
177
|
+
const truncated = extracted.text.length > TEXT_LIMIT;
|
|
178
|
+
return { title: extracted.title, url: response.url, text: extracted.text.slice(0, TEXT_LIMIT), truncated, contentType: response.contentType };
|
|
179
|
+
}
|
|
180
|
+
}
|