@neus117/dsh-anchor 0.0.0-stage → 0.6.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/lib/form.js ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * dsh-anchor — 表单半边:工具 `anchor_form`。
3
+ *
4
+ * 三行(做什么 / 为什么 / 还没验证)由人在会话里填,写回**与注入同一份解析出来的**锚点文件;
5
+ * 第四栏「问我」不写进锚点,原样回到会话,由智能体按问题范围翻历史回答。
6
+ *
7
+ * 为什么必须在工具里问人:宿主根插件拿不到合法的 agent 载体(三种传参实测全失败),
8
+ * 工具执行上下文里的 `exec.agent` 才是。
9
+ *
10
+ * @module dsh-anchor/form
11
+ */
12
+
13
+
14
+ import { clip, isProtected, isVagueWhat, looksLikeEvidence, writeJudgment } from "./core.js";
15
+ import { anchorTargetFor } from "./inject.js";
16
+
17
+ const GOOD_EXAMPLE = "跑 test\\run.mjs 得到 19 passed";
18
+
19
+ /** 答案取值:自填优先,其次第一个选项,都没有算空。 */
20
+ export function pickAnswer(answer, id) {
21
+ const item = (answer?.answers || []).find((entry) => entry?.id === id);
22
+ if (!item) return "";
23
+ const custom = String(item.custom ?? "").trim();
24
+ if (custom) return custom;
25
+ const selected = Array.isArray(item.selected) ? item.selected : [];
26
+ return String(selected[0] ?? "").trim();
27
+ }
28
+
29
+ export const DEFAULT_INTRO =
30
+ "压缩后我看不到这段对话,只能看到这个锚点文件——这三行就是那时的全部依据,所以要写成脱离上下文也能读的样子。";
31
+
32
+ function blockText(content) {
33
+ if (typeof content === "string") return content;
34
+ if (!Array.isArray(content)) return "";
35
+ return content
36
+ .map((block) => (block && (block.type === undefined || block.type === "text") ? block.text || "" : ""))
37
+ .join(" ")
38
+ .trim();
39
+ }
40
+
41
+ /**
42
+ * 从本会话里取候选:最近两条真实用户原话、最近两条失败调用。顺序新→旧。
43
+ * 只从末尾往前扫,够数就停(大会话不整份分析)。
44
+ * 大会话只取尾部若干事件:`snapshotEvents(fromSeq)` 让内存占用有上限,
45
+ * 否则 5 万事件的会话每次弹表单都要复制整个数组。
46
+ */
47
+ export function sessionHints(ctx, agent) {
48
+ const out = { users: [], fails: [], events: 0 };
49
+ try {
50
+ const session = ctx.get?.("sessions")?.get?.(agent?.id);
51
+ if (!session || typeof session.snapshotEvents !== "function") return out;
52
+ const tail = 400;
53
+ const seq = Number(session.seq);
54
+ let events;
55
+ try {
56
+ events = Number.isFinite(seq) && seq > tail + 1 ? session.snapshotEvents(seq - tail) : session.snapshotEvents();
57
+ } catch {
58
+ events = session.snapshotEvents() || [];
59
+ }
60
+ if (!Array.isArray(events)) events = [];
61
+ out.events = Number.isFinite(seq) ? seq : events.length;
62
+ for (let i = events.length - 1; i >= 0 && (out.users.length < 2 || out.fails.length < 2); i -= 1) {
63
+ const event = events[i];
64
+ if (!event) continue;
65
+ if (event.type === "tool/result" && out.fails.length < 2) {
66
+ const failed =
67
+ event.data?.message?.isError === true || event.data?.isError === true || event.data?.error !== undefined;
68
+ const text = failed ? blockText(event.data?.message?.content) : "";
69
+ if (text) out.fails.push(text.replace(/\s+/g, " ").trim().slice(0, 160));
70
+ } else if (event.type === "user/message" && out.users.length < 2) {
71
+ // 宿主注入的 system-reminder 也是 user/message,按 source.kind 滤掉
72
+ if (event.data?.source?.kind !== "user") continue;
73
+ const text = blockText(event.data?.content);
74
+ if (text) out.users.push(text.replace(/\s+/g, " ").trim().slice(0, 160));
75
+ }
76
+ }
77
+ } catch {}
78
+ return out;
79
+ }
80
+
81
+ const WHAT_LABEL = /^刚才说的第\s*(\d+)\s*条$/;
82
+ const WHY_LABEL = /^上一轮报错\s*(\d+)$/;
83
+
84
+ /**
85
+ * 选项 label 只用来"指哪一条"(短,卡片上能整个看到);真正写进锚点的是被指到的原文。
86
+ */
87
+ export function resolveChoice(label, hints = {}) {
88
+ const text = String(label || "").trim();
89
+ const what = WHAT_LABEL.exec(text);
90
+ if (what) return hints.users?.[Number(what[1]) - 1] ?? text;
91
+ const why = WHY_LABEL.exec(text);
92
+ if (why) return hints.fails?.[Number(why[1]) - 1] ?? text;
93
+ return text;
94
+ }
95
+
96
+ /** 表单四题:三个写进锚点的空 + 一个可选提问。hints 用来做可点候选。 */
97
+ export function buildQuestions(hints = {}, options = {}) {
98
+ const intro = String(options.intro ?? DEFAULT_INTRO).trim();
99
+ const users = Array.isArray(hints.users) ? hints.users.slice(0, 2) : [];
100
+ const fails = Array.isArray(hints.fails) ? hints.fails.slice(0, 2) : [];
101
+ const whatOptions = users.map((text, index) => ({
102
+ label: `刚才说的第 ${index + 1} 条`,
103
+ description: `${clip(text, 80)}——选中后写完整原文`,
104
+ }));
105
+ const whyOptions = [
106
+ ...fails.map((text, index) => ({
107
+ label: `上一轮报错 ${index + 1}`,
108
+ description: `${clip(text, 80)}——选中后写完整原文`,
109
+ })),
110
+ ...users.slice(0, 1).map((text, index) => ({
111
+ label: `刚才说的第 ${index + 1} 条`,
112
+ description: `${clip(text, 80)}——用这句当判据,选中后写完整原文`,
113
+ })),
114
+ ];
115
+ return [
116
+ {
117
+ id: "anchor_what",
118
+ header: "做什么",
119
+ question: "下一步做什么?写成一个能照着做的动作",
120
+ detail:
121
+ (intro ? `${intro}\n\n` : "") +
122
+ "格式:动词 + 对象(改哪个文件 / 跑哪条命令 / 动哪一处)。\n" +
123
+ "合格:把 anchor_form 挂进 neus 预设的 plugins 并调一次验收\n" +
124
+ "不合格:继续推进 / 优化一下",
125
+ ...(whatOptions.length ? { options: whatOptions } : {}),
126
+ },
127
+ {
128
+ id: "anchor_why",
129
+ header: "为什么",
130
+ question: "为什么做这一步?给一条能核对的依据",
131
+ detail:
132
+ "依据 = 命令、读数、报错原文,或我说过的话。\n" +
133
+ `合格:${GOOD_EXAMPLE}\n` +
134
+ "不合格:这样更好 / 上一轮已验收",
135
+ ...(whyOptions.length ? { options: whyOptions } : {}),
136
+ },
137
+ {
138
+ id: "anchor_pending",
139
+ header: "还没验证",
140
+ question: "哪一条还没验证?没有就选「无」",
141
+ detail: "交付时这句会被原样照抄,所以宁可写「无」,也别写没核过的结论。",
142
+ options: [{ label: "无", description: "这一轮全部验证过了" }],
143
+ },
144
+ {
145
+ id: "anchor_back",
146
+ header: "问我",
147
+ question: "填表时哪里卡住了,或者想让我查点什么?没有就选「没问题」",
148
+ detail:
149
+ "写在这里的话会原样回到会话,我按问题的范围去翻聊天记录回答:问细节只看最近几轮,问「当初为什么」会把相关那一段翻出来。这一栏不写进锚点。",
150
+ options: [{ label: "没问题", description: "跳过,不用额外查询" }],
151
+ },
152
+ ];
153
+ }
154
+
155
+ const NO_QUESTION = /^(没有|没问题|无|不用|跳过|不需要|暂无)[。.!!]?$/;
156
+
157
+ function renderOutput(_args, value) {
158
+ const lines = [
159
+ `锚点已更新:${value.file}`,
160
+ `做什么:${value.what || "(空)"}`,
161
+ `为什么:${value.why || "(空)"}`,
162
+ `还没验证:${value.pending || "(空)"}`,
163
+ ];
164
+ if (value.status !== "written") lines.push(`注意:写入状态 ${value.status}${value.detail ? `(${value.detail})` : ""}`);
165
+ if (value.question) {
166
+ lines.push("", `用户提问:${value.question}`);
167
+ lines.push("先在回复里回答这个问题:按问题范围决定翻多少历史——问细节只看最近几轮,问「当初为什么」把相关那一段翻出来。");
168
+ }
169
+ return [{ type: "text", text: lines.join("\n") }];
170
+ }
171
+
172
+ /**
173
+ * 注册工具。由 index.js 调用;异常由调用方隔离。
174
+ * @param ctx - 宿主上下文
175
+ * @param config - 整个插件的 config
176
+ * @param trace - 日志函数
177
+ */
178
+ export function installForm(ctx, config, trace) {
179
+ const feature = config?.form && typeof config.form === "object" ? config.form : {};
180
+ const protectedFiles = Array.isArray(config?.["protectedFiles"]) ? config["protectedFiles"] : [];
181
+ const intro = feature?.["intro"] ?? config?.["intro"];
182
+
183
+ const tools = ctx.tools;
184
+ if (!tools || typeof tools.register !== "function") throw new Error("tools 服务不可用");
185
+ trace("form: tools 服务就绪");
186
+
187
+ const ask = (questions, exec) => ctx.userQuestions.ask({ agent: exec.agent, questions, signal: exec.signal });
188
+
189
+ tools.register({
190
+ name: "anchor_form", description:
191
+ "Open a form for the user: 做什么 / 为什么 / 还没验证 (written into the anchor file's 下一步 section) plus an optional question back to you. After the call, answer any question the user asked — read as much session history as that question needs.",
192
+ parameters: { type: "object", additionalProperties: false, properties: {} },
193
+ output: {
194
+ schema: {
195
+ type: "object",
196
+ additionalProperties: false,
197
+ required: ["file", "status"],
198
+ properties: {
199
+ file: { type: "string" },
200
+ status: { type: "string", enum: ["written", "no-change"] },
201
+ what: { type: "string" },
202
+ why: { type: "string" },
203
+ pending: { type: "string" },
204
+ question: { type: "string" },
205
+ sessionEvents: { type: "number" },
206
+ detail: { type: "string" },
207
+ },
208
+ },
209
+ render: renderOutput,
210
+ },
211
+ async execute(_args, exec) {
212
+ if (exec?.agent === undefined) throw new Error("anchor_form: 没有活的 agent 载体,无法问人");
213
+ // 和注入共用同一份解析(anchorFile → anchorDir/<cwd>/<会话短id>-anchor.md)。
214
+ // 文件不存在时它按 createIfMissing 复制模板,所以"某会话第一次填"也能直接用。
215
+ const anchorFile = anchorTargetFor(ctx, exec.agent.id, config, trace);
216
+ if (isProtected(anchorFile, protectedFiles)) {
217
+ throw new Error(`anchor_form: ${anchorFile} 在 protectedFiles 里,拒绝写入`);
218
+ }
219
+
220
+ const hints = sessionHints(ctx, exec.agent);
221
+ trace(`form: ask callId=${exec.callId} agent=${exec.agent?.id} file=${anchorFile} hints=${hints.users.length}u/${hints.fails.length}f/${hints.events}e`);
222
+
223
+ let answer;
224
+ try {
225
+ answer = await ask(buildQuestions(hints, { intro }), exec);
226
+ } catch (error) {
227
+ trace(`form: ask failed: ${error?.code || ""} ${error?.message || error}`);
228
+ throw error;
229
+ }
230
+
231
+ let what = resolveChoice(pickAnswer(answer, "anchor_what"), hints);
232
+ let why = resolveChoice(pickAnswer(answer, "anchor_why"), hints);
233
+ const pending = pickAnswer(answer, "anchor_pending");
234
+ const rawQuestion = pickAnswer(answer, "anchor_back");
235
+ const question = NO_QUESTION.test(rawQuestion.trim()) ? "" : rawQuestion;
236
+
237
+ if (isVagueWhat(what)) {
238
+ trace(`form: what too vague, re-asking: ${what}`);
239
+ try {
240
+ const retryAnswer = await ask([
241
+ {
242
+ id: "anchor_what_retry",
243
+ header: "做什么 · 打回",
244
+ question: `「${clip(what, 24)}」不算动作:没有对象,换个人不知道要动哪里。改成「动词 + 对象」再填一次。`,
245
+ detail:
246
+ "打回理由:只写了通用动词,压缩后的我看完仍不知道下一步动哪里。\n" +
247
+ "格式:动词 + 对象(改哪个文件 / 跑哪条命令 / 动哪一处)。\n" +
248
+ "合格例子:把 anchor_form 挂进 neus 预设的 plugins 并调一次验收",
249
+ },
250
+ ], exec);
251
+ const retry = resolveChoice(pickAnswer(retryAnswer, "anchor_what_retry"), hints);
252
+ trace(`form: what retry=${JSON.stringify(retry).slice(0, 60)}`);
253
+ if (retry && !isVagueWhat(retry)) what = retry;
254
+ } catch (error) {
255
+ trace(`form: what retry failed: ${error?.message || error}`);
256
+ }
257
+ }
258
+
259
+ if (why && why !== "未知" && !looksLikeEvidence(why)) {
260
+ trace(`form: why lacks evidence, re-asking: ${why}`);
261
+ try {
262
+ const retryAnswer = await ask([
263
+ {
264
+ id: "anchor_why_retry",
265
+ header: "为什么 · 打回",
266
+ question: `「${clip(why, 24)}」里没有可核对的依据。补一条命令 / 数字 / 报错原文,或直接写「未知」。`,
267
+ detail: `打回理由:这条没有判据,压缩后的我没法重跑核对。\n合格例子:${GOOD_EXAMPLE}`,
268
+ },
269
+ ], exec);
270
+ const retry = resolveChoice(pickAnswer(retryAnswer, "anchor_why_retry"), hints);
271
+ trace(`form: why retry=${JSON.stringify(retry).slice(0, 60)}`);
272
+ if (retry) why = retry;
273
+ } catch (error) {
274
+ trace(`form: why retry failed: ${error?.message || error}`);
275
+ }
276
+ }
277
+ if (why && why !== "未知" && !looksLikeEvidence(why)) why = `${why} [判据缺失]`;
278
+
279
+ trace(`form: answer what=${JSON.stringify(what).slice(0, 60)} why=${JSON.stringify(why).slice(0, 60)} pending=${JSON.stringify(pending).slice(0, 40)} question=${JSON.stringify(question).slice(0, 60)}`);
280
+
281
+ if (!what && !why) {
282
+ trace("form: skip 三行都空");
283
+ return {
284
+ file: anchorFile,
285
+ status: "no-change",
286
+ what: "",
287
+ why: "",
288
+ pending: "",
289
+ question,
290
+ sessionEvents: hints.events,
291
+ detail: "用户跳过了三行(没有作答)",
292
+ };
293
+ }
294
+
295
+ const judgment = { what: what || "未知", why: why || "未知", pending: pending || "无" };
296
+ const written = writeJudgment(anchorFile, judgment);
297
+ trace(`form: write status=${written.status} ${written.detail}`);
298
+ if (written.status === "failed") throw new Error(`anchor_form: ${written.detail}`);
299
+ return {
300
+ file: anchorFile,
301
+ status: written.status === "written" ? "written" : "no-change",
302
+ what: judgment.what,
303
+ why: judgment.why,
304
+ pending: judgment.pending,
305
+ question,
306
+ sessionEvents: hints.events,
307
+ detail: written.detail,
308
+ };
309
+ },
310
+ });
311
+ trace("form: anchor_form 已注册");
312
+ // register 没抛 ≠ 工具真的取得到:output.schema 的方言变了会被挡在注册里(我们已经踩过一次
313
+ // `required` 必须写成数组)。读回来一次,把"静默没挂上"变成一个可查的信号。
314
+ try {
315
+ const registered = tools.get?.("anchor_form");
316
+ trace(`form: verify=${registered ? "ok" : "missing"}`);
317
+ } catch (error) {
318
+ trace(`form: verify failed: ${error?.message || error}`);
319
+ }
320
+ }
package/lib/index.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * dsh-anchor — 锚点系统的唯一插件。
3
+ *
4
+ * 两个半边,同一个锚点文件:
5
+ * 注入(inject):会话出现 `compaction/end` 时,把锚点原文追加回上下文;
6
+ * 「下一步」三行还空着时,指路让智能体调 `anchor_form`。
7
+ * 表单(form): 工具 `anchor_form` 让**人在会话里**填三行,写回**同一份解析出来的**文件。
8
+ *
9
+ * 为什么合成一个包(2026-11-06):
10
+ * 两个包各有一份 `writeJudgment`,`$&` 那个写盘 bug 只长在其中一份里;而且两份 `anchorFile`
11
+ * 配置可以指向不同文件——"人填的"喂不到"压缩后读取的"。合并后写盘规则、路径解析、三行文案
12
+ * 都只有一份,配置也只有一份。
13
+ *
14
+ * 挂载纪律:
15
+ * 两半各自 try/catch 隔离——任何一边挂不上,另一边照常工作,宿主也不会被拖下水
16
+ * (预设里整棵树会因为一行 fiber 抛错而挂载失败,见 2026-11-06 事故记录)。
17
+ *
18
+ * @module dsh-anchor
19
+ */
20
+
21
+ import { appendFileSync, statSync, truncateSync } from "node:fs";
22
+
23
+ import { attachInject } from "./inject.js";
24
+ import { installForm } from "./form.js";
25
+
26
+ const TRACE_MAX_BYTES = 256 * 1024;
27
+
28
+ export const name = "dsh-anchor";
29
+ // ctx.tools / ctx.userQuestions / ctx.sessions 都需要声明 inject 才可读。
30
+ export const inject = ["tools", "userQuestions", "sessions"];
31
+
32
+ export function apply(ctx, config = {}) {
33
+ try {
34
+ mount(ctx, config);
35
+ } catch (error) {
36
+ try {
37
+ ctx?.logger?.warn?.(`dsh-anchor: 未挂载(${error?.message || error})`);
38
+ } catch {}
39
+ }
40
+ }
41
+
42
+ function mount(ctx, config) {
43
+ const traceFile = String(config?.["traceFile"] ?? "").trim();
44
+ // 探针文件会随每次压缩/每次表单调用增长;超过 256 KB 就在 apply 时清空。
45
+ // 只留"最近这一段"够排查了,不设上限等于留了个只会长大的文件。
46
+ if (traceFile) {
47
+ try {
48
+ if (statSync(traceFile).size > TRACE_MAX_BYTES) truncateSync(traceFile, 0);
49
+ } catch {}
50
+ }
51
+ const trace = (line) => {
52
+ if (!traceFile) return;
53
+ try {
54
+ appendFileSync(traceFile, `${new Date().toISOString()} ${line}\n`, "utf8");
55
+ } catch {}
56
+ };
57
+
58
+ const featureOf = (key) => (config?.[key] && typeof config[key] === "object" ? config[key] : {});
59
+ const injectEnabled = featureOf("inject").enabled !== false;
60
+ const formEnabled = featureOf("form").enabled !== false;
61
+
62
+ trace(`apply anchorFile=${String(config?.["anchorFile"] ?? "") || "(none)"} inject=${injectEnabled} form=${formEnabled}`);
63
+
64
+ if (injectEnabled) {
65
+ try {
66
+ attachInject(ctx, config, trace);
67
+ } catch (error) {
68
+ trace(`inject failed: ${error?.message || error}`);
69
+ try { ctx?.logger?.warn?.(`dsh-anchor: 注入半边未挂载(${error?.message || error})`); } catch {}
70
+ }
71
+ }
72
+
73
+ if (formEnabled) {
74
+ try {
75
+ installForm(ctx, config, trace);
76
+ } catch (error) {
77
+ trace(`form failed: ${error?.message || error}`);
78
+ try { ctx?.logger?.warn?.(`dsh-anchor: 表单半边未挂载(${error?.message || error})`); } catch {}
79
+ }
80
+ }
81
+ }
82
+
83
+ // 单测与外部脚本用:核心规则只此一份
84
+ export { clip, isProtected, isVagueWhat, judgmentEmpty, looksLikeEvidence, readText, writeJudgment } from "./core.js";
85
+ export { anchorTargetFor, loadAnchor, renderNotice, resolveAnchorPath } from "./inject.js";
86
+ export { buildQuestions, pickAnswer, resolveChoice, sessionHints } from "./form.js";