dsh-plan-completion 0.0.0-stage → 0.1.1

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/host/config.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * host/config.js — 插件 Host 侧默认参数(集中一处,便于 index.js 用插件 Config 覆盖)。
3
+ *
4
+ * 纯数据模块:零依赖、无副作用,Node 可直接 import。
5
+ * 所有数值均来自设计文档 v1.2 §3.5.4 / §3.5.5 / §3.8。
6
+ */
7
+
8
+ export const DEFAULTS = Object.freeze({
9
+ // ── 截止线与并发(§3.5.4)──────────────────────────────
10
+ /** 建议展示截止线:超过则本回合不弹建议,但计划继续落盘。 */
11
+ suggestionDeadlineMs: 2500,
12
+ /** 计划落盘截止线:超过则放弃本回合计划,保留 last-good。 */
13
+ planDeadlineMs: 6000,
14
+ /** 强制重写(/replan)截止线。 */
15
+ forceRewriteDeadlineMs: 8000,
16
+ /** 全局并发闸:跨会话同时 in-flight 的作业数上限(§3.5.2)。 */
17
+ globalConcurrency: 2,
18
+ /** 每会话每日模型调用上限;超出只走启发式(§3.5.5)。 */
19
+ dailyCallLimit: 300,
20
+ /** 连续失败次数达到该值熔断(§3.5.4)。 */
21
+ maxConsecutiveFailures: 3,
22
+ /** 熔断时长:5 分钟(§3.5.4)。 */
23
+ circuitBreakerMs: 5 * 60 * 1000,
24
+ /** 两次自动 rewrite 之间的最小回合间隔(§3.5.7 防抖)。 */
25
+ rewriteMinTurns: 3,
26
+ /** 两次自动 rewrite 之间的最小时间间隔(§3.5.7 防抖)。 */
27
+ rewriteMinIntervalMs: 5 * 60 * 1000,
28
+ /** JSON 非法后修复重发次数(§3.5.4:1 次)。 */
29
+ repairRetryMax: 1,
30
+ /** 幂等键 (sessionId, turn) 的去重窗口。 */
31
+ dedupeTtlMs: 10 * 60 * 1000,
32
+
33
+ // ── 计划数据约束(§3.4)────────────────────────────────
34
+ /** 步骤数下限 / 上限。 */
35
+ minSteps: 3,
36
+ maxSteps: 7,
37
+ /** 每个会话保留的历史版本数(§3.8 版本保留数)。 */
38
+ keepVersions: 5,
39
+ /** 每条步骤最多挂多少条证据。 */
40
+ maxEvidencePerStep: 8,
41
+ /** 步骤标题最大字符数(防御性截断)。 */
42
+ maxTitleChars: 120,
43
+ /** 单条证据最大字符数(防御性截断)。 */
44
+ maxEvidenceChars: 200,
45
+ /** 计划 intent 最大字符数。 */
46
+ maxIntentChars: 300,
47
+ /** 更新原因 reason 最大字符数。 */
48
+ maxReasonChars: 200,
49
+
50
+ // ── 建议文本(§3.6)────────────────────────────────────
51
+ /** suggestion 最大字符数:≤40 字、单行。 */
52
+ suggestionMaxChars: 40,
53
+ /** 备选建议最多条数。 */
54
+ alternatesMax: 3,
55
+
56
+ // ── 证据采集(§3.5.5 / task 验收)──────────────────────
57
+ /** 最后一条 assistant 文本截断长度。 */
58
+ assistantTextMaxChars: 1500,
59
+ /** 单条用户消息截断长度。 */
60
+ userMessageMaxChars: 500,
61
+ /** 证据里保留的最近消息条数(rewrite 用)。 */
62
+ recentMessagesMax: 6,
63
+ /** 最近消息单条截断长度。 */
64
+ recentMessageChars: 300,
65
+ /** 证据里最多保留的工具调用条数。 */
66
+ maxToolRecords: 20,
67
+
68
+ // ── 模型调用参数(§3.5.5;设计预算输出 <300 tokens,但实测 7 步 JSON 会截断,故上调)──
69
+ // 真机实测(Lead index.js,2026-11):rewrite 700 会被截断 → 至少 1200;patch 建议至少 800。
70
+ maxTokensPatch: 800,
71
+ maxTokensRewrite: 1200,
72
+ temperaturePatch: 0.2,
73
+ temperatureRewrite: 0.3,
74
+ });
75
+
76
+ /**
77
+ * 数字参数安全取值:非法/缺失时回落默认值并夹在给定区间内。
78
+ * @param {unknown} value
79
+ * @param {number} fallback
80
+ * @param {number} [min]
81
+ * @param {number} [max]
82
+ * @returns {number}
83
+ */
84
+ export function numberOption(value, fallback, min = -Infinity, max = Infinity) {
85
+ const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
86
+ return Math.min(max, Math.max(min, n));
87
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * host/evidence.js — 从 SessionEvent 数组采集「证据」(纯函数,零依赖)。
3
+ *
4
+ * 事件契约已按 probe-verify 的实测结果(`research/p0-contract.md` §9)对齐:
5
+ * SessionEvent = { type: string, seq: number, time: number, data: <按 type 映射>, ignorable? }
6
+ * turn/start data = { turn }
7
+ * turn/end data = { turn, reason: { kind, error? } }
8
+ * user/message data = UserMessage { role:'user', content: ContentBlock[] }
9
+ * assistant/message data = { turn, step, message: AssistantMessage { content: ContentBlock[] }, stream, usage? }
10
+ * tool/call data = { turn, step, callId, name, arguments: JSON string }
11
+ * tool/result data = { turn, step, message: ToolResultMessage { toolCallId, isError?, content }, error?: {name,code,reason?} }
12
+ * 文本只取 `type === 'text'` 的 block(reasoning / tool-call 等块不算正文)。
13
+ *
14
+ * 输出字段(与 index.js 接线约定一致):
15
+ * turn 本回合编号(number)
16
+ * lastAssistantText 最后一条 assistant 正文(截断 1500 字)
17
+ * toolCalls 本回合工具调用 [{name, ok, callId?, error?, path?}](失败结果按 callId 回填)
18
+ * failedTools 其中失败的项
19
+ * lastUserMessages 最近 2 条用户消息(string[],各截断 500 字)
20
+ * todoSteps 当前计划里未完成步骤 [{id,title,status}]
21
+ * 另附:sessionId、seq、watermark、intent、firstUserMessage、recentMessages、files、
22
+ * hasQuestion、turnEnded、turnEndReason、turnEndError、interrupted、plan、counts。
23
+ *
24
+ * 纯函数:不修改 events 数组、不修改数组元素、不修改传入的 plan。
25
+ */
26
+
27
+ import { DEFAULTS } from './config.js';
28
+ import { clampChars, looksLikeQuestion, oneLine, toText } from './text.js';
29
+
30
+ const TYPE_SETS = {
31
+ turnStart: new Set(['turn/start', 'turn.start', 'turn_start', 'turn/started']),
32
+ turnEnd: new Set(['turn/end', 'turn.end', 'turn_end', 'turn/ended', 'turn/complete', 'turn/completed']),
33
+ user: new Set(['user/message', 'user.message', 'user_message', 'message/user']),
34
+ assistant: new Set(['assistant/message', 'assistant.message', 'assistant_message', 'message/assistant']),
35
+ toolCall: new Set(['tool/call', 'tool.call', 'tool_call', 'tool/start', 'tool/begin']),
36
+ toolResult: new Set(['tool/result', 'tool.result', 'tool_result', 'tool/end', 'tool/done', 'tool/finish']),
37
+ };
38
+
39
+ const BLOCK_IGNORED_TYPES = new Set(['reasoning', 'image', 'file', 'tool-call', 'tool-addition', 'tool-removal']);
40
+
41
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
42
+
43
+ function typeOf(event) {
44
+ if (!isObj(event)) return '';
45
+ const raw = event.type ?? event.kind ?? event.event ?? event.name;
46
+ return toText(raw).toLowerCase();
47
+ }
48
+
49
+ function payloadOf(event) {
50
+ if (!isObj(event)) return {};
51
+ const candidates = [event.data, event.payload, event.detail, event.value];
52
+ for (const candidate of candidates) {
53
+ if (isObj(candidate)) return candidate;
54
+ }
55
+ return event;
56
+ }
57
+
58
+ function seqOf(event, index) {
59
+ const direct = event?.seq ?? event?.sequence ?? payloadOf(event).seq;
60
+ if (Number.isInteger(direct) && direct >= 0) return direct;
61
+ return index + 1;
62
+ }
63
+
64
+ function intOrNull(value) {
65
+ return Number.isInteger(value) && value >= 0 ? value : null;
66
+ }
67
+
68
+ /**
69
+ * 从消息节点里提取正文:只取 `type === 'text'` 的 ContentBlock。
70
+ * @param {unknown} node
71
+ * @param {number} [depth]
72
+ * @returns {string}
73
+ */
74
+ function extractText(node, depth = 0) {
75
+ if (depth > 6 || node == null) return '';
76
+ if (typeof node === 'string') return node;
77
+ if (typeof node === 'number') return String(node);
78
+ if (Array.isArray(node)) {
79
+ return node
80
+ .map((item) => extractText(item, depth + 1))
81
+ .filter(Boolean)
82
+ .join('');
83
+ }
84
+ if (!isObj(node)) return '';
85
+ const blockType = typeof node.type === 'string' ? node.type : '';
86
+ if (blockType) {
87
+ if (blockType === 'text') return typeof node.text === 'string' ? node.text : '';
88
+ if (BLOCK_IGNORED_TYPES.has(blockType)) return '';
89
+ }
90
+ if (typeof node.text === 'string') return node.text;
91
+ if (typeof node.value === 'string') return node.value;
92
+ if (typeof node.delta === 'string') return node.delta;
93
+ for (const key of ['content', 'message', 'parts', 'blocks', 'body', 'output']) {
94
+ if (node[key] != null) {
95
+ const text = extractText(node[key], depth + 1);
96
+ if (text) return text;
97
+ }
98
+ }
99
+ return '';
100
+ }
101
+
102
+ function messageOf(payload) {
103
+ const message = payload?.message ?? payload?.msg ?? payload?.content ?? payload;
104
+ const text = oneLine(extractText(message));
105
+ const roleRaw = payload?.role ?? message?.role ?? payload?.message?.role;
106
+ return { text, role: toText(roleRaw).toLowerCase(), message };
107
+ }
108
+
109
+ function errorTextOf(raw) {
110
+ if (raw == null) return '';
111
+ if (typeof raw === 'string') return oneLine(raw);
112
+ if (Array.isArray(raw)) {
113
+ return raw
114
+ .map((item) => errorTextOf(item))
115
+ .filter(Boolean)
116
+ .join('; ');
117
+ }
118
+ if (!isObj(raw)) return oneLine(String(raw));
119
+ const parts = [raw.message, raw.reason, raw.error?.message, raw.name, raw.code, raw.status]
120
+ .map((part) => toText(part))
121
+ .filter(Boolean);
122
+ if (parts.length) return oneLine(parts.join(' '));
123
+ return oneLine(extractText(raw));
124
+ }
125
+
126
+ function parseMaybeJson(value) {
127
+ if (typeof value !== 'string') return value;
128
+ const text = value.trim();
129
+ if (!text) return value;
130
+ if (!text.startsWith('{') && !text.startsWith('[')) return value;
131
+ try {
132
+ return JSON.parse(text);
133
+ } catch {
134
+ return value;
135
+ }
136
+ }
137
+
138
+ function inputPathOf(input) {
139
+ const parsed = parseMaybeJson(input);
140
+ if (!isObj(parsed)) return '';
141
+ for (const key of ['file_path', 'filePath', 'path', 'file', 'target', 'filename']) {
142
+ const value = parsed[key];
143
+ if (typeof value === 'string' && value.trim()) return value.trim();
144
+ }
145
+ return '';
146
+ }
147
+
148
+ function toolCallOf(payload) {
149
+ const call = isObj(payload?.call)
150
+ ? payload.call
151
+ : isObj(payload?.toolCall)
152
+ ? payload.toolCall
153
+ : isObj(payload?.tool)
154
+ ? payload.tool
155
+ : payload;
156
+ const name =
157
+ toText(call?.name ?? call?.toolName ?? call?.tool_name ?? payload?.name ?? payload?.toolName ?? '') || '(unknown)';
158
+ const callId =
159
+ toText(
160
+ call?.callId ??
161
+ call?.call_id ??
162
+ call?.id ??
163
+ call?.toolCallId ??
164
+ payload?.callId ??
165
+ payload?.call_id ??
166
+ payload?.toolCallId ??
167
+ payload?.id ??
168
+ '',
169
+ ) || '';
170
+ const input = parseMaybeJson(
171
+ call?.input ?? call?.args ?? call?.arguments ?? payload?.input ?? payload?.args ?? payload?.arguments ?? null,
172
+ );
173
+ return { name, callId, input };
174
+ }
175
+
176
+ function isErrorResult(payload) {
177
+ if (!isObj(payload)) return false;
178
+ const message = payload.message;
179
+ if (isObj(message) && message.isError === true) return true;
180
+ if (payload.isError === true || payload.is_error === true) return true;
181
+ if (payload.ok === false || payload.success === false || payload.failed === true) return true;
182
+ const status = toText(payload.status).toLowerCase();
183
+ if (status === 'error' || status === 'failed' || status === 'failure') return true;
184
+ return payload.error != null;
185
+ }
186
+
187
+ function resultErrorText(payload) {
188
+ const message = isObj(payload?.message) ? payload.message : null;
189
+ if (message && message.isError === true) {
190
+ const fromBlock = oneLine(extractText(message.content));
191
+ if (fromBlock) return fromBlock;
192
+ }
193
+ const raw = payload?.error ?? payload?.result?.error ?? payload?.output?.error ?? message?.error ?? null;
194
+ const fromError = errorTextOf(raw);
195
+ if (fromError) return fromError;
196
+ return oneLine(extractText(message?.content));
197
+ }
198
+
199
+ function todoStepsOf(plan) {
200
+ if (!isObj(plan) || !Array.isArray(plan.steps)) return [];
201
+ return plan.steps
202
+ .filter((step) => isObj(step) && step.status !== 'done' && step.status !== 'dropped')
203
+ .map((step) => ({ id: toText(step.id), title: toText(step.title), status: toText(step.status) || 'todo' }));
204
+ }
205
+
206
+ function emptyEvidence(error) {
207
+ return {
208
+ ok: false,
209
+ error,
210
+ sessionId: '',
211
+ turn: 0,
212
+ seq: 0,
213
+ watermark: { turn: 0, seq: 0 },
214
+ intent: '',
215
+ plan: null,
216
+ lastAssistantText: '',
217
+ toolCalls: [],
218
+ failedTools: [],
219
+ lastUserMessages: [],
220
+ firstUserMessage: '',
221
+ recentMessages: [],
222
+ todoSteps: [],
223
+ files: [],
224
+ hasQuestion: false,
225
+ turnEnded: false,
226
+ turnEndReason: '',
227
+ turnEndError: '',
228
+ interrupted: false,
229
+ counts: { events: 0, toolCalls: 0, failedTools: 0, messages: 0, userMessages: 0 },
230
+ };
231
+ }
232
+
233
+ function messageRecord(payload) {
234
+ return messageOf(payload);
235
+ }
236
+
237
+ /**
238
+ * 从 SessionEvent 数组采集证据。
239
+ *
240
+ * @param {{events?: unknown, plan?: object|null, turn?: number, sessionId?: string}} params
241
+ * @returns {object} 证据对象(见文件头注释;`ok:false` 表示入参不是事件数组)
242
+ */
243
+ export function collectFromEvents(params = {}) {
244
+ try {
245
+ const events = params?.events;
246
+ if (!Array.isArray(events)) return emptyEvidence('events 必须是数组');
247
+ const basePlan = isObj(params?.plan) ? params.plan : null;
248
+
249
+ // ── 1. 水位:全量事件的最大 seq;回合:最后一个 turn/start 之后 ──────
250
+ let seq = 0;
251
+ let lastTurnStartIndex = -1;
252
+ let turnFromEvents = null;
253
+ let turnEnded = false;
254
+ let turnEndReason = '';
255
+ let turnEndError = '';
256
+ for (let i = 0; i < events.length; i += 1) {
257
+ const event = events[i];
258
+ const type = typeOf(event);
259
+ const eventSeq = seqOf(event, i);
260
+ if (eventSeq > seq) seq = eventSeq;
261
+ if (TYPE_SETS.turnStart.has(type)) {
262
+ lastTurnStartIndex = i;
263
+ const data = payloadOf(event);
264
+ turnFromEvents = intOrNull(data.turn) ?? turnFromEvents;
265
+ turnEnded = false;
266
+ turnEndReason = '';
267
+ turnEndError = '';
268
+ } else if (TYPE_SETS.turnEnd.has(type)) {
269
+ turnEnded = true;
270
+ const data = payloadOf(event);
271
+ turnFromEvents = intOrNull(data.turn) ?? turnFromEvents;
272
+ const reason = isObj(data.reason) ? data.reason : null;
273
+ turnEndReason = toText(reason?.kind) || toText(data.reason);
274
+ turnEndError = errorTextOf(reason?.error ?? data.error);
275
+ }
276
+ }
277
+ const scopeStart = lastTurnStartIndex >= 0 ? lastTurnStartIndex : 0;
278
+ const explicitTurn = intOrNull(params?.turn);
279
+ const turn = explicitTurn ?? turnFromEvents ?? 0;
280
+
281
+ // ── 2. 消息:全量扫描(最近 2 条用户消息可能跨回合)──────────────
282
+ let lastAssistantText = '';
283
+ let firstUserMessage = '';
284
+ const userTexts = [];
285
+ const recentMessages = [];
286
+
287
+ for (let i = 0; i < events.length; i += 1) {
288
+ const event = events[i];
289
+ const type = typeOf(event);
290
+ const payload = payloadOf(event);
291
+ const isUser = TYPE_SETS.user.has(type) || (!TYPE_SETS.assistant.has(type) && messageRecord(payload).role === 'user');
292
+ const isAssistant = TYPE_SETS.assistant.has(type) || messageRecord(payload).role === 'assistant';
293
+ if (!isUser && !isAssistant) continue;
294
+ const record = messageRecord(payload);
295
+ if (!record.text) continue;
296
+ if (isUser) {
297
+ const text = clampChars(record.text, DEFAULTS.userMessageMaxChars);
298
+ userTexts.push(text);
299
+ if (!firstUserMessage) firstUserMessage = text;
300
+ recentMessages.push({ role: 'user', text: clampChars(record.text, DEFAULTS.recentMessageChars) });
301
+ } else {
302
+ lastAssistantText = clampChars(record.text, DEFAULTS.assistantTextMaxChars);
303
+ recentMessages.push({ role: 'assistant', text: clampChars(record.text, DEFAULTS.recentMessageChars) });
304
+ }
305
+ }
306
+
307
+ // ── 3. 工具调用:只取本回合(last turn/start 之后)──────────────
308
+ const toolCalls = [];
309
+ const callIndex = new Map();
310
+ for (let i = scopeStart; i < events.length; i += 1) {
311
+ const event = events[i];
312
+ const type = typeOf(event);
313
+ const payload = payloadOf(event);
314
+ if (TYPE_SETS.toolCall.has(type)) {
315
+ const call = toolCallOf(payload);
316
+ const record = { name: call.name, ok: true };
317
+ if (call.callId) record.callId = call.callId;
318
+ const path = inputPathOf(call.input);
319
+ if (path) record.path = path;
320
+ toolCalls.push(record);
321
+ if (call.callId) callIndex.set(call.callId, toolCalls.length - 1);
322
+ continue;
323
+ }
324
+ if (TYPE_SETS.toolResult.has(type)) {
325
+ const message = isObj(payload?.message) ? payload.message : null;
326
+ const callId = toText(
327
+ payload?.callId ??
328
+ payload?.call_id ??
329
+ payload?.toolCallId ??
330
+ payload?.id ??
331
+ message?.toolCallId ??
332
+ message?.source?.callId ??
333
+ '',
334
+ );
335
+ const ok = !isErrorResult(payload);
336
+ let record = callId ? toolCalls[callIndex.get(callId)] : undefined;
337
+ if (!record) {
338
+ const fallbackName = toText(payload?.name ?? payload?.toolName) || toolCallOf(payload).name;
339
+ record = { name: fallbackName && fallbackName !== '(unknown)' ? fallbackName : 'tool', ok: true };
340
+ toolCalls.push(record);
341
+ if (callId) record.callId = callId;
342
+ }
343
+ record.ok = ok;
344
+ if (!ok) record.error = resultErrorText(payload) || '工具执行失败';
345
+ }
346
+ }
347
+
348
+ // ── 4. 汇总 ────────────────────────────────────────────────
349
+ const failedTools = toolCalls.filter((call) => call.ok === false);
350
+ const dedupedUserTexts = [];
351
+ for (const text of userTexts) {
352
+ if (dedupedUserTexts[dedupedUserTexts.length - 1] !== text) dedupedUserTexts.push(text);
353
+ }
354
+ const lastUserMessages = dedupedUserTexts.slice(-2);
355
+ const plan = basePlan;
356
+ const files = [...new Set(toolCalls.filter((call) => call.path).map((call) => call.path))].slice(-DEFAULTS.maxToolRecords);
357
+ const intent = isObj(plan) && typeof plan.intent === 'string' && plan.intent ? plan.intent : firstUserMessage;
358
+
359
+ return {
360
+ ok: true,
361
+ error: '',
362
+ sessionId: toText(params?.sessionId) || (isObj(plan) ? toText(plan.sessionId) : ''),
363
+ turn,
364
+ seq,
365
+ watermark: { turn, seq },
366
+ intent: clampChars(intent, DEFAULTS.maxIntentChars),
367
+ plan,
368
+ lastAssistantText,
369
+ toolCalls: toolCalls.slice(0, DEFAULTS.maxToolRecords),
370
+ failedTools,
371
+ lastUserMessages,
372
+ firstUserMessage,
373
+ recentMessages: recentMessages.slice(-DEFAULTS.recentMessagesMax),
374
+ todoSteps: todoStepsOf(plan),
375
+ files,
376
+ hasQuestion: looksLikeQuestion(lastAssistantText),
377
+ turnEnded,
378
+ turnEndReason,
379
+ turnEndError,
380
+ interrupted: turnEndReason === 'interrupted' || turnEndReason === 'aborted' || turnEndReason === 'error',
381
+ counts: {
382
+ events: events.length,
383
+ toolCalls: toolCalls.length,
384
+ failedTools: failedTools.length,
385
+ messages: recentMessages.length,
386
+ userMessages: dedupedUserTexts.length,
387
+ },
388
+ };
389
+ } catch (err) {
390
+ return emptyEvidence(`证据采集异常:${err && err.message ? err.message : String(err)}`);
391
+ }
392
+ }