@winmatrix/supervisor 1.0.5
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/package.json +24 -0
- package/src/blocked-args-filter.mjs +95 -0
- package/src/claude-session-lib.mjs +487 -0
- package/src/deliverable-tracker.mjs +119 -0
- package/src/engine-driver-config.mjs +131 -0
- package/src/env-sanitizer.mjs +112 -0
- package/src/resume-degradation.mjs +117 -0
- package/src/run-agent-engine.mjs +1580 -0
- package/src/run-engine.mjs +192 -0
- package/src/token-usage.mjs +147 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@winmatrix/supervisor",
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/run-agent-engine.mjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"run-agent-engine": "./src/run-agent-engine.mjs",
|
|
8
|
+
"run-engine": "./src/run-engine.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "node -e \"console.log('no build step')\"",
|
|
15
|
+
"test": "node --test tests/**/*.test.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@winmatrix/agent-sdk": "^1.0.5",
|
|
19
|
+
"@winmatrix/protocol": "^1.0.5"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D11.3: Blocked Args 过滤
|
|
3
|
+
*
|
|
4
|
+
* 用户或业务方可能通过 customArgs 传入 CLI 参数。
|
|
5
|
+
* 每种 PodEngineDriver 声明受协议保护的 blockedArgs,
|
|
6
|
+
* 在 launch 阶段从用户 customArgs 中移除匹配项并记入审计日志。
|
|
7
|
+
*
|
|
8
|
+
* 参考 Multica 的 filterCustomArgs() 设计,但增强为:
|
|
9
|
+
* - 被过滤参数不进入 fingerprint
|
|
10
|
+
* - 记入操作审计日志
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getEngineDriverConfig } from './engine-driver-config.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从用户 customArgs 中过滤 Engine 协议保护的参数
|
|
17
|
+
*
|
|
18
|
+
* @param {string} engineId - Engine 标识
|
|
19
|
+
* @param {string[]} customArgs - 用户传入的自定义参数
|
|
20
|
+
* @returns {{ filtered: string[], removed: string[] }}
|
|
21
|
+
* filtered: 过滤后的安全参数列表
|
|
22
|
+
* removed: 被移除的参数列表(用于审计)
|
|
23
|
+
*/
|
|
24
|
+
export function filterBlockedArgs(engineId, customArgs) {
|
|
25
|
+
if (!customArgs || !Array.isArray(customArgs) || customArgs.length === 0) {
|
|
26
|
+
return { filtered: [], removed: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = getEngineDriverConfig(engineId);
|
|
30
|
+
const blockedArgs = config.blockedArgs;
|
|
31
|
+
|
|
32
|
+
if (blockedArgs.length === 0) {
|
|
33
|
+
return { filtered: [...customArgs], removed: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const filtered = [];
|
|
37
|
+
const removed = [];
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < customArgs.length; i++) {
|
|
40
|
+
const arg = customArgs[i];
|
|
41
|
+
|
|
42
|
+
// 检查是否匹配 blocked arg
|
|
43
|
+
const matched = blockedArgs.some(blocked => {
|
|
44
|
+
// 精确匹配(如 --output-format)
|
|
45
|
+
if (arg === blocked) return true;
|
|
46
|
+
// --output-format=value 形式
|
|
47
|
+
if (arg.startsWith(blocked + '=')) return true;
|
|
48
|
+
return false;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (matched) {
|
|
52
|
+
removed.push(arg);
|
|
53
|
+
// 检查这个参数是否需要下一个参数作为值
|
|
54
|
+
// 例如 --output-format stream-json,下一个 arg 是值
|
|
55
|
+
if (!arg.includes('=') && isValueArg(arg) && i + 1 < customArgs.length) {
|
|
56
|
+
i++;
|
|
57
|
+
removed.push(customArgs[i]);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
filtered.push(arg);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { filtered, removed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 判断一个 CLI flag 是否需要后续值参数
|
|
69
|
+
* 大多数 --xxx 形式的 flag 都需要值
|
|
70
|
+
* @param {string} arg
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function isValueArg(arg) {
|
|
74
|
+
// 布尔 flag 不需要值
|
|
75
|
+
const booleanFlags = new Set([
|
|
76
|
+
'--verbose',
|
|
77
|
+
'--print',
|
|
78
|
+
'-p',
|
|
79
|
+
'--strict-mcp-config',
|
|
80
|
+
'--include-partial-messages',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return !booleanFlags.has(arg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 格式化被过滤参数为审计日志消息
|
|
88
|
+
* @param {string[]} removed - 被移除的参数
|
|
89
|
+
* @param {string} engineId
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function formatBlockedArgsAudit(removed, engineId) {
|
|
93
|
+
if (removed.length === 0) return '';
|
|
94
|
+
return `[blocked-args] engine=${engineId} filtered ${removed.length} args: ${removed.map(a => JSON.stringify(a)).join(', ')}`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Session 查询公共库(task 7.15)。
|
|
3
|
+
*
|
|
4
|
+
* 从 query-claude-sessions.mjs / query-claude-session-detail.mjs 抽出的纯函数,
|
|
5
|
+
* 供 run-agent-engine.mjs 的 session-* 子命令与(迁移过渡期)旧脚本共用。
|
|
6
|
+
*
|
|
7
|
+
* 设计约束:
|
|
8
|
+
* - 全部为纯函数(除 loadSdk / resolveDirViaGlobalListSessions 读 SDK / FS);
|
|
9
|
+
* - 不输出 stdout、不读 process.argv——由调用方负责 argv 解析与输出;
|
|
10
|
+
* - 脱敏:所有 raw/preview 输出经 redactPreviewText / jsonReplacer 过滤敏感值。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
export const TRUNCATE_LEN = 4096;
|
|
18
|
+
export const REDACTED = '[REDACTED]';
|
|
19
|
+
export const SENSITIVE_KEY_PATTERN =
|
|
20
|
+
/(authorization|password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|session[_-]?key|jwt)/i;
|
|
21
|
+
|
|
22
|
+
/** SDK 加载路径:优先 CLAUDE_AGENT_SDK_PATH,回退默认全局路径。 */
|
|
23
|
+
export function resolveSdkPath(env = process.env) {
|
|
24
|
+
return (
|
|
25
|
+
env.CLAUDE_AGENT_SDK_PATH ||
|
|
26
|
+
'/home/node/.local/lib/node_modules/@anthropic-ai/claude-agent-sdk'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 加载 @anthropic-ai/claude-agent-sdk。失败返回 null(由调用方决定输出 error 形态)。
|
|
32
|
+
* @returns {{ sdk: object | null; error?: string }}
|
|
33
|
+
*/
|
|
34
|
+
export function loadSdk() {
|
|
35
|
+
const sdkPath = resolveSdkPath();
|
|
36
|
+
try {
|
|
37
|
+
return { sdk: require(sdkPath) };
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { sdk: null, error: `SDK 加载失败: ${e.message}` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── argv 解析(与旧脚本等价) ── */
|
|
44
|
+
|
|
45
|
+
/** @param {string[]} argv @param {string} key */
|
|
46
|
+
export function getOptValue(argv, key) {
|
|
47
|
+
const eqPrefix = key + '=';
|
|
48
|
+
for (let i = 2; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === key && argv[i + 1] != null) return argv[i + 1];
|
|
51
|
+
if (a.startsWith(eqPrefix)) return a.slice(eqPrefix.length);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string[]} argv @param {string} flag */
|
|
57
|
+
export function hasFlag(argv, flag) {
|
|
58
|
+
return argv.includes(flag);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} v @param {number} def @param {number} min @param {number} max */
|
|
62
|
+
export function clampInt(v, def, min, max) {
|
|
63
|
+
const n = Number(v);
|
|
64
|
+
if (!Number.isFinite(n)) return def;
|
|
65
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string[]} argv @param {number} defaultVal @param {number} maxVal */
|
|
69
|
+
export function parseLimit(argv, defaultVal, maxVal) {
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
const a = argv[i];
|
|
72
|
+
if (a.startsWith('--limit=')) {
|
|
73
|
+
const n = Number(a.slice('--limit='.length));
|
|
74
|
+
return Math.min(maxVal, Math.max(1, Number.isFinite(n) ? n : defaultVal));
|
|
75
|
+
}
|
|
76
|
+
if (a === '--limit' && argv[i + 1] != null) {
|
|
77
|
+
const n = Number(argv[i + 1]);
|
|
78
|
+
return Math.min(maxVal, Math.max(1, Number.isFinite(n) ? n : defaultVal));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Math.min(maxVal, defaultVal);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ── cursor 编解码(offset → 不透明 cursor,base64) ── */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 将 offset 编码为不透明 cursor(base64(JSON))。offset 默认 0 不产生 cursor。
|
|
88
|
+
* @param {number} offset
|
|
89
|
+
* @returns {string | undefined}
|
|
90
|
+
*/
|
|
91
|
+
export function encodeOffsetCursor(offset) {
|
|
92
|
+
if (!Number.isInteger(offset) || offset <= 0) return undefined;
|
|
93
|
+
return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解码不透明 cursor 为 offset。非法/空 cursor 返回 0。
|
|
98
|
+
* @param {string | undefined} cursor
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
export function decodeOffsetCursor(cursor) {
|
|
102
|
+
if (!cursor || typeof cursor !== 'string') return 0;
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
|
|
105
|
+
if (parsed && typeof parsed.offset === 'number' && Number.isFinite(parsed.offset)) {
|
|
106
|
+
return Math.max(0, Math.trunc(parsed.offset));
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// 非法 cursor 静默回退到 0
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── 时间 / session 元数据 ── */
|
|
115
|
+
|
|
116
|
+
/** @param {unknown} v */
|
|
117
|
+
export function toIsoString(v) {
|
|
118
|
+
if (v == null) return undefined;
|
|
119
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
120
|
+
const d = new Date(v);
|
|
121
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
122
|
+
}
|
|
123
|
+
if (typeof v === 'string') {
|
|
124
|
+
const d = new Date(v);
|
|
125
|
+
return Number.isNaN(d.getTime()) ? v : d.toISOString();
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {Record<string, unknown>} session */
|
|
131
|
+
export function lastActivityMs(session) {
|
|
132
|
+
const keys = [
|
|
133
|
+
'lastModified',
|
|
134
|
+
'last_modified',
|
|
135
|
+
'lastActivityAt',
|
|
136
|
+
'last_activity_at',
|
|
137
|
+
'updatedAt',
|
|
138
|
+
'updated_at',
|
|
139
|
+
'createdAt',
|
|
140
|
+
'created_at',
|
|
141
|
+
];
|
|
142
|
+
for (const k of keys) {
|
|
143
|
+
const c = session[k];
|
|
144
|
+
if (typeof c === 'number' && Number.isFinite(c)) return c;
|
|
145
|
+
if (typeof c === 'string') {
|
|
146
|
+
const t = Date.parse(c);
|
|
147
|
+
if (!Number.isNaN(t)) return t;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 从宽松 SDK session 记录构建结构化 SessionMeta(id/cwd/projectPath/...)。
|
|
155
|
+
* list 与 detail 共用;detail 额外带 fileSize。
|
|
156
|
+
* @param {Record<string, unknown>} session
|
|
157
|
+
*/
|
|
158
|
+
export function buildSessionMeta(session) {
|
|
159
|
+
const rawPath = session.projectPath ?? session.cwd;
|
|
160
|
+
const pathStr = rawPath != null ? String(rawPath).replace(/\/+$/, '') : undefined;
|
|
161
|
+
const cwdStr = session.cwd != null ? String(session.cwd).replace(/\/+$/, '') : pathStr;
|
|
162
|
+
|
|
163
|
+
const lastActivityAt =
|
|
164
|
+
toIsoString(session.lastActivityAt ?? session.last_activity_at) ??
|
|
165
|
+
toIsoString(session.updatedAt ?? session.updated_at) ??
|
|
166
|
+
toIsoString(session.lastModified ?? session.last_modified) ??
|
|
167
|
+
toIsoString(session.createdAt ?? session.created_at);
|
|
168
|
+
|
|
169
|
+
const createdAt = toIsoString(session.createdAt ?? session.created_at);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
id: session.id ?? session.sessionId,
|
|
173
|
+
cwd: cwdStr,
|
|
174
|
+
projectPath: pathStr,
|
|
175
|
+
projectName:
|
|
176
|
+
session.projectName != null
|
|
177
|
+
? String(session.projectName)
|
|
178
|
+
: pathStr
|
|
179
|
+
? pathStr.split('/').filter(Boolean).pop()
|
|
180
|
+
: undefined,
|
|
181
|
+
summary: session.summary != null ? String(session.summary) : undefined,
|
|
182
|
+
customTitle:
|
|
183
|
+
session.customTitle != null
|
|
184
|
+
? String(session.customTitle)
|
|
185
|
+
: session.custom_title != null
|
|
186
|
+
? String(session.custom_title)
|
|
187
|
+
: undefined,
|
|
188
|
+
firstPrompt:
|
|
189
|
+
session.firstPrompt != null
|
|
190
|
+
? String(session.firstPrompt)
|
|
191
|
+
: session.first_prompt != null
|
|
192
|
+
? String(session.first_prompt)
|
|
193
|
+
: undefined,
|
|
194
|
+
createdAt,
|
|
195
|
+
lastActivityAt,
|
|
196
|
+
messageCount:
|
|
197
|
+
typeof session.messageCount === 'number'
|
|
198
|
+
? session.messageCount
|
|
199
|
+
: typeof session.numMessages === 'number'
|
|
200
|
+
? session.numMessages
|
|
201
|
+
: undefined,
|
|
202
|
+
status: session.status != null ? String(session.status) : undefined,
|
|
203
|
+
gitBranch:
|
|
204
|
+
session.gitBranch != null
|
|
205
|
+
? String(session.gitBranch)
|
|
206
|
+
: session.git_branch != null
|
|
207
|
+
? String(session.git_branch)
|
|
208
|
+
: undefined,
|
|
209
|
+
tag: session.tag != null ? String(session.tag) : undefined,
|
|
210
|
+
fileSize:
|
|
211
|
+
typeof session.fileSize === 'number'
|
|
212
|
+
? session.fileSize
|
|
213
|
+
: typeof session.file_size === 'number'
|
|
214
|
+
? session.file_size
|
|
215
|
+
: undefined,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── 消息抽取 / 脱敏 / diagnostics ── */
|
|
220
|
+
|
|
221
|
+
/** 从 Claude API message.content 抽取纯文本块拼接。兼容字符串/数组两种形态。 */
|
|
222
|
+
export function extractText(message) {
|
|
223
|
+
if (message == null) return '';
|
|
224
|
+
/** @type {unknown} */
|
|
225
|
+
let content = message;
|
|
226
|
+
if (typeof message === 'object' && message !== null && 'content' in message) {
|
|
227
|
+
content = /** @type {Record<string, unknown>} */ (message).content;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof content === 'string') return content;
|
|
231
|
+
if (!Array.isArray(content)) return '';
|
|
232
|
+
|
|
233
|
+
const parts = [];
|
|
234
|
+
for (const block of content) {
|
|
235
|
+
if (block == null) continue;
|
|
236
|
+
if (typeof block === 'string') {
|
|
237
|
+
parts.push(block);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (typeof block !== 'object') continue;
|
|
241
|
+
const b = /** @type {Record<string, unknown>} */ (block);
|
|
242
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
243
|
+
parts.push(b.text);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return parts.join('');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {unknown} message @returns {unknown[]} */
|
|
250
|
+
export function extractContentBlocks(message) {
|
|
251
|
+
if (message == null) return [];
|
|
252
|
+
let content = message;
|
|
253
|
+
if (typeof message === 'object' && message !== null && 'content' in message) {
|
|
254
|
+
content = /** @type {Record<string, unknown>} */ (message).content;
|
|
255
|
+
}
|
|
256
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
257
|
+
if (!Array.isArray(content)) return [];
|
|
258
|
+
return content;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** JSON replacer:bigint 转字符串,敏感 key redact。 */
|
|
262
|
+
export function jsonReplacer(_k, v) {
|
|
263
|
+
if (typeof v === 'bigint') return v.toString();
|
|
264
|
+
if (SENSITIVE_KEY_PATTERN.test(String(_k))) return REDACTED;
|
|
265
|
+
return v;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @param {unknown} messages @returns {{ list: unknown[]; ok: boolean }} */
|
|
269
|
+
export function cloneMessagesForJson(messages) {
|
|
270
|
+
try {
|
|
271
|
+
return { list: JSON.parse(JSON.stringify(messages, jsonReplacer)), ok: true };
|
|
272
|
+
} catch {
|
|
273
|
+
return { list: [], ok: false };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @param {ReturnType<typeof buildSessionMeta>} sessionMeta @param {string} sessionIdArg */
|
|
278
|
+
export function buildCliReplayGuide(sessionMeta, sessionIdArg, env = process.env) {
|
|
279
|
+
const agentHome = env.HOME?.trim() || '';
|
|
280
|
+
const projectPath =
|
|
281
|
+
(sessionMeta.projectPath && String(sessionMeta.projectPath)) ||
|
|
282
|
+
(sessionMeta.cwd && String(sessionMeta.cwd)) ||
|
|
283
|
+
'';
|
|
284
|
+
const sessionId = sessionMeta.id != null ? String(sessionMeta.id) : sessionIdArg;
|
|
285
|
+
const kubectlHome = agentHome || '<agentHome>';
|
|
286
|
+
const stepsZh = [
|
|
287
|
+
'使用与运行 agent 相同的 Linux 用户进入 Pod。',
|
|
288
|
+
agentHome
|
|
289
|
+
? `export HOME="${agentHome}"`
|
|
290
|
+
: '在容器内执行 printenv HOME,再 export HOME=该值(须与查询此会话时一致)。',
|
|
291
|
+
`cd "${projectPath || '(见 projectPath)'}"`,
|
|
292
|
+
'运行 claude,应能看到该工程下的会话记录。',
|
|
293
|
+
];
|
|
294
|
+
return {
|
|
295
|
+
agentHome: agentHome || undefined,
|
|
296
|
+
projectPath: projectPath || undefined,
|
|
297
|
+
sessionId,
|
|
298
|
+
stepsZh,
|
|
299
|
+
kubectlTemplate: `kubectl exec -it <pod> -n <ns> -- env HOME=${kubectlHome} bash -l`,
|
|
300
|
+
noteZh:
|
|
301
|
+
'Claude 会话数据位于 $HOME/.claude;数字人隔离时 HOME 常非 /home/node。HOME 或工程路径不一致则 CLI 看不到本会话。',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @param {string} s @param {boolean} noTruncate */
|
|
306
|
+
export function maybeTruncate(s, noTruncate) {
|
|
307
|
+
if (noTruncate || s.length <= TRUNCATE_LEN) return { text: s, truncated: false };
|
|
308
|
+
return {
|
|
309
|
+
text: s.slice(0, TRUNCATE_LEN) + `\n…[truncated ${s.length - TRUNCATE_LEN} chars]`,
|
|
310
|
+
truncated: true,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @param {unknown} value @param {number} max */
|
|
315
|
+
export function previewValue(value, max = 1000) {
|
|
316
|
+
let text = '';
|
|
317
|
+
if (typeof value === 'string') text = value;
|
|
318
|
+
else {
|
|
319
|
+
try {
|
|
320
|
+
text = JSON.stringify(value, jsonReplacer);
|
|
321
|
+
} catch {
|
|
322
|
+
text = String(value);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
text = redactPreviewText(text);
|
|
326
|
+
return text.length > max ? text.slice(0, max) + `…[${text.length - max} chars]` : text;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @param {string} text */
|
|
330
|
+
export function redactPreviewText(text) {
|
|
331
|
+
return text
|
|
332
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, 'Bearer [REDACTED]')
|
|
333
|
+
.replace(/\b(?:eyJ[A-Za-z0-9_-]{10,})\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, REDACTED)
|
|
334
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, REDACTED)
|
|
335
|
+
.replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, REDACTED)
|
|
336
|
+
.replace(
|
|
337
|
+
/((?:authorization|password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|session[_-]?key|jwt)\s*[:=]\s*)(["']?)[^\s"',}\]]+/gi,
|
|
338
|
+
`$1$2${REDACTED}`,
|
|
339
|
+
)
|
|
340
|
+
.replace(
|
|
341
|
+
/((?:postgres|postgresql|mysql|redis|mongodb):\/\/[^:\s/@]+:)([^@\s]+)(@)/gi,
|
|
342
|
+
`$1${REDACTED}$3`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @param {string} text */
|
|
347
|
+
export function extractFileReferences(text) {
|
|
348
|
+
const matches = text.match(/(?:\/[^\s"'`<>|]+(?:\/[^\s"'`<>|]+)+|[A-Za-z]:\\[^\s"'`<>|]+)/g) ?? [];
|
|
349
|
+
return matches
|
|
350
|
+
.map((v) => v.replace(/[),.;,。;]+$/g, ''))
|
|
351
|
+
.filter((v) => v.length >= 4);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 构建 diagnostics 富字段(messageStats / latestAssistantText / assistantTail /
|
|
356
|
+
* toolCalls / toolResults / fileReferences / timeline)。供 detail metadata 透传。
|
|
357
|
+
* @param {unknown[]} messages
|
|
358
|
+
* @param {boolean} noTruncate
|
|
359
|
+
*/
|
|
360
|
+
export function buildDiagnostics(messages, noTruncate) {
|
|
361
|
+
const stats = {
|
|
362
|
+
total: messages.length,
|
|
363
|
+
user: 0,
|
|
364
|
+
assistant: 0,
|
|
365
|
+
system: 0,
|
|
366
|
+
toolUse: 0,
|
|
367
|
+
toolResult: 0,
|
|
368
|
+
errorToolResult: 0,
|
|
369
|
+
};
|
|
370
|
+
const toolCalls = [];
|
|
371
|
+
const toolResults = [];
|
|
372
|
+
const timeline = [];
|
|
373
|
+
const assistantTail = [];
|
|
374
|
+
const fileRefs = new Set();
|
|
375
|
+
let latestAssistantText = '';
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
378
|
+
const m = messages[i];
|
|
379
|
+
if (m == null || typeof m !== 'object') continue;
|
|
380
|
+
const rec = /** @type {Record<string, unknown>} */ (m);
|
|
381
|
+
const message = rec.message;
|
|
382
|
+
const messageRole =
|
|
383
|
+
message && typeof message === 'object' && 'role' in message
|
|
384
|
+
? String(/** @type {Record<string, unknown>} */ (message).role ?? '')
|
|
385
|
+
: '';
|
|
386
|
+
const role = String(rec.type ?? messageRole ?? 'unknown');
|
|
387
|
+
if (role === 'user') stats.user += 1;
|
|
388
|
+
else if (role === 'assistant') stats.assistant += 1;
|
|
389
|
+
else if (role === 'system') stats.system += 1;
|
|
390
|
+
|
|
391
|
+
const text = extractText(message);
|
|
392
|
+
if (text) {
|
|
393
|
+
for (const p of extractFileReferences(text)) fileRefs.add(p);
|
|
394
|
+
if (role === 'assistant') {
|
|
395
|
+
latestAssistantText = text;
|
|
396
|
+
assistantTail.push(previewValue(text, 1200));
|
|
397
|
+
if (assistantTail.length > 5) assistantTail.shift();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let messageToolUse = 0;
|
|
402
|
+
let messageToolResult = 0;
|
|
403
|
+
for (const block of extractContentBlocks(message)) {
|
|
404
|
+
if (block == null || typeof block !== 'object') continue;
|
|
405
|
+
const b = /** @type {Record<string, unknown>} */ (block);
|
|
406
|
+
if (b.type === 'tool_use') {
|
|
407
|
+
stats.toolUse += 1;
|
|
408
|
+
messageToolUse += 1;
|
|
409
|
+
if (toolCalls.length < 80) {
|
|
410
|
+
toolCalls.push({
|
|
411
|
+
index: i,
|
|
412
|
+
id: b.id != null ? String(b.id) : undefined,
|
|
413
|
+
name: b.name != null ? String(b.name) : undefined,
|
|
414
|
+
inputPreview: previewValue(b.input, 1200),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
for (const p of extractFileReferences(previewValue(b.input, 2000))) fileRefs.add(p);
|
|
418
|
+
} else if (b.type === 'tool_result') {
|
|
419
|
+
stats.toolResult += 1;
|
|
420
|
+
messageToolResult += 1;
|
|
421
|
+
const isError = b.is_error === true || b.isError === true;
|
|
422
|
+
if (isError) stats.errorToolResult += 1;
|
|
423
|
+
const preview = previewValue(b.content, 1400);
|
|
424
|
+
if (toolResults.length < 80 && (isError || toolResults.length < 20)) {
|
|
425
|
+
toolResults.push({
|
|
426
|
+
index: i,
|
|
427
|
+
toolUseId: b.tool_use_id != null ? String(b.tool_use_id) : undefined,
|
|
428
|
+
isError,
|
|
429
|
+
preview,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
for (const p of extractFileReferences(preview)) fileRefs.add(p);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (timeline.length < 120) {
|
|
437
|
+
timeline.push({
|
|
438
|
+
index: i,
|
|
439
|
+
role,
|
|
440
|
+
textPreview: text ? previewValue(text, noTruncate ? 2000 : 500) : undefined,
|
|
441
|
+
toolUseCount: messageToolUse || undefined,
|
|
442
|
+
toolResultCount: messageToolResult || undefined,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
messageStats: stats,
|
|
449
|
+
latestAssistantText: previewValue(
|
|
450
|
+
maybeTruncate(latestAssistantText, noTruncate).text,
|
|
451
|
+
noTruncate ? 8000 : TRUNCATE_LEN,
|
|
452
|
+
),
|
|
453
|
+
assistantTail,
|
|
454
|
+
toolCalls,
|
|
455
|
+
toolResults,
|
|
456
|
+
fileReferences: Array.from(fileRefs).slice(0, 80),
|
|
457
|
+
timeline,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* SDK:listSessions() 不传 dir 时会列举所有项目。可在 session 元数据路径「差一点」时
|
|
463
|
+
* 用其全局列表里的 cwd 重试加载消息。
|
|
464
|
+
* @param {object} sdkMod
|
|
465
|
+
* @param {string} sid
|
|
466
|
+
* @returns {Promise<string | undefined>}
|
|
467
|
+
*/
|
|
468
|
+
export async function resolveDirViaGlobalListSessions(sdkMod, sid) {
|
|
469
|
+
if (typeof sdkMod.listSessions !== 'function') return undefined;
|
|
470
|
+
try {
|
|
471
|
+
const list = await sdkMod.listSessions({ limit: 800 });
|
|
472
|
+
if (!Array.isArray(list)) return undefined;
|
|
473
|
+
for (const entry of list) {
|
|
474
|
+
if (entry == null || typeof entry !== 'object') continue;
|
|
475
|
+
const rec = /** @type {Record<string, unknown>} */ (entry);
|
|
476
|
+
const eid = rec.sessionId ?? rec.id;
|
|
477
|
+
if (eid == null || String(eid) !== String(sid)) continue;
|
|
478
|
+
const cwd = rec.cwd;
|
|
479
|
+
if (cwd != null && String(cwd).trim() !== '') {
|
|
480
|
+
return String(cwd).replace(/\/+$/, '');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|