pi-web-ui 0.97.0 → 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 +97 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +637 -145
- 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 +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- 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 +8 -4
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +49 -5
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- 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 -26
- 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-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 +21 -6
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +58 -20
- 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 +3 -5
- 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 +16 -2
- 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 +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -25
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- 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/web/dist/assets/{TerminalPanel-CITBqQrb.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-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
package/dist/server/index.js
CHANGED
|
@@ -56,7 +56,7 @@ import { syncPluginCatalog } from "./plugin-catalog-sync.js";
|
|
|
56
56
|
import { McpBridge } from "./mcp-bridge.js";
|
|
57
57
|
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
58
58
|
import { createHostMetricsSampler } from "./host-metrics.js";
|
|
59
|
-
import { SchedulerStore } from "./scheduler-tasks.js";
|
|
59
|
+
import { SchedulerStore, SchedulerValidationError } from "./scheduler-tasks.js";
|
|
60
60
|
import { initHttpProxy } from "./http-proxy.js";
|
|
61
61
|
import { globalLspPool } from "./lsp-tool.js";
|
|
62
62
|
import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
|
|
@@ -89,6 +89,13 @@ function cliFlag(name) {
|
|
|
89
89
|
return undefined;
|
|
90
90
|
}
|
|
91
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
|
+
}
|
|
92
99
|
const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
93
100
|
const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
94
101
|
// The data dir is where the control socket, client state, plugins, themes and
|
|
@@ -976,6 +983,8 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
976
983
|
// closing sockets, sleep/wake, network partitions). Also broadcasts lightweight
|
|
977
984
|
// server host metrics (CPU and memory usage) sampled every ~2s.
|
|
978
985
|
const HEARTBEAT_INTERVAL_MS = 2_000;
|
|
986
|
+
/** 全局广播与心跳推送背压阈值(256KB):socket 积压时丢弃广播消息,防用户态无界堆积(issue #460)。 */
|
|
987
|
+
const BROADCAST_BACKPRESSURE_MAX_BYTES = 262_144;
|
|
979
988
|
const sampleHostMetrics = createHostMetricsSampler();
|
|
980
989
|
const heartbeatTimer = setInterval(() => {
|
|
981
990
|
let message = { type: "heartbeat" };
|
|
@@ -988,8 +997,40 @@ const heartbeatTimer = setInterval(() => {
|
|
|
988
997
|
if (wss.clients.size > 0) {
|
|
989
998
|
const payload = JSON.stringify(message);
|
|
990
999
|
for (const ws of wss.clients) {
|
|
991
|
-
if (ws.readyState
|
|
992
|
-
|
|
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 { }
|
|
993
1034
|
}
|
|
994
1035
|
}
|
|
995
1036
|
}
|
|
@@ -1272,6 +1313,8 @@ function pushSchedulerTasks() {
|
|
|
1272
1313
|
for (const client of wss.clients) {
|
|
1273
1314
|
if (client.readyState !== WebSocket.OPEN)
|
|
1274
1315
|
continue;
|
|
1316
|
+
if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
|
|
1317
|
+
continue;
|
|
1275
1318
|
try {
|
|
1276
1319
|
client.send(payload);
|
|
1277
1320
|
}
|
|
@@ -1290,6 +1333,8 @@ function pushNoticeToAll(level, text, textEn) {
|
|
|
1290
1333
|
for (const client of wss.clients) {
|
|
1291
1334
|
if (client.readyState !== WebSocket.OPEN)
|
|
1292
1335
|
continue;
|
|
1336
|
+
if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
|
|
1337
|
+
continue;
|
|
1293
1338
|
try {
|
|
1294
1339
|
client.send(payload);
|
|
1295
1340
|
}
|
|
@@ -1535,6 +1580,13 @@ function serializeShared(msg) {
|
|
|
1535
1580
|
return s;
|
|
1536
1581
|
}
|
|
1537
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
|
+
});
|
|
1538
1590
|
// Count attached sockets (the control socket reports REAL sockets, not
|
|
1539
1591
|
// cached client-session objects).
|
|
1540
1592
|
service.noteSocketOpen();
|
|
@@ -1651,7 +1703,9 @@ wss.on("connection", (ws) => {
|
|
|
1651
1703
|
break;
|
|
1652
1704
|
}
|
|
1653
1705
|
case "queue_remove":
|
|
1654
|
-
|
|
1706
|
+
// #491:removeQueued 是 async——dispatch 是 fire-and-forget,缺 void 时
|
|
1707
|
+
// 内部抛错即 unhandledRejection,而全仓没有兜底 handler,Node ≥15 直接崩进程。
|
|
1708
|
+
void cs.removeQueued(msg.kind, msg.text, msg.index);
|
|
1655
1709
|
break;
|
|
1656
1710
|
case "draft_update":
|
|
1657
1711
|
cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
|
|
@@ -1686,12 +1740,6 @@ wss.on("connection", (ws) => {
|
|
|
1686
1740
|
case "rollback_session":
|
|
1687
1741
|
void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
|
|
1688
1742
|
break;
|
|
1689
|
-
case "cycle_model":
|
|
1690
|
-
void cs.cycleModel();
|
|
1691
|
-
break;
|
|
1692
|
-
case "cycle_thinking":
|
|
1693
|
-
cs.cycleThinking();
|
|
1694
|
-
break;
|
|
1695
1743
|
case "get_state":
|
|
1696
1744
|
// Always a FULL snapshot: the client is (re)connecting or detected
|
|
1697
1745
|
// a rev/seq gap — it needs an authoritative state to rebuild from.
|
|
@@ -1745,12 +1793,14 @@ wss.on("connection", (ws) => {
|
|
|
1745
1793
|
void cs.setConversationPinned(msg.id, msg.pinned);
|
|
1746
1794
|
}
|
|
1747
1795
|
break;
|
|
1796
|
+
case "pin_session":
|
|
1797
|
+
if (typeof cs.pinSession === "function") {
|
|
1798
|
+
void cs.pinSession(msg.path, msg.pinned);
|
|
1799
|
+
}
|
|
1800
|
+
break;
|
|
1748
1801
|
case "dismiss_finished_subagents":
|
|
1749
1802
|
void cs.dismissFinishedSubagents(msg.parentId);
|
|
1750
1803
|
break;
|
|
1751
|
-
case "subagent_handoff":
|
|
1752
|
-
void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
|
|
1753
|
-
break;
|
|
1754
1804
|
case "switch_session":
|
|
1755
1805
|
void cs.switchSession(msg.path);
|
|
1756
1806
|
break;
|
|
@@ -2231,6 +2281,9 @@ wss.on("connection", (ws) => {
|
|
|
2231
2281
|
done: async (ok, info) => {
|
|
2232
2282
|
if (ok) {
|
|
2233
2283
|
await reloadPluginsAndPush(jobLang);
|
|
2284
|
+
if (typeof cs?.checkPluginUpdates === "function") {
|
|
2285
|
+
void cs.checkPluginUpdates(false);
|
|
2286
|
+
}
|
|
2234
2287
|
const isZh = jobLang() === "zh";
|
|
2235
2288
|
const actionLabel = jobAction === "uninstall"
|
|
2236
2289
|
? isZh
|
|
@@ -2472,6 +2525,18 @@ wss.on("connection", (ws) => {
|
|
|
2472
2525
|
case "plan_update":
|
|
2473
2526
|
cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
|
|
2474
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;
|
|
2475
2540
|
case "set_plan_mode":
|
|
2476
2541
|
// 计划模式(只规划不实施):会话级开关,热生效(提示词重建 + 工具硬闸门)。
|
|
2477
2542
|
void cs.setPlanMode?.(msg.enabled, msg.conversationId);
|
|
@@ -2532,11 +2597,12 @@ wss.on("connection", (ws) => {
|
|
|
2532
2597
|
scheduler.upsert(msg.task);
|
|
2533
2598
|
}
|
|
2534
2599
|
catch (err) {
|
|
2600
|
+
const isVal = err instanceof SchedulerValidationError;
|
|
2535
2601
|
send({
|
|
2536
2602
|
type: "notice",
|
|
2537
2603
|
level: "error",
|
|
2538
|
-
text: `保存定时任务失败:${err.message}`,
|
|
2539
|
-
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}`,
|
|
2540
2606
|
});
|
|
2541
2607
|
}
|
|
2542
2608
|
break;
|
|
@@ -2600,6 +2666,8 @@ wss.on("connection", (ws) => {
|
|
|
2600
2666
|
service: SERVICE_INFO ?? undefined,
|
|
2601
2667
|
});
|
|
2602
2668
|
ws.on("message", (data) => {
|
|
2669
|
+
tracked.isAlive = true;
|
|
2670
|
+
tracked.missedPings = 0;
|
|
2603
2671
|
let msg;
|
|
2604
2672
|
try {
|
|
2605
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,6 +853,7 @@ 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,
|
|
798
859
|
promptSnippet: "IDE-grade semantic analysis (LSP) across the workspace: definition, references, hover, diagnostics, symbols, impact check",
|
|
@@ -838,13 +899,16 @@ Lines are 1-indexed.`,
|
|
|
838
899
|
description: "1-indexed column/character position (defaults to 1).",
|
|
839
900
|
})),
|
|
840
901
|
timeout: Type.Optional(Type.Number({
|
|
841
|
-
|
|
902
|
+
minimum: 1,
|
|
903
|
+
maximum: 120,
|
|
904
|
+
description: "Timeout in seconds (1-120, defaults to 15).",
|
|
842
905
|
})),
|
|
843
906
|
allowInstall: Type.Optional(Type.Boolean({
|
|
844
907
|
description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
|
|
845
908
|
})),
|
|
846
909
|
}),
|
|
847
910
|
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
911
|
+
const L = getLang();
|
|
848
912
|
const action = params.action;
|
|
849
913
|
let targetPath = params.path;
|
|
850
914
|
if (!targetPath && action === "workspaceSymbol") {
|
|
@@ -854,7 +918,7 @@ Lines are 1-indexed.`,
|
|
|
854
918
|
content: [
|
|
855
919
|
{
|
|
856
920
|
type: "text",
|
|
857
|
-
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."),
|
|
858
922
|
},
|
|
859
923
|
],
|
|
860
924
|
details: { ok: false, error: "Missing path: cannot route language server" },
|
|
@@ -863,14 +927,19 @@ Lines are 1-indexed.`,
|
|
|
863
927
|
}
|
|
864
928
|
if (!targetPath) {
|
|
865
929
|
return {
|
|
866
|
-
content: [
|
|
930
|
+
content: [
|
|
931
|
+
{
|
|
932
|
+
type: "text",
|
|
933
|
+
text: pick(L, `错误:执行操作 '${action}' 时必须提供 'path' 参数。`, `Error: 'path' parameter is required for action '${action}'.`),
|
|
934
|
+
},
|
|
935
|
+
],
|
|
867
936
|
details: { ok: false, error: "Missing path parameter" },
|
|
868
937
|
};
|
|
869
938
|
}
|
|
870
939
|
const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
|
|
871
940
|
const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
|
|
872
941
|
const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
|
|
873
|
-
const timeoutMs = (params.timeout ?? 15) * 1000;
|
|
942
|
+
const timeoutMs = Math.max(1000, Math.min(120_000, Math.floor((params.timeout ?? 15) * 1000)));
|
|
874
943
|
const rel = relative(cwd, absPath);
|
|
875
944
|
if (rel === ".." ||
|
|
876
945
|
rel.startsWith(".." + sep) ||
|
|
@@ -878,13 +947,23 @@ Lines are 1-indexed.`,
|
|
|
878
947
|
rel.startsWith("..\\") ||
|
|
879
948
|
isAbsolute(rel)) {
|
|
880
949
|
return {
|
|
881
|
-
content: [
|
|
950
|
+
content: [
|
|
951
|
+
{
|
|
952
|
+
type: "text",
|
|
953
|
+
text: pick(L, `错误:路径穿越已被拒绝:${targetPath} 超出了工作区范围。`, `Error: Path traversal denied: ${targetPath} is outside workspace.`),
|
|
954
|
+
},
|
|
955
|
+
],
|
|
882
956
|
details: { ok: false, error: "Path traversal denied" },
|
|
883
957
|
};
|
|
884
958
|
}
|
|
885
959
|
if (!existsSync(absPath)) {
|
|
886
960
|
return {
|
|
887
|
-
content: [
|
|
961
|
+
content: [
|
|
962
|
+
{
|
|
963
|
+
type: "text",
|
|
964
|
+
text: pick(L, `错误:文件不存在:${targetPath}`, `Error: File not found: ${targetPath}`),
|
|
965
|
+
},
|
|
966
|
+
],
|
|
888
967
|
details: { ok: false, error: "File not found" },
|
|
889
968
|
};
|
|
890
969
|
}
|
|
@@ -893,7 +972,7 @@ Lines are 1-indexed.`,
|
|
|
893
972
|
content: [
|
|
894
973
|
{
|
|
895
974
|
type: "text",
|
|
896
|
-
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").`),
|
|
897
976
|
},
|
|
898
977
|
],
|
|
899
978
|
details: { ok: false, error: "Missing symbol parameter" },
|
|
@@ -904,7 +983,7 @@ Lines are 1-indexed.`,
|
|
|
904
983
|
content: [
|
|
905
984
|
{
|
|
906
985
|
type: "text",
|
|
907
|
-
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').`),
|
|
908
987
|
},
|
|
909
988
|
],
|
|
910
989
|
details: { ok: false, error: "Empty query parameter" },
|
|
@@ -1065,8 +1144,10 @@ Lines are 1-indexed.`,
|
|
|
1065
1144
|
};
|
|
1066
1145
|
}
|
|
1067
1146
|
const lines = formatDocumentSymbols(symbols);
|
|
1068
|
-
// details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols
|
|
1069
|
-
|
|
1147
|
+
// details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径
|
|
1148
|
+
// 截断 —— 按展平后的符号数(先序遍历,顶层与子级一并计数)截到 300,保留
|
|
1149
|
+
// 符号维持父子结构;再按序列化体积兜底收紧,极端文件下保证 details 进得了快照。
|
|
1150
|
+
const { capped: detailsSymbols, truncated: symbolsTruncated } = capDocumentSymbolsForDetails(symbols);
|
|
1070
1151
|
return {
|
|
1071
1152
|
content: [
|
|
1072
1153
|
{
|
|
@@ -1074,7 +1155,7 @@ Lines are 1-indexed.`,
|
|
|
1074
1155
|
text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
|
|
1075
1156
|
},
|
|
1076
1157
|
],
|
|
1077
|
-
details: { ok: true, count: symbols.length, symbols: detailsSymbols },
|
|
1158
|
+
details: { ok: true, count: symbols.length, symbols: detailsSymbols, truncated: symbolsTruncated },
|
|
1078
1159
|
};
|
|
1079
1160
|
}
|
|
1080
1161
|
if (action === "read_symbol") {
|
|
@@ -1219,12 +1300,14 @@ Lines are 1-indexed.`,
|
|
|
1219
1300
|
const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
1220
1301
|
// 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
|
|
1221
1302
|
const seeds = [];
|
|
1303
|
+
let seedTotal = 0; // 顶层符号总数(截断前),超出 MAX_SEEDS 时在输出尾部标明
|
|
1222
1304
|
if (typeof params.line === "number") {
|
|
1223
1305
|
seeds.push({ line: line - 1, character: character - 1 });
|
|
1224
1306
|
}
|
|
1225
1307
|
else {
|
|
1226
1308
|
const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
|
|
1227
1309
|
const topSymbols = Array.isArray(symResult) ? symResult : [];
|
|
1310
|
+
seedTotal = topSymbols.length;
|
|
1228
1311
|
for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
|
|
1229
1312
|
const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
|
|
1230
1313
|
if (pos && typeof pos.line === "number") {
|
|
@@ -1267,6 +1350,10 @@ Lines are 1-indexed.`,
|
|
|
1267
1350
|
depPaths.add(refPath);
|
|
1268
1351
|
}
|
|
1269
1352
|
}
|
|
1353
|
+
// 只分析排序后的前 MAX_DEPENDENTS 个引用方,但计数必须如实上报:文本与
|
|
1354
|
+
// details 都带截断前的真实总数,超出部分用尾注标明(对照 references 动作
|
|
1355
|
+
// 的 "... and N more references" 口径),避免模型误以为清单是完整的。
|
|
1356
|
+
const totalReferencing = depPaths.size;
|
|
1270
1357
|
const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
|
|
1271
1358
|
if (dependents.length === 0) {
|
|
1272
1359
|
return {
|
|
@@ -1327,7 +1414,7 @@ Lines are 1-indexed.`,
|
|
|
1327
1414
|
}
|
|
1328
1415
|
impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
|
|
1329
1416
|
const out = [];
|
|
1330
|
-
out.push(`Impact cascade for ${targetPath}: ${
|
|
1417
|
+
out.push(`Impact cascade for ${targetPath}: ${totalReferencing} referencing file(s), ${impacted.length} with findings.`);
|
|
1331
1418
|
for (const item of impacted) {
|
|
1332
1419
|
out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
|
|
1333
1420
|
for (const d of item.diagnostics.slice(0, 5)) {
|
|
@@ -1347,6 +1434,14 @@ Lines are 1-indexed.`,
|
|
|
1347
1434
|
if (notReported.length > 0) {
|
|
1348
1435
|
out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
|
|
1349
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
|
+
}
|
|
1350
1445
|
return {
|
|
1351
1446
|
content: [{ type: "text", text: out.join("\n") }],
|
|
1352
1447
|
details: {
|
|
@@ -1355,6 +1450,7 @@ Lines are 1-indexed.`,
|
|
|
1355
1450
|
clean,
|
|
1356
1451
|
notReported,
|
|
1357
1452
|
referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
|
|
1453
|
+
referencedFilesTotal: totalReferencing,
|
|
1358
1454
|
},
|
|
1359
1455
|
};
|
|
1360
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") {
|
|
@@ -15,30 +15,6 @@ function parseId(raw) {
|
|
|
15
15
|
const n = Number(raw);
|
|
16
16
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
17
17
|
}
|
|
18
|
-
function formatStatus(s) {
|
|
19
|
-
switch (s) {
|
|
20
|
-
case "pending":
|
|
21
|
-
return "pending";
|
|
22
|
-
case "in_progress":
|
|
23
|
-
return "in_progress";
|
|
24
|
-
case "completed":
|
|
25
|
-
return "completed";
|
|
26
|
-
default:
|
|
27
|
-
return s;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export function describeTodos(state, includeDeleted = false, lang = "en") {
|
|
31
|
-
const visible = state.tasks.filter((t) => includeDeleted || t.status !== "deleted");
|
|
32
|
-
if (visible.length === 0)
|
|
33
|
-
return pick(lang, "[todo] (空)", "[todo] (empty)", "markers.todo.list.empty");
|
|
34
|
-
return visible
|
|
35
|
-
.map((t) => {
|
|
36
|
-
const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
|
|
37
|
-
const deps = t.blockedBy.length ? ` ⛓ ${t.blockedBy.join(",")}` : "";
|
|
38
|
-
return `[${formatStatus(t.status)}] #${t.id} ${t.subject}${form}${deps}`;
|
|
39
|
-
})
|
|
40
|
-
.join("\n");
|
|
41
|
-
}
|
|
42
18
|
const TODO_GUIDANCE_ZH = [
|
|
43
19
|
"- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
|
|
44
20
|
"- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
|
|
@@ -64,7 +40,9 @@ export const todoMarker = {
|
|
|
64
40
|
const op = token.op;
|
|
65
41
|
switch (op) {
|
|
66
42
|
case "new": {
|
|
67
|
-
|
|
43
|
+
// #499:subject 是唯一「整段自由文本」参数——args.join 还原含逗号的主题,
|
|
44
|
+
// 否则 `[[todo:new:修复登录页,同步改注册页]]` 的后半段被静默丢弃。
|
|
45
|
+
const subject = token.args.join(",").trim();
|
|
68
46
|
if (!subject)
|
|
69
47
|
return {
|
|
70
48
|
applied: false,
|
|
@@ -179,7 +157,9 @@ export const todoMarker = {
|
|
|
179
157
|
const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
|
|
180
158
|
return {
|
|
181
159
|
applied: true,
|
|
182
|
-
feedback: pick(lang, `#${id} 依赖:${depsText}`,
|
|
160
|
+
feedback: pick(lang, `#${id} 依赖:${depsText}`,
|
|
161
|
+
// #499:实现是 blockedBy(deps 先完成)——原英文 "blocks" 把依赖方向说反了。
|
|
162
|
+
`#${id} is blocked by: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
|
|
183
163
|
};
|
|
184
164
|
}
|
|
185
165
|
default:
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* marker.ts — 通用内联标记核心抽象(内置版)。
|
|
3
3
|
* 复刻自 pi-marker-tools,保持相同解析语义,便于 AI 无缝迁移。
|
|
4
4
|
*/
|
|
5
|
-
export const MARKER_OPEN = "[[";
|
|
6
|
-
export const MARKER_CLOSE = "]]";
|
|
7
5
|
// ---------------------------------------------------------------------------
|
|
8
6
|
// 解析器
|
|
9
7
|
// ---------------------------------------------------------------------------
|
|
@@ -38,16 +36,3 @@ export function parseMarkers(text) {
|
|
|
38
36
|
}
|
|
39
37
|
return tokens;
|
|
40
38
|
}
|
|
41
|
-
export function stripMarkers(text) {
|
|
42
|
-
return text.replace(TOKEN_RE, () => "");
|
|
43
|
-
}
|
|
44
|
-
export function replaceToken(text, raw, replacement) {
|
|
45
|
-
return text.split(raw).join(replacement);
|
|
46
|
-
}
|
|
47
|
-
export function serializeToken(token) {
|
|
48
|
-
const parts = [token.tool, token.op, ...token.args];
|
|
49
|
-
const kwargs = Object.entries(token.kwargs)
|
|
50
|
-
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
51
|
-
.map(([k, v]) => `${k}=${v}`);
|
|
52
|
-
return `${MARKER_OPEN}${[...parts, ...kwargs].join(":")}${MARKER_CLOSE}`;
|
|
53
|
-
}
|
|
@@ -11,9 +11,6 @@ export function getMarker(name) {
|
|
|
11
11
|
export function allMarkers() {
|
|
12
12
|
return [...registry.values()];
|
|
13
13
|
}
|
|
14
|
-
export function lookupToken(name) {
|
|
15
|
-
return registry.get(name);
|
|
16
|
-
}
|
|
17
14
|
export function collectGuidance(disabled = new Set(), lang = "en") {
|
|
18
15
|
const out = [];
|
|
19
16
|
for (const m of allMarkers()) {
|
|
@@ -23,9 +23,6 @@ export function loadStateFromBranch(branch, namespace) {
|
|
|
23
23
|
}
|
|
24
24
|
return latest?.state;
|
|
25
25
|
}
|
|
26
|
-
export function hasStateInBranch(branch, namespace) {
|
|
27
|
-
return loadStateFromBranch(branch, namespace) !== undefined;
|
|
28
|
-
}
|
|
29
26
|
/** 追加快照:优先走 sessionManager.appendCustomEntry,否则回退到回调。 */
|
|
30
27
|
export function appendSnapshot(mgr, namespace, state, fallbackSave) {
|
|
31
28
|
const snapshot = {
|