pi-web-ui 0.96.1 → 0.98.0
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/CHANGELOG.md +144 -2
- package/README.md +29 -3
- package/README.zh-CN.md +28 -3
- package/bin/pi-web-ui.mjs +75 -25
- package/dist/server/agent-service.js +1829 -262
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +296 -11
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +148 -0
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +143 -0
- package/dist/server/delegate-task.js +10 -10
- package/dist/server/dsh/dsh-agent-service.js +66 -31
- package/dist/server/dsh/dsh-client.js +48 -10
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +11 -5
- package/dist/server/goal-review-gate.js +250 -0
- package/dist/server/goal-service.js +1272 -448
- package/dist/server/index.js +118 -16
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +123 -24
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -28
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-bridge.js +13 -0
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +0 -3
- package/dist/server/patch-tool.js +22 -6
- package/dist/server/patch-turn-end-boundary.js +77 -0
- package/dist/server/permission-preset.js +69 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +414 -0
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +39 -8
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +22 -5
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +33 -9
- package/dist/server/settings-service.js +18 -6
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +379 -416
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/text-sniff.js +15 -9
- package/dist/server/tool-manager.js +60 -7
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +80 -28
- package/dist/server/wait-subscription-scan.js +5 -1
- package/extensions/webui.ts +15 -1
- package/package.json +33 -21
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/themes/zhupi-dark.css +5 -30
- package/themes/zhupi.css +5 -30
- package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +1 -0
- package/web/dist/assets/index-BjdK7s1M.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-B7Zp23Vc.css +0 -1
- package/web/dist/assets/index-ByfoaDwp.js +0 -366
package/dist/server/index.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* all share one conversation list per project.
|
|
17
17
|
* PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
|
|
18
18
|
*/
|
|
19
|
+
// MUST be imported before the SDK is loaded so on-disk patches apply cleanly.
|
|
20
|
+
import "./patch-remote-catalog.js";
|
|
21
|
+
import "./patch-turn-end-boundary.js";
|
|
19
22
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
23
|
import { stat } from "node:fs/promises";
|
|
21
24
|
import { createServer, request as proxyRequest } from "node:http";
|
|
@@ -53,7 +56,7 @@ import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
|
53
56
|
import { McpBridge } from "./mcp-bridge.js";
|
|
54
57
|
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
55
58
|
import { createHostMetricsSampler } from "./host-metrics.js";
|
|
56
|
-
import { SchedulerStore } from "./scheduler-tasks.js";
|
|
59
|
+
import { SchedulerStore, SchedulerValidationError } from "./scheduler-tasks.js";
|
|
57
60
|
import { initHttpProxy } from "./http-proxy.js";
|
|
58
61
|
import { globalLspPool } from "./lsp-tool.js";
|
|
59
62
|
import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
|
|
@@ -86,6 +89,13 @@ function cliFlag(name) {
|
|
|
86
89
|
return undefined;
|
|
87
90
|
}
|
|
88
91
|
const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
|
|
92
|
+
// #506:直启路径与 bin 层对齐做端口校验——`--port abc` / `PI_WEB_PORT=abc` 之前会
|
|
93
|
+
// listen(NaN) 被 Node 当 0 绑到随机端口,且日志打印 http://localhost:NaN。
|
|
94
|
+
if (!Number.isInteger(PORT) || PORT <= 0 || PORT > 65535) {
|
|
95
|
+
console.error(`无效端口: ${cliFlag("--port") ?? process.env.PI_WEB_PORT} (--port / PI_WEB_PORT)`);
|
|
96
|
+
console.error(`Invalid port: ${cliFlag("--port") ?? process.env.PI_WEB_PORT} (--port / PI_WEB_PORT)`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
89
99
|
const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
90
100
|
const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
91
101
|
// The data dir is where the control socket, client state, plugins, themes and
|
|
@@ -973,6 +983,8 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
973
983
|
// closing sockets, sleep/wake, network partitions). Also broadcasts lightweight
|
|
974
984
|
// server host metrics (CPU and memory usage) sampled every ~2s.
|
|
975
985
|
const HEARTBEAT_INTERVAL_MS = 2_000;
|
|
986
|
+
/** 全局广播与心跳推送背压阈值(256KB):socket 积压时丢弃广播消息,防用户态无界堆积(issue #460)。 */
|
|
987
|
+
const BROADCAST_BACKPRESSURE_MAX_BYTES = 262_144;
|
|
976
988
|
const sampleHostMetrics = createHostMetricsSampler();
|
|
977
989
|
const heartbeatTimer = setInterval(() => {
|
|
978
990
|
let message = { type: "heartbeat" };
|
|
@@ -985,8 +997,40 @@ const heartbeatTimer = setInterval(() => {
|
|
|
985
997
|
if (wss.clients.size > 0) {
|
|
986
998
|
const payload = JSON.stringify(message);
|
|
987
999
|
for (const ws of wss.clients) {
|
|
988
|
-
if (ws.readyState
|
|
989
|
-
|
|
1000
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1001
|
+
continue;
|
|
1002
|
+
const tracked = ws;
|
|
1003
|
+
// 半开连接死连接回收(issue #460):连续无活动/无 pong 则 terminate
|
|
1004
|
+
if (tracked.isAlive === false) {
|
|
1005
|
+
tracked.missedPings = (tracked.missedPings || 0) + 1;
|
|
1006
|
+
if (tracked.missedPings >= 3) {
|
|
1007
|
+
try {
|
|
1008
|
+
ws.terminate?.();
|
|
1009
|
+
}
|
|
1010
|
+
catch { }
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
tracked.missedPings = 0;
|
|
1016
|
+
}
|
|
1017
|
+
tracked.isAlive = false;
|
|
1018
|
+
try {
|
|
1019
|
+
ws.ping();
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
try {
|
|
1023
|
+
ws.terminate?.();
|
|
1024
|
+
}
|
|
1025
|
+
catch { }
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
// 广播背压:缓冲积压严重时跳过心跳推送
|
|
1029
|
+
if (ws.bufferedAmount <= BROADCAST_BACKPRESSURE_MAX_BYTES) {
|
|
1030
|
+
try {
|
|
1031
|
+
ws.send(payload);
|
|
1032
|
+
}
|
|
1033
|
+
catch { }
|
|
990
1034
|
}
|
|
991
1035
|
}
|
|
992
1036
|
}
|
|
@@ -1269,6 +1313,8 @@ function pushSchedulerTasks() {
|
|
|
1269
1313
|
for (const client of wss.clients) {
|
|
1270
1314
|
if (client.readyState !== WebSocket.OPEN)
|
|
1271
1315
|
continue;
|
|
1316
|
+
if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
|
|
1317
|
+
continue;
|
|
1272
1318
|
try {
|
|
1273
1319
|
client.send(payload);
|
|
1274
1320
|
}
|
|
@@ -1287,6 +1333,8 @@ function pushNoticeToAll(level, text, textEn) {
|
|
|
1287
1333
|
for (const client of wss.clients) {
|
|
1288
1334
|
if (client.readyState !== WebSocket.OPEN)
|
|
1289
1335
|
continue;
|
|
1336
|
+
if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
|
|
1337
|
+
continue;
|
|
1290
1338
|
try {
|
|
1291
1339
|
client.send(payload);
|
|
1292
1340
|
}
|
|
@@ -1532,6 +1580,13 @@ function serializeShared(msg) {
|
|
|
1532
1580
|
return s;
|
|
1533
1581
|
}
|
|
1534
1582
|
wss.on("connection", (ws) => {
|
|
1583
|
+
const tracked = ws;
|
|
1584
|
+
tracked.isAlive = true;
|
|
1585
|
+
tracked.missedPings = 0;
|
|
1586
|
+
ws.on("pong", () => {
|
|
1587
|
+
tracked.isAlive = true;
|
|
1588
|
+
tracked.missedPings = 0;
|
|
1589
|
+
});
|
|
1535
1590
|
// Count attached sockets (the control socket reports REAL sockets, not
|
|
1536
1591
|
// cached client-session objects).
|
|
1537
1592
|
service.noteSocketOpen();
|
|
@@ -1633,11 +1688,24 @@ wss.on("connection", (ws) => {
|
|
|
1633
1688
|
return;
|
|
1634
1689
|
}
|
|
1635
1690
|
switch (msg.type) {
|
|
1636
|
-
case "prompt":
|
|
1691
|
+
case "prompt": {
|
|
1692
|
+
const hasAttach = Boolean(msg.attachments && msg.attachments.length > 0);
|
|
1693
|
+
if (!msg.text?.trim() && !hasAttach) {
|
|
1694
|
+
send({
|
|
1695
|
+
type: "notice",
|
|
1696
|
+
level: "warning",
|
|
1697
|
+
text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
|
|
1698
|
+
textEn: "Prompt ignored: text is empty and no attachments were provided.",
|
|
1699
|
+
});
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1637
1702
|
void cs.prompt(msg.text, msg.attachments, msg.queue);
|
|
1638
1703
|
break;
|
|
1704
|
+
}
|
|
1639
1705
|
case "queue_remove":
|
|
1640
|
-
|
|
1706
|
+
// #491:removeQueued 是 async——dispatch 是 fire-and-forget,缺 void 时
|
|
1707
|
+
// 内部抛错即 unhandledRejection,而全仓没有兜底 handler,Node ≥15 直接崩进程。
|
|
1708
|
+
void cs.removeQueued(msg.kind, msg.text, msg.index);
|
|
1641
1709
|
break;
|
|
1642
1710
|
case "draft_update":
|
|
1643
1711
|
cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
|
|
@@ -1672,12 +1740,6 @@ wss.on("connection", (ws) => {
|
|
|
1672
1740
|
case "rollback_session":
|
|
1673
1741
|
void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
|
|
1674
1742
|
break;
|
|
1675
|
-
case "cycle_model":
|
|
1676
|
-
void cs.cycleModel();
|
|
1677
|
-
break;
|
|
1678
|
-
case "cycle_thinking":
|
|
1679
|
-
cs.cycleThinking();
|
|
1680
|
-
break;
|
|
1681
1743
|
case "get_state":
|
|
1682
1744
|
// Always a FULL snapshot: the client is (re)connecting or detected
|
|
1683
1745
|
// a rev/seq gap — it needs an authoritative state to rebuild from.
|
|
@@ -1697,6 +1759,9 @@ wss.on("connection", (ws) => {
|
|
|
1697
1759
|
send({ type: "tool_info", name: msg.name, found: false, unsupported: true });
|
|
1698
1760
|
}
|
|
1699
1761
|
break;
|
|
1762
|
+
case "get_compacted_messages":
|
|
1763
|
+
void cs.getCompactedMessages?.(msg.compactionMessageId, msg.conversationId);
|
|
1764
|
+
break;
|
|
1700
1765
|
case "list_sessions":
|
|
1701
1766
|
void cs.refreshSessions();
|
|
1702
1767
|
break;
|
|
@@ -1723,12 +1788,19 @@ wss.on("connection", (ws) => {
|
|
|
1723
1788
|
void cs.persistConversation(msg.id);
|
|
1724
1789
|
}
|
|
1725
1790
|
break;
|
|
1791
|
+
case "pin_conversation":
|
|
1792
|
+
if (typeof cs.setConversationPinned === "function") {
|
|
1793
|
+
void cs.setConversationPinned(msg.id, msg.pinned);
|
|
1794
|
+
}
|
|
1795
|
+
break;
|
|
1796
|
+
case "pin_session":
|
|
1797
|
+
if (typeof cs.pinSession === "function") {
|
|
1798
|
+
void cs.pinSession(msg.path, msg.pinned);
|
|
1799
|
+
}
|
|
1800
|
+
break;
|
|
1726
1801
|
case "dismiss_finished_subagents":
|
|
1727
1802
|
void cs.dismissFinishedSubagents(msg.parentId);
|
|
1728
1803
|
break;
|
|
1729
|
-
case "subagent_handoff":
|
|
1730
|
-
void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
|
|
1731
|
-
break;
|
|
1732
1804
|
case "switch_session":
|
|
1733
1805
|
void cs.switchSession(msg.path);
|
|
1734
1806
|
break;
|
|
@@ -2019,6 +2091,7 @@ wss.on("connection", (ws) => {
|
|
|
2019
2091
|
reviewModel: msg.reviewModel,
|
|
2020
2092
|
maxRounds: msg.maxRounds,
|
|
2021
2093
|
locked: msg.locked,
|
|
2094
|
+
execModel: msg.execModel,
|
|
2022
2095
|
});
|
|
2023
2096
|
break;
|
|
2024
2097
|
case "clear_goal":
|
|
@@ -2036,6 +2109,7 @@ wss.on("connection", (ws) => {
|
|
|
2036
2109
|
reviewModel: msg.reviewModel,
|
|
2037
2110
|
maxRounds: msg.maxRounds,
|
|
2038
2111
|
locked: msg.locked,
|
|
2112
|
+
execModel: msg.execModel,
|
|
2039
2113
|
});
|
|
2040
2114
|
break;
|
|
2041
2115
|
case "get_settings":
|
|
@@ -2207,6 +2281,9 @@ wss.on("connection", (ws) => {
|
|
|
2207
2281
|
done: async (ok, info) => {
|
|
2208
2282
|
if (ok) {
|
|
2209
2283
|
await reloadPluginsAndPush(jobLang);
|
|
2284
|
+
if (typeof cs?.checkPluginUpdates === "function") {
|
|
2285
|
+
void cs.checkPluginUpdates(false);
|
|
2286
|
+
}
|
|
2210
2287
|
const isZh = jobLang() === "zh";
|
|
2211
2288
|
const actionLabel = jobAction === "uninstall"
|
|
2212
2289
|
? isZh
|
|
@@ -2448,6 +2525,28 @@ wss.on("connection", (ws) => {
|
|
|
2448
2525
|
case "plan_update":
|
|
2449
2526
|
cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
|
|
2450
2527
|
break;
|
|
2528
|
+
case "plan_step_update":
|
|
2529
|
+
cs.updatePlanStep?.(msg.stepId, msg.patch, msg.conversationId);
|
|
2530
|
+
break;
|
|
2531
|
+
case "plan_step_delete":
|
|
2532
|
+
cs.deletePlanStep?.(msg.stepId, msg.conversationId);
|
|
2533
|
+
break;
|
|
2534
|
+
case "plan_step_add":
|
|
2535
|
+
cs.addPlanStep?.(msg.step, msg.afterStepId, msg.conversationId);
|
|
2536
|
+
break;
|
|
2537
|
+
case "plan_clean_handoff":
|
|
2538
|
+
void cs.planCleanHandoff?.(msg.steps, msg.prompt);
|
|
2539
|
+
break;
|
|
2540
|
+
case "set_plan_mode":
|
|
2541
|
+
// 计划模式(只规划不实施):会话级开关,热生效(提示词重建 + 工具硬闸门)。
|
|
2542
|
+
void cs.setPlanMode?.(msg.enabled, msg.conversationId);
|
|
2543
|
+
break;
|
|
2544
|
+
case "set_delegate_mode":
|
|
2545
|
+
// 审查者模式(自动委派):会话级开关,默认关。开启后本对话只审阅,
|
|
2546
|
+
// 用户 prompt 由服务端转给常驻落盘执行对话。DSH 引擎的实现在
|
|
2547
|
+
// setDelegateMode 里直接拒(无 customTools 注册面 → 闸门无处可挂)。
|
|
2548
|
+
void cs.setDelegateMode?.(msg.enabled, msg.conversationId);
|
|
2549
|
+
break;
|
|
2451
2550
|
case "page_response":
|
|
2452
2551
|
// 浏览器(page-picker 扩展经前端)对 browser_page 的回包:恢复挂起的
|
|
2453
2552
|
// pageCall;id 不匹配(超时后迟到/页面刷新)由 resolvePageCall 静默忽略。
|
|
@@ -2498,11 +2597,12 @@ wss.on("connection", (ws) => {
|
|
|
2498
2597
|
scheduler.upsert(msg.task);
|
|
2499
2598
|
}
|
|
2500
2599
|
catch (err) {
|
|
2600
|
+
const isVal = err instanceof SchedulerValidationError;
|
|
2501
2601
|
send({
|
|
2502
2602
|
type: "notice",
|
|
2503
2603
|
level: "error",
|
|
2504
|
-
text: `保存定时任务失败:${err.message}`,
|
|
2505
|
-
textEn: `Failed to save scheduled task: ${err.message}`,
|
|
2604
|
+
text: `保存定时任务失败:${isVal ? err.messageZh : err.message}`,
|
|
2605
|
+
textEn: `Failed to save scheduled task: ${isVal ? err.messageEn : err.message}`,
|
|
2506
2606
|
});
|
|
2507
2607
|
}
|
|
2508
2608
|
break;
|
|
@@ -2566,6 +2666,8 @@ wss.on("connection", (ws) => {
|
|
|
2566
2666
|
service: SERVICE_INFO ?? undefined,
|
|
2567
2667
|
});
|
|
2568
2668
|
ws.on("message", (data) => {
|
|
2669
|
+
tracked.isAlive = true;
|
|
2670
|
+
tracked.missedPings = 0;
|
|
2569
2671
|
let msg;
|
|
2570
2672
|
try {
|
|
2571
2673
|
msg = JSON.parse(data.toString());
|
|
@@ -136,10 +136,6 @@ export function launchOrigin() {
|
|
|
136
136
|
cached ??= detectLaunchOrigin();
|
|
137
137
|
return cached;
|
|
138
138
|
}
|
|
139
|
-
/** 仅测试用:清掉缓存。 */
|
|
140
|
-
export function resetLaunchOriginCache() {
|
|
141
|
-
cached = null;
|
|
142
|
-
}
|
|
143
139
|
/** 下发给浏览器的服务信息(null = 没有 supervisor,界面不提供「重启服务」)。 */
|
|
144
140
|
export function toServiceInfo(origin) {
|
|
145
141
|
if (!origin.supervisor)
|
package/dist/server/locales.js
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* tolerable. Manually dropped <dataDir>/locales/*.json files work too
|
|
14
14
|
* (offline installs) — the list is read from disk.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync,
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { extractServerStrings, registerServerStrings, unregisterServerStrings } from "./i18n.js";
|
|
19
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
19
20
|
/** Packs available for download (alphabetical by code). */
|
|
20
21
|
export const LOCALE_PACKS = [
|
|
21
22
|
{ code: "de", nativeName: "Deutsch" },
|
|
@@ -188,11 +189,8 @@ export async function installPack(dataDir, code, opts = {}) {
|
|
|
188
189
|
const v = validatePack(data, code);
|
|
189
190
|
if (!v.ok)
|
|
190
191
|
throw new Error(`invalid pack ${code}: ${v.error}`);
|
|
191
|
-
mkdirSync(join(dataDir, "locales"), { recursive: true });
|
|
192
192
|
const file = packPath(dataDir, code);
|
|
193
|
-
|
|
194
|
-
writeFileSync(tmp, JSON.stringify(v.pack));
|
|
195
|
-
renameSync(tmp, file);
|
|
193
|
+
writeJsonAtomicSync(file, v.pack, 0);
|
|
196
194
|
return { code, nativeName: v.pack.nativeName, version: v.pack.version };
|
|
197
195
|
}
|
|
198
196
|
catch (e) {
|
package/dist/server/lsp-tool.js
CHANGED
|
@@ -19,6 +19,7 @@ import { delimiter, extname, isAbsolute, join, relative, resolve, sep } from "no
|
|
|
19
19
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
20
20
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { Type } from "typebox";
|
|
22
|
+
import { pick } from "./i18n.js";
|
|
22
23
|
export const LSP_TOOL_NAME = "lsp";
|
|
23
24
|
export const LSP_SYMBOL_KINDS = {
|
|
24
25
|
1: "File",
|
|
@@ -694,13 +695,22 @@ export async function getLiveLspDiagnostics(absPath, cwd) {
|
|
|
694
695
|
});
|
|
695
696
|
return `⚠️ Post-edit Diagnostics (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`;
|
|
696
697
|
}
|
|
698
|
+
/** 文本大纲与 details.symbols 共用的展平符号数上限(顶层与子级一并计数)。 */
|
|
699
|
+
const DOCUMENT_SYMBOL_OUTLINE_CAP = 300;
|
|
700
|
+
/**
|
|
701
|
+
* details.symbols 的序列化体积预算(字符数)。details 随会话持久且整体 ≤64KB,
|
|
702
|
+
* 超限会被整条丢弃(见 serialize.ts 的 TOOL_DETAILS_CAP);符号名/签名极长的
|
|
703
|
+
* 极端文件(生成的 .d.ts、protobuf 产物)下 300 个符号也可能撑爆,故按实际
|
|
704
|
+
* 序列化体积逐步收紧上限(预算留足余量给 count/truncated 等包装字段)。
|
|
705
|
+
*/
|
|
706
|
+
const DOCUMENT_SYMBOL_DETAILS_CHAR_BUDGET = 60_000;
|
|
697
707
|
/**
|
|
698
708
|
* 递归格式化 DocumentSymbol 列表为缩进的符号大纲树(做条数上限保护)
|
|
699
709
|
*/
|
|
700
710
|
function formatDocumentSymbols(symbols, indent = "", lines = []) {
|
|
701
711
|
for (const sym of symbols) {
|
|
702
|
-
if (lines.length >=
|
|
703
|
-
lines.push(`${indent}• ... [Truncated: outline exceeds
|
|
712
|
+
if (lines.length >= DOCUMENT_SYMBOL_OUTLINE_CAP) {
|
|
713
|
+
lines.push(`${indent}• ... [Truncated: outline exceeds ${DOCUMENT_SYMBOL_OUTLINE_CAP} symbols]`);
|
|
704
714
|
break;
|
|
705
715
|
}
|
|
706
716
|
const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
|
|
@@ -716,6 +726,56 @@ function formatDocumentSymbols(symbols, indent = "", lines = []) {
|
|
|
716
726
|
}
|
|
717
727
|
return lines;
|
|
718
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* 与 formatDocumentSymbols 同口径的结构化裁剪:按展平后的符号数(先序遍历,
|
|
731
|
+
* 顶层与子级一并计数)截断到 cap 个。只在展平序列的边界上裁剪,保留符号的
|
|
732
|
+
* 父子结构保持一致 —— 父被裁掉则整棵子树不再出现;有符号被裁时 truncated 置真。
|
|
733
|
+
* 未触发截断时原样返回(不拷贝,避免大对象无谓重建)。
|
|
734
|
+
*/
|
|
735
|
+
function capDocumentSymbolTree(symbols, cap) {
|
|
736
|
+
let count = 0;
|
|
737
|
+
let truncated = false;
|
|
738
|
+
const walk = (list) => {
|
|
739
|
+
const kept = [];
|
|
740
|
+
let changed = false;
|
|
741
|
+
for (const sym of list) {
|
|
742
|
+
if (count >= cap) {
|
|
743
|
+
truncated = true;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
count += 1;
|
|
747
|
+
if (Array.isArray(sym.children) && sym.children.length > 0) {
|
|
748
|
+
const sub = walk(sym.children);
|
|
749
|
+
if (sub.changed || sub.kept.length !== sym.children.length) {
|
|
750
|
+
changed = true;
|
|
751
|
+
kept.push({ ...sym, children: sub.kept });
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
kept.push(sym);
|
|
756
|
+
}
|
|
757
|
+
return { kept, changed };
|
|
758
|
+
};
|
|
759
|
+
const { kept } = walk(symbols);
|
|
760
|
+
return { capped: truncated ? kept : symbols, truncated };
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* details.symbols 的最终裁剪:先与文本大纲同口径按展平计数截到 300;再用实际
|
|
764
|
+
* 序列化体积校验 —— details 随会话持久且整体 ≤64KB,超限整条丢弃(见
|
|
765
|
+
* serialize.ts 的 TOOL_DETAILS_CAP),符号名/签名极长的极端文件下按半数逐步
|
|
766
|
+
* 收紧展平上限,保证结构化大纲真的能进快照而不是被整条丢掉。
|
|
767
|
+
*/
|
|
768
|
+
function capDocumentSymbolsForDetails(symbols) {
|
|
769
|
+
let cap = DOCUMENT_SYMBOL_OUTLINE_CAP;
|
|
770
|
+
let res = capDocumentSymbolTree(symbols, cap);
|
|
771
|
+
let size = JSON.stringify(res.capped).length;
|
|
772
|
+
while (size > DOCUMENT_SYMBOL_DETAILS_CHAR_BUDGET && cap > 1) {
|
|
773
|
+
cap = Math.floor(cap / 2);
|
|
774
|
+
res = capDocumentSymbolTree(symbols, cap);
|
|
775
|
+
size = JSON.stringify(res.capped).length;
|
|
776
|
+
}
|
|
777
|
+
return res;
|
|
778
|
+
}
|
|
719
779
|
/**
|
|
720
780
|
* 递归单趟查找符号(两遍扫描:先严格精确匹配,未命中再执行大小写忽略回退,避免遮蔽后续精确符号;支持 containerName 点分路径)
|
|
721
781
|
*/
|
|
@@ -793,8 +853,10 @@ function findDefaultSourceFileForLsp(cwd) {
|
|
|
793
853
|
}
|
|
794
854
|
export function makeLspTool(options) {
|
|
795
855
|
const cwd = options.cwd;
|
|
856
|
+
const getLang = options.lang ?? (() => "en");
|
|
796
857
|
return defineTool({
|
|
797
858
|
name: LSP_TOOL_NAME,
|
|
859
|
+
promptSnippet: "IDE-grade semantic analysis (LSP) across the workspace: definition, references, hover, diagnostics, symbols, impact check",
|
|
798
860
|
label: "LSP code intelligence",
|
|
799
861
|
description: `IDE-grade semantic analysis (LSP) across the workspace. Actions:
|
|
800
862
|
- \`definition\`: definition of the symbol at \`line\`/\`character\` in \`path\` (file, line, snippet).
|
|
@@ -807,16 +869,18 @@ export function makeLspTool(options) {
|
|
|
807
869
|
- \`cascade\`: impact check for \`path\` — report diagnostics of files referencing it.
|
|
808
870
|
Lines are 1-indexed.`,
|
|
809
871
|
parameters: Type.Object({
|
|
810
|
-
action: Type.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
872
|
+
action: Type.Unsafe({
|
|
873
|
+
type: "string",
|
|
874
|
+
enum: [
|
|
875
|
+
"definition",
|
|
876
|
+
"references",
|
|
877
|
+
"hover",
|
|
878
|
+
"diagnostics",
|
|
879
|
+
"documentSymbol",
|
|
880
|
+
"read_symbol",
|
|
881
|
+
"workspaceSymbol",
|
|
882
|
+
"cascade",
|
|
883
|
+
],
|
|
820
884
|
description: "The LSP operation to perform.",
|
|
821
885
|
}),
|
|
822
886
|
path: Type.Optional(Type.String({
|
|
@@ -835,13 +899,16 @@ Lines are 1-indexed.`,
|
|
|
835
899
|
description: "1-indexed column/character position (defaults to 1).",
|
|
836
900
|
})),
|
|
837
901
|
timeout: Type.Optional(Type.Number({
|
|
838
|
-
|
|
902
|
+
minimum: 1,
|
|
903
|
+
maximum: 120,
|
|
904
|
+
description: "Timeout in seconds (1-120, defaults to 15).",
|
|
839
905
|
})),
|
|
840
906
|
allowInstall: Type.Optional(Type.Boolean({
|
|
841
907
|
description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
|
|
842
908
|
})),
|
|
843
909
|
}),
|
|
844
910
|
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
911
|
+
const L = getLang();
|
|
845
912
|
const action = params.action;
|
|
846
913
|
let targetPath = params.path;
|
|
847
914
|
if (!targetPath && action === "workspaceSymbol") {
|
|
@@ -851,7 +918,7 @@ Lines are 1-indexed.`,
|
|
|
851
918
|
content: [
|
|
852
919
|
{
|
|
853
920
|
type: "text",
|
|
854
|
-
text:
|
|
921
|
+
text: pick(L, "错误:无法自动检测项目主要源码文件以路由语言服务器。请提供 'path' 参数(指向项目中任意源码文件,例如 path='src/index.ts')。", "Error: Could not automatically detect a primary project source file to route language server. Please provide 'path' (pointing to any source file in the project, e.g. path='src/index.ts') to select the language server."),
|
|
855
922
|
},
|
|
856
923
|
],
|
|
857
924
|
details: { ok: false, error: "Missing path: cannot route language server" },
|
|
@@ -860,14 +927,19 @@ Lines are 1-indexed.`,
|
|
|
860
927
|
}
|
|
861
928
|
if (!targetPath) {
|
|
862
929
|
return {
|
|
863
|
-
content: [
|
|
930
|
+
content: [
|
|
931
|
+
{
|
|
932
|
+
type: "text",
|
|
933
|
+
text: pick(L, `错误:执行操作 '${action}' 时必须提供 'path' 参数。`, `Error: 'path' parameter is required for action '${action}'.`),
|
|
934
|
+
},
|
|
935
|
+
],
|
|
864
936
|
details: { ok: false, error: "Missing path parameter" },
|
|
865
937
|
};
|
|
866
938
|
}
|
|
867
939
|
const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
|
|
868
940
|
const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
|
|
869
941
|
const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
|
|
870
|
-
const timeoutMs = (params.timeout ?? 15) * 1000;
|
|
942
|
+
const timeoutMs = Math.max(1000, Math.min(120_000, Math.floor((params.timeout ?? 15) * 1000)));
|
|
871
943
|
const rel = relative(cwd, absPath);
|
|
872
944
|
if (rel === ".." ||
|
|
873
945
|
rel.startsWith(".." + sep) ||
|
|
@@ -875,13 +947,23 @@ Lines are 1-indexed.`,
|
|
|
875
947
|
rel.startsWith("..\\") ||
|
|
876
948
|
isAbsolute(rel)) {
|
|
877
949
|
return {
|
|
878
|
-
content: [
|
|
950
|
+
content: [
|
|
951
|
+
{
|
|
952
|
+
type: "text",
|
|
953
|
+
text: pick(L, `错误:路径穿越已被拒绝:${targetPath} 超出了工作区范围。`, `Error: Path traversal denied: ${targetPath} is outside workspace.`),
|
|
954
|
+
},
|
|
955
|
+
],
|
|
879
956
|
details: { ok: false, error: "Path traversal denied" },
|
|
880
957
|
};
|
|
881
958
|
}
|
|
882
959
|
if (!existsSync(absPath)) {
|
|
883
960
|
return {
|
|
884
|
-
content: [
|
|
961
|
+
content: [
|
|
962
|
+
{
|
|
963
|
+
type: "text",
|
|
964
|
+
text: pick(L, `错误:文件不存在:${targetPath}`, `Error: File not found: ${targetPath}`),
|
|
965
|
+
},
|
|
966
|
+
],
|
|
885
967
|
details: { ok: false, error: "File not found" },
|
|
886
968
|
};
|
|
887
969
|
}
|
|
@@ -890,7 +972,7 @@ Lines are 1-indexed.`,
|
|
|
890
972
|
content: [
|
|
891
973
|
{
|
|
892
974
|
type: "text",
|
|
893
|
-
text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName")
|
|
975
|
+
text: pick(L, `错误:执行 'read_symbol' 操作必须提供 'symbol' 参数(例如 symbol="parseConfig" 或 "ClassName.methodName")。`, `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`),
|
|
894
976
|
},
|
|
895
977
|
],
|
|
896
978
|
details: { ok: false, error: "Missing symbol parameter" },
|
|
@@ -901,7 +983,7 @@ Lines are 1-indexed.`,
|
|
|
901
983
|
content: [
|
|
902
984
|
{
|
|
903
985
|
type: "text",
|
|
904
|
-
text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router')
|
|
986
|
+
text: pick(L, `错误:执行 'workspaceSymbol' 操作时 'query' 参数不能为空。请提供搜索词(例如 query='User' 或 'Router')。`, `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`),
|
|
905
987
|
},
|
|
906
988
|
],
|
|
907
989
|
details: { ok: false, error: "Empty query parameter" },
|
|
@@ -1062,8 +1144,10 @@ Lines are 1-indexed.`,
|
|
|
1062
1144
|
};
|
|
1063
1145
|
}
|
|
1064
1146
|
const lines = formatDocumentSymbols(symbols);
|
|
1065
|
-
// details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols
|
|
1066
|
-
|
|
1147
|
+
// details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径
|
|
1148
|
+
// 截断 —— 按展平后的符号数(先序遍历,顶层与子级一并计数)截到 300,保留
|
|
1149
|
+
// 符号维持父子结构;再按序列化体积兜底收紧,极端文件下保证 details 进得了快照。
|
|
1150
|
+
const { capped: detailsSymbols, truncated: symbolsTruncated } = capDocumentSymbolsForDetails(symbols);
|
|
1067
1151
|
return {
|
|
1068
1152
|
content: [
|
|
1069
1153
|
{
|
|
@@ -1071,7 +1155,7 @@ Lines are 1-indexed.`,
|
|
|
1071
1155
|
text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
|
|
1072
1156
|
},
|
|
1073
1157
|
],
|
|
1074
|
-
details: { ok: true, count: symbols.length, symbols: detailsSymbols },
|
|
1158
|
+
details: { ok: true, count: symbols.length, symbols: detailsSymbols, truncated: symbolsTruncated },
|
|
1075
1159
|
};
|
|
1076
1160
|
}
|
|
1077
1161
|
if (action === "read_symbol") {
|
|
@@ -1216,12 +1300,14 @@ Lines are 1-indexed.`,
|
|
|
1216
1300
|
const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
1217
1301
|
// 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
|
|
1218
1302
|
const seeds = [];
|
|
1303
|
+
let seedTotal = 0; // 顶层符号总数(截断前),超出 MAX_SEEDS 时在输出尾部标明
|
|
1219
1304
|
if (typeof params.line === "number") {
|
|
1220
1305
|
seeds.push({ line: line - 1, character: character - 1 });
|
|
1221
1306
|
}
|
|
1222
1307
|
else {
|
|
1223
1308
|
const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
|
|
1224
1309
|
const topSymbols = Array.isArray(symResult) ? symResult : [];
|
|
1310
|
+
seedTotal = topSymbols.length;
|
|
1225
1311
|
for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
|
|
1226
1312
|
const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
|
|
1227
1313
|
if (pos && typeof pos.line === "number") {
|
|
@@ -1264,6 +1350,10 @@ Lines are 1-indexed.`,
|
|
|
1264
1350
|
depPaths.add(refPath);
|
|
1265
1351
|
}
|
|
1266
1352
|
}
|
|
1353
|
+
// 只分析排序后的前 MAX_DEPENDENTS 个引用方,但计数必须如实上报:文本与
|
|
1354
|
+
// details 都带截断前的真实总数,超出部分用尾注标明(对照 references 动作
|
|
1355
|
+
// 的 "... and N more references" 口径),避免模型误以为清单是完整的。
|
|
1356
|
+
const totalReferencing = depPaths.size;
|
|
1267
1357
|
const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
|
|
1268
1358
|
if (dependents.length === 0) {
|
|
1269
1359
|
return {
|
|
@@ -1324,7 +1414,7 @@ Lines are 1-indexed.`,
|
|
|
1324
1414
|
}
|
|
1325
1415
|
impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
|
|
1326
1416
|
const out = [];
|
|
1327
|
-
out.push(`Impact cascade for ${targetPath}: ${
|
|
1417
|
+
out.push(`Impact cascade for ${targetPath}: ${totalReferencing} referencing file(s), ${impacted.length} with findings.`);
|
|
1328
1418
|
for (const item of impacted) {
|
|
1329
1419
|
out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
|
|
1330
1420
|
for (const d of item.diagnostics.slice(0, 5)) {
|
|
@@ -1344,6 +1434,14 @@ Lines are 1-indexed.`,
|
|
|
1344
1434
|
if (notReported.length > 0) {
|
|
1345
1435
|
out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
|
|
1346
1436
|
}
|
|
1437
|
+
// 截断尾注:与 references 动作的 "... and N more references" 同口径,
|
|
1438
|
+
// 让模型知道被丢弃的引用方/种子符号既不在 clean 也不在 notReported 里。
|
|
1439
|
+
if (totalReferencing > dependents.length) {
|
|
1440
|
+
out.push(`... and ${totalReferencing - dependents.length} more not analyzed`);
|
|
1441
|
+
}
|
|
1442
|
+
if (seedTotal > MAX_SEEDS) {
|
|
1443
|
+
out.push(`... and ${seedTotal - MAX_SEEDS} more top-level symbols not analyzed`);
|
|
1444
|
+
}
|
|
1347
1445
|
return {
|
|
1348
1446
|
content: [{ type: "text", text: out.join("\n") }],
|
|
1349
1447
|
details: {
|
|
@@ -1352,6 +1450,7 @@ Lines are 1-indexed.`,
|
|
|
1352
1450
|
clean,
|
|
1353
1451
|
notReported,
|
|
1354
1452
|
referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
|
|
1453
|
+
referencedFilesTotal: totalReferencing,
|
|
1355
1454
|
},
|
|
1356
1455
|
};
|
|
1357
1456
|
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { getServerBlock, pick } from "../../i18n.js";
|
|
5
5
|
const NOTIFY_GUIDANCE_ZH = [
|
|
6
|
-
|
|
6
|
+
// #499:guidance 只能承诺协议真实支持的级别——success 从未存在于 notice 协议
|
|
7
|
+
// (protocol.ts 仅 info|warning|error),按指引用 success 会被静默降级成 info。
|
|
8
|
+
"- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|error。",
|
|
7
9
|
];
|
|
8
10
|
const NOTIFY_GUIDANCE_EN = [
|
|
9
|
-
"- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|
|
|
11
|
+
"- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|error.",
|
|
10
12
|
];
|
|
11
13
|
/** 语言感知的 notify guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
|
|
12
14
|
export function getNotifyGuidance(lang = "en") {
|