dsh-expert-group 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/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Inspector sidecar writer/reader。
|
|
3
|
+
* 只读 session.jsonl,完整 prompt 写入受控 UTF-8 分块;manifest 不保存 raw event 或 prompt。
|
|
4
|
+
*/
|
|
5
|
+
import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
buildAgentTree,
|
|
11
|
+
chunkUtf8,
|
|
12
|
+
classifyTruncation,
|
|
13
|
+
extractPromptRecords,
|
|
14
|
+
hashUtf8,
|
|
15
|
+
parseJsonlEvents,
|
|
16
|
+
reassembleUtf8Chunks,
|
|
17
|
+
utf8Length,
|
|
18
|
+
} from "./prompt-inspector.js";
|
|
19
|
+
|
|
20
|
+
export const SIDECAR_FORMAT = "dsh-prompt-sidecar-v1";
|
|
21
|
+
export const DEFAULT_MAX_BYTES = 1800;
|
|
22
|
+
const MANIFEST_NAME = "manifest.json";
|
|
23
|
+
const SAFE_FILE_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
24
|
+
const SENSITIVE_KEY = /(?:authorization|cookie|password|secret|credential|encrypted|cipher|private|api.?key|access.?token|refresh.?token)/i;
|
|
25
|
+
const SENSITIVE_VALUE = /(?:authorization|cookie|password|secret|credential|encrypted|cipher|private|bearer|basic\s+|access.?token|refresh.?token|api.?key|\btoken\b|\bkey\b)/i;
|
|
26
|
+
const SAFE_DIAGNOSTIC_STRING = /^[^\u0000-\u001f\u007f]{1,256}$/u;
|
|
27
|
+
const FATAL_UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
28
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
29
|
+
const MAX_MANIFEST_STRING_LENGTH = 256;
|
|
30
|
+
|
|
31
|
+
function asError(error, prefix) {
|
|
32
|
+
if (error instanceof Error) return new Error(prefix + ": " + error.message, { cause: error });
|
|
33
|
+
return new Error(prefix + ": " + String(error));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureRelativeSafePath(root, candidate) {
|
|
37
|
+
if (typeof candidate !== "string" || candidate.length === 0 || isAbsolute(candidate)) {
|
|
38
|
+
throw new Error("manifest 含有无效相对路径");
|
|
39
|
+
}
|
|
40
|
+
const target = resolve(root, candidate);
|
|
41
|
+
const rootPath = resolve(root) + sep;
|
|
42
|
+
if (target !== resolve(root) && !target.startsWith(rootPath)) throw new Error("manifest 路径越界");
|
|
43
|
+
if (relative(root, target).split(sep).some((part) => part === "..")) throw new Error("manifest 路径含有 ..");
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeComparablePath(path) {
|
|
48
|
+
const normalized = resolve(path).replace(/[\\/]+/g, "\\");
|
|
49
|
+
return normalized.replace(/\\+$/, "").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pathsOverlap(left, right) {
|
|
53
|
+
const a = normalizeComparablePath(left);
|
|
54
|
+
const b = normalizeComparablePath(right);
|
|
55
|
+
return a === b || a.startsWith(b + "\\") || b.startsWith(a + "\\");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function pathKind(path) {
|
|
59
|
+
try {
|
|
60
|
+
const info = await lstat(path);
|
|
61
|
+
if (info.isSymbolicLink()) return "symlink";
|
|
62
|
+
if (info.isDirectory()) return "directory";
|
|
63
|
+
if (info.isFile()) return "file";
|
|
64
|
+
return "other";
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === "ENOENT") return "missing";
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pathExists(path) {
|
|
72
|
+
try {
|
|
73
|
+
await access(path, constants.F_OK);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function rejectSymlinkPath(path, label) {
|
|
81
|
+
let current = resolve(path);
|
|
82
|
+
while (true) {
|
|
83
|
+
let info;
|
|
84
|
+
try {
|
|
85
|
+
info = await lstat(current);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code !== "ENOENT") throw error;
|
|
88
|
+
info = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (info?.isSymbolicLink()) throw new Error(label + " 含有 symlink/junction: " + current);
|
|
91
|
+
const parent = dirname(current);
|
|
92
|
+
if (parent === current) break;
|
|
93
|
+
current = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function rejectSymlinkTree(path, label) {
|
|
98
|
+
await rejectSymlinkPath(path, label);
|
|
99
|
+
let rootInfo;
|
|
100
|
+
try {
|
|
101
|
+
rootInfo = await lstat(path);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code === "ENOENT") return;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (!rootInfo.isDirectory()) return;
|
|
107
|
+
const walk = async (directory) => {
|
|
108
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const child = join(directory, entry.name);
|
|
111
|
+
if (entry.isSymbolicLink()) throw new Error(label + " 含有 symlink/junction: " + child);
|
|
112
|
+
if (entry.isDirectory()) await walk(child);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await walk(resolve(path));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function canonicalComparablePath(path) {
|
|
119
|
+
let current = resolve(path);
|
|
120
|
+
const missing = [];
|
|
121
|
+
while (true) {
|
|
122
|
+
try {
|
|
123
|
+
const canonical = await realpath(current);
|
|
124
|
+
return normalizeComparablePath(join(canonical, ...missing.reverse()));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code !== "ENOENT") throw error;
|
|
127
|
+
const parent = dirname(current);
|
|
128
|
+
if (parent === current) return normalizeComparablePath(path);
|
|
129
|
+
missing.push(basename(current));
|
|
130
|
+
current = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function rejectOverlappingPaths(inputPath, outputPath) {
|
|
136
|
+
// 先检查现有树及所有祖先;输出临时目录随后只会创建在已验证的父目录中。
|
|
137
|
+
await rejectSymlinkTree(inputPath, "输入路径");
|
|
138
|
+
await rejectSymlinkTree(outputPath, "输出路径");
|
|
139
|
+
if ((await pathKind(outputPath)) !== "missing" && (await pathKind(outputPath)) !== "directory") {
|
|
140
|
+
throw new Error("输出路径必须是目录");
|
|
141
|
+
}
|
|
142
|
+
const [inputCanonical, outputCanonical] = await Promise.all([
|
|
143
|
+
canonicalComparablePath(inputPath),
|
|
144
|
+
canonicalComparablePath(outputPath),
|
|
145
|
+
]);
|
|
146
|
+
if (pathsOverlap(inputCanonical, outputCanonical)) {
|
|
147
|
+
throw new Error("输入与输出路径重叠,已拒绝以保护原始 session/sidecar");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveSidecarPaths(sidecarDirOrManifest) {
|
|
152
|
+
const candidate = resolve(sidecarDirOrManifest);
|
|
153
|
+
// lstat 之前先检查 candidate 本身和所有祖先,避免 stat 跟随 root/manifest symlink。
|
|
154
|
+
await rejectSymlinkPath(candidate, "sidecar 根");
|
|
155
|
+
let info;
|
|
156
|
+
try {
|
|
157
|
+
info = await lstat(candidate);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error?.code !== "ENOENT") throw error;
|
|
160
|
+
}
|
|
161
|
+
const root = info?.isDirectory() === true ? candidate : dirname(candidate);
|
|
162
|
+
const manifestPath = info?.isDirectory() === true ? join(candidate, MANIFEST_NAME) : candidate;
|
|
163
|
+
await rejectSymlinkPath(root, "sidecar 根");
|
|
164
|
+
await rejectSymlinkPath(manifestPath, "sidecar manifest");
|
|
165
|
+
return { root, manifestPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function safeSourceFile(root, file) {
|
|
169
|
+
const value = relative(root, file).split(sep).join("/");
|
|
170
|
+
assertSafeRelativeMetadataPath(value, "sourceFile");
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertSafeRelativeMetadataPath(value, label) {
|
|
175
|
+
if (typeof value !== "string" || value.length === 0 || isAbsolute(value)
|
|
176
|
+
|| /^[a-zA-Z]:[\\/]/.test(value) || value.includes("\\")
|
|
177
|
+
|| value.split("/").some((part) => part === ".." || part.length === 0)) {
|
|
178
|
+
throw new Error(label + " 不是规范相对路径");
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function collectSessionFiles(inputPath) {
|
|
184
|
+
const input = resolve(inputPath);
|
|
185
|
+
const inputStat = await lstat(input);
|
|
186
|
+
if (inputStat.isSymbolicLink()) throw new Error("输入路径含有 symlink/junction: " + input);
|
|
187
|
+
if (inputStat.isFile()) {
|
|
188
|
+
return { root: dirname(input), files: [input] };
|
|
189
|
+
}
|
|
190
|
+
if (!inputStat.isDirectory()) throw new Error("输入路径不是文件或目录: " + input);
|
|
191
|
+
const files = [];
|
|
192
|
+
const walk = async (directory) => {
|
|
193
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
194
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (entry.name === "." || entry.name === "..") continue;
|
|
197
|
+
const child = join(directory, entry.name);
|
|
198
|
+
if (entry.isSymbolicLink()) throw new Error("输入树含有 symlink/junction: " + child);
|
|
199
|
+
if (entry.isDirectory()) await walk(child);
|
|
200
|
+
else if (entry.isFile() && entry.name === "session.jsonl") files.push(child);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
await walk(input);
|
|
204
|
+
files.sort((left, right) => relative(input, left).localeCompare(relative(input, right)));
|
|
205
|
+
return { root: input, files };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stableRecordId(record, sourceFile, occurrence) {
|
|
209
|
+
const seed = [sourceFile, record.kind, record.sessionId ?? "", record.lineNumber ?? "", occurrence, hashUtf8(record.text)].join("\0");
|
|
210
|
+
return hashUtf8(seed).slice(0, 24);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function safeManifestValue(value) {
|
|
214
|
+
if (typeof value === "string") {
|
|
215
|
+
return SAFE_DIAGNOSTIC_STRING.test(value) && !SENSITIVE_VALUE.test(value) ? value : undefined;
|
|
216
|
+
}
|
|
217
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
218
|
+
if (typeof value === "boolean") return value;
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function safeManifestSeq(value) {
|
|
223
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function safeManifestTime(value) {
|
|
227
|
+
return typeof value === "string" && SAFE_DIAGNOSTIC_STRING.test(value) && !SENSITIVE_VALUE.test(value)
|
|
228
|
+
? value
|
|
229
|
+
: undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function safeManifestMetadata(metadata) {
|
|
233
|
+
const result = {};
|
|
234
|
+
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return result;
|
|
235
|
+
// 仅保留明确的模型与 lineage 诊断字段,且限制内容长度、控制字符和敏感值。
|
|
236
|
+
const allowed = new Set([
|
|
237
|
+
"provider", "model", "reasoningEffort", "maxTokens", "temperature", "topP",
|
|
238
|
+
"sessionId", "parentSession", "origin", "delegationDepth", "status",
|
|
239
|
+
]);
|
|
240
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
241
|
+
if (!allowed.has(key) || SENSITIVE_KEY.test(key)) continue;
|
|
242
|
+
const safeValue = safeManifestValue(value);
|
|
243
|
+
if (safeValue !== undefined) result[key] = safeValue;
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function publicTree(tree) {
|
|
249
|
+
const flatNode = (node) => ({
|
|
250
|
+
sessionId: safeManifestValue(node.sessionId),
|
|
251
|
+
parentSession: safeManifestValue(node.parentSession),
|
|
252
|
+
origin: safeManifestValue(node.origin),
|
|
253
|
+
delegationDepth: safeManifestValue(node.delegationDepth),
|
|
254
|
+
dangling: node.dangling,
|
|
255
|
+
external: node.external,
|
|
256
|
+
parentConflict: node.parentConflict,
|
|
257
|
+
cycle: node.cycle,
|
|
258
|
+
corruptLineage: node.corruptLineage,
|
|
259
|
+
recordIds: node.records.map((record) => record.recordId).filter(Boolean),
|
|
260
|
+
});
|
|
261
|
+
const visit = (node) => ({
|
|
262
|
+
...flatNode(node),
|
|
263
|
+
children: node.children.map(visit),
|
|
264
|
+
});
|
|
265
|
+
return {
|
|
266
|
+
roots: tree.roots.map(visit),
|
|
267
|
+
dangling: tree.dangling.map(visit),
|
|
268
|
+
external: tree.external.map(visit),
|
|
269
|
+
cycles: tree.cycles.map((cycle) => cycle.map(flatNode)),
|
|
270
|
+
corruptLineage: tree.corruptLineage.map(flatNode),
|
|
271
|
+
// 平铺完整节点集合,保证 cycle 或其下游节点不会因树不可递归而静默丢失。
|
|
272
|
+
nodes: tree.nodes.map(flatNode),
|
|
273
|
+
unattachedRecordCount: tree.unattachedRecords.length,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function replaceDirectory(tempDir, outputDir) {
|
|
278
|
+
const backup = outputDir + ".old-" + randomUUID();
|
|
279
|
+
const hadOld = await pathExists(outputDir);
|
|
280
|
+
if (hadOld) await rename(outputDir, backup);
|
|
281
|
+
try {
|
|
282
|
+
await rename(tempDir, outputDir);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (hadOld && !(await pathExists(outputDir)) && (await pathExists(backup))) {
|
|
285
|
+
try {
|
|
286
|
+
await rename(backup, outputDir);
|
|
287
|
+
} catch (restoreError) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"sidecar 原子替换失败,恢复旧输出也失败;backup 保留于 " + backup,
|
|
290
|
+
{ cause: new AggregateError([error, restoreError], "替换/恢复均失败") },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
if (hadOld) {
|
|
297
|
+
try {
|
|
298
|
+
await rm(backup, { recursive: true, force: false });
|
|
299
|
+
} catch (cleanupError) {
|
|
300
|
+
throw new Error("sidecar 已替换,但旧输出 backup 清理失败,路径: " + backup, { cause: cleanupError });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeOptions(options = {}) {
|
|
306
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
307
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("maxBytes 必须是大于 0 的安全整数");
|
|
308
|
+
return { maxBytes };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 解析单个 session.jsonl 或目录下所有 session.jsonl,原子写入 sidecar 目录。
|
|
313
|
+
*/
|
|
314
|
+
export async function writePromptSidecar(inputPathOrDir, outputDir, options = {}) {
|
|
315
|
+
if (typeof inputPathOrDir !== "string" || inputPathOrDir.length === 0) throw new TypeError("必须提供输入路径");
|
|
316
|
+
if (typeof outputDir !== "string" || outputDir.length === 0) throw new TypeError("必须提供输出目录");
|
|
317
|
+
const { maxBytes } = normalizeOptions(options);
|
|
318
|
+
// 必须在扫描输入和创建父目录前拒绝相等、祖先/后代和 symlink/junction 路径。
|
|
319
|
+
const inputPath = resolve(inputPathOrDir);
|
|
320
|
+
const output = resolve(outputDir);
|
|
321
|
+
await rejectOverlappingPaths(inputPath, output);
|
|
322
|
+
const { root, files } = await collectSessionFiles(inputPath);
|
|
323
|
+
if (files.length === 0) throw new Error("未找到 session.jsonl: " + inputPath);
|
|
324
|
+
await mkdir(dirname(output), { recursive: true });
|
|
325
|
+
const temp = output + ".tmp-" + randomUUID();
|
|
326
|
+
const records = [];
|
|
327
|
+
const sourceFiles = [];
|
|
328
|
+
let totalChunks = 0;
|
|
329
|
+
try {
|
|
330
|
+
await mkdir(temp, { recursive: true });
|
|
331
|
+
for (const file of files) {
|
|
332
|
+
const sourceFile = safeSourceFile(root, file);
|
|
333
|
+
sourceFiles.push(sourceFile);
|
|
334
|
+
const input = await readFile(file);
|
|
335
|
+
const events = parseJsonlEvents(input);
|
|
336
|
+
const parseErrors = events.filter((event) => event.parseError);
|
|
337
|
+
if (parseErrors.length > 0) {
|
|
338
|
+
throw new Error(sourceFile + " 包含 " + parseErrors.length + " 个 JSONL 解析失败行(首个: " + parseErrors[0].lineNumber + ")");
|
|
339
|
+
}
|
|
340
|
+
const extracted = extractPromptRecords(events, { sourceFile });
|
|
341
|
+
for (const record of extracted) records.push(record);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tree = buildAgentTree(records);
|
|
345
|
+
const usedIds = new Set();
|
|
346
|
+
for (let occurrence = 0; occurrence < records.length; occurrence += 1) {
|
|
347
|
+
const record = records[occurrence];
|
|
348
|
+
let recordId = stableRecordId(record, record.sourceFile ?? "", occurrence);
|
|
349
|
+
while (usedIds.has(recordId)) recordId = hashUtf8(recordId + "\0" + occurrence).slice(0, 24);
|
|
350
|
+
usedIds.add(recordId);
|
|
351
|
+
record.recordId = recordId;
|
|
352
|
+
const chunks = chunkUtf8(record.text, maxBytes);
|
|
353
|
+
const chunkDirectory = join("records", recordId);
|
|
354
|
+
const chunkAbsoluteDirectory = join(temp, chunkDirectory);
|
|
355
|
+
await mkdir(chunkAbsoluteDirectory, { recursive: true });
|
|
356
|
+
const manifestChunks = [];
|
|
357
|
+
for (const chunk of chunks) {
|
|
358
|
+
const fileName = String(chunk.index).padStart(6, "0") + ".txt";
|
|
359
|
+
const relativePath = join(chunkDirectory, fileName).split(sep).join("/");
|
|
360
|
+
const absolutePath = join(temp, chunkDirectory, fileName);
|
|
361
|
+
await writeFile(absolutePath, chunk.text, { encoding: "utf8", flag: "wx" });
|
|
362
|
+
manifestChunks.push({
|
|
363
|
+
index: chunk.index,
|
|
364
|
+
byteOffset: chunk.byteOffset,
|
|
365
|
+
byteLength: chunk.byteLength,
|
|
366
|
+
path: relativePath,
|
|
367
|
+
});
|
|
368
|
+
totalChunks += 1;
|
|
369
|
+
}
|
|
370
|
+
record.sidecar = {
|
|
371
|
+
recordId,
|
|
372
|
+
chunkDir: chunkDirectory.split(sep).join("/"),
|
|
373
|
+
chunkCount: chunks.length,
|
|
374
|
+
chunks: manifestChunks,
|
|
375
|
+
charLength: record.text.length,
|
|
376
|
+
utf8ByteLength: utf8Length(record.text),
|
|
377
|
+
sha256: hashUtf8(record.text),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const manifest = {
|
|
382
|
+
format: SIDECAR_FORMAT,
|
|
383
|
+
createdAt: new Date().toISOString(),
|
|
384
|
+
maxBytes,
|
|
385
|
+
sourceFiles,
|
|
386
|
+
sessionIds: [...new Set(records.map((record) => safeManifestValue(record.sessionId)).filter((id) => id !== undefined))],
|
|
387
|
+
agentTree: publicTree(tree),
|
|
388
|
+
records: records.map((record) => ({
|
|
389
|
+
recordId: record.recordId,
|
|
390
|
+
kind: record.kind,
|
|
391
|
+
sourceFile: record.sourceFile,
|
|
392
|
+
lineNumber: record.lineNumber,
|
|
393
|
+
// 这是原始 JSONL 行的定位信息,不是 chunk 的 byteOffset/byteLength。
|
|
394
|
+
byteOffset: record.byteOffset,
|
|
395
|
+
byteLength: record.byteLength,
|
|
396
|
+
seq: safeManifestSeq(record.seq),
|
|
397
|
+
time: safeManifestTime(record.time),
|
|
398
|
+
sessionId: safeManifestValue(record.sessionId),
|
|
399
|
+
parentSession: safeManifestValue(record.parentSession),
|
|
400
|
+
origin: safeManifestValue(record.origin),
|
|
401
|
+
delegationDepth: safeManifestValue(record.delegationDepth),
|
|
402
|
+
metadata: safeManifestMetadata(record.metadata),
|
|
403
|
+
truncation: (() => {
|
|
404
|
+
const classification = classifyTruncation(record);
|
|
405
|
+
return { kind: classification.kind, truncated: classification.truncated };
|
|
406
|
+
})(),
|
|
407
|
+
charLength: record.sidecar.charLength,
|
|
408
|
+
utf8ByteLength: record.sidecar.utf8ByteLength,
|
|
409
|
+
sha256: record.sidecar.sha256,
|
|
410
|
+
chunkCount: record.sidecar.chunkCount,
|
|
411
|
+
chunkDir: record.sidecar.chunkDir,
|
|
412
|
+
chunks: record.sidecar.chunks,
|
|
413
|
+
})),
|
|
414
|
+
summary: {
|
|
415
|
+
fileCount: files.length,
|
|
416
|
+
recordCount: records.length,
|
|
417
|
+
chunkCount: totalChunks,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
await writeFile(join(temp, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
|
|
421
|
+
const verification = await verifyPromptSidecar(temp);
|
|
422
|
+
if (!verification.ok) throw new Error("生成的 sidecar 校验失败: " + verification.errors.join(";"));
|
|
423
|
+
await replaceDirectory(temp, output);
|
|
424
|
+
return {
|
|
425
|
+
format: SIDECAR_FORMAT,
|
|
426
|
+
sourceFiles: files.length,
|
|
427
|
+
recordCount: records.length,
|
|
428
|
+
chunkCount: totalChunks,
|
|
429
|
+
outputDir: output,
|
|
430
|
+
verification,
|
|
431
|
+
};
|
|
432
|
+
} catch (error) {
|
|
433
|
+
let cleanupError;
|
|
434
|
+
try {
|
|
435
|
+
await rm(temp, { recursive: true, force: true });
|
|
436
|
+
} catch (caughtCleanupError) {
|
|
437
|
+
cleanupError = caughtCleanupError;
|
|
438
|
+
}
|
|
439
|
+
if (cleanupError !== undefined) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
"写入 Prompt sidecar 失败;临时目录清理失败,仍需人工处理: " + temp,
|
|
442
|
+
{ cause: new AggregateError([error, cleanupError], "sidecar 写入和清理均失败") },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
throw asError(error, "写入 Prompt sidecar 失败");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function assertManifestInteger(value, label) {
|
|
450
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(label + " 必须是非负安全整数");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function assertManifestTime(value, label) {
|
|
454
|
+
if (typeof value !== "string" || value.length > MAX_MANIFEST_STRING_LENGTH
|
|
455
|
+
|| /[\u0000-\u001f\u007f]/u.test(value) || SENSITIVE_VALUE.test(value)) {
|
|
456
|
+
throw new Error(label + " 必须是安全时间字符串");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function assertManifestSummary(manifest) {
|
|
461
|
+
if (!manifest.summary || typeof manifest.summary !== "object" || Array.isArray(manifest.summary)) {
|
|
462
|
+
throw new Error("manifest.summary 不是对象");
|
|
463
|
+
}
|
|
464
|
+
assertManifestInteger(manifest.summary.fileCount, "summary.fileCount");
|
|
465
|
+
assertManifestInteger(manifest.summary.recordCount, "summary.recordCount");
|
|
466
|
+
assertManifestInteger(manifest.summary.chunkCount, "summary.chunkCount");
|
|
467
|
+
if (manifest.summary.fileCount !== manifest.sourceFiles.length) throw new Error("summary.fileCount 与 sourceFiles.length 不一致");
|
|
468
|
+
if (manifest.summary.recordCount !== manifest.records.length) throw new Error("summary.recordCount 与 records.length 不一致");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function validateManifestShape(manifest) {
|
|
472
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest 不是对象");
|
|
473
|
+
if (manifest.format !== SIDECAR_FORMAT) throw new Error("不支持的 sidecar manifest format");
|
|
474
|
+
if (!Array.isArray(manifest.sourceFiles)) throw new Error("manifest.sourceFiles 不是数组");
|
|
475
|
+
for (const sourceFile of manifest.sourceFiles) assertSafeRelativeMetadataPath(sourceFile, "sourceFile");
|
|
476
|
+
if (!Array.isArray(manifest.records)) throw new Error("manifest.records 不是数组");
|
|
477
|
+
for (const [index, record] of manifest.records.entries()) {
|
|
478
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) {
|
|
479
|
+
throw new Error("record[" + index + "] 不是对象");
|
|
480
|
+
}
|
|
481
|
+
if (typeof record.recordId !== "string" || record.recordId.length === 0 || !SAFE_FILE_NAME.test(record.recordId) || record.recordId.includes("..")) {
|
|
482
|
+
throw new Error("record[" + index + "].recordId 缺失或不安全");
|
|
483
|
+
}
|
|
484
|
+
if (record.sourceFile !== undefined) assertSafeRelativeMetadataPath(record.sourceFile, "record[" + index + "].sourceFile");
|
|
485
|
+
if (record.seq !== undefined) {
|
|
486
|
+
if (!Number.isSafeInteger(record.seq)) throw new Error("record[" + index + "].seq 必须是有限安全整数");
|
|
487
|
+
}
|
|
488
|
+
if (record.time !== undefined) assertManifestTime(record.time, "record[" + index + "].time");
|
|
489
|
+
assertManifestInteger(record.charLength, "record[" + index + "].charLength");
|
|
490
|
+
assertManifestInteger(record.utf8ByteLength, "record[" + index + "].utf8ByteLength");
|
|
491
|
+
if (typeof record.sha256 !== "string" || !SHA256_HEX.test(record.sha256)) {
|
|
492
|
+
throw new Error("record[" + index + "].sha256 缺失或格式无效");
|
|
493
|
+
}
|
|
494
|
+
if (!Array.isArray(record.chunks)) throw new Error("record[" + index + "].chunks 不是数组");
|
|
495
|
+
if (!Number.isSafeInteger(record.chunkCount) || record.chunkCount < 0 || record.chunkCount !== record.chunks.length) {
|
|
496
|
+
throw new Error("record[" + index + "].chunkCount 与 chunks 不一致");
|
|
497
|
+
}
|
|
498
|
+
for (const [chunkIndex, chunk] of record.chunks.entries()) {
|
|
499
|
+
if (chunk === null || typeof chunk !== "object" || Array.isArray(chunk)) {
|
|
500
|
+
throw new Error("record[" + index + "].chunks[" + chunkIndex + "] 不是对象");
|
|
501
|
+
}
|
|
502
|
+
if (!Number.isSafeInteger(chunk.index) || chunk.index < 0 || chunk.index !== chunkIndex) {
|
|
503
|
+
throw new Error("record[" + index + "].chunks[" + chunkIndex + "].index 无效");
|
|
504
|
+
}
|
|
505
|
+
assertManifestInteger(chunk.byteOffset, "record[" + index + "].chunks[" + chunkIndex + "].byteOffset");
|
|
506
|
+
assertManifestInteger(chunk.byteLength, "record[" + index + "].chunks[" + chunkIndex + "].byteLength");
|
|
507
|
+
if (typeof chunk.path !== "string" || chunk.path.length === 0) {
|
|
508
|
+
throw new Error("record[" + index + "].chunks[" + chunkIndex + "].path 缺失");
|
|
509
|
+
}
|
|
510
|
+
assertSafeRelativeMetadataPath(chunk.path, "record[" + index + "].chunks[" + chunkIndex + "].path");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
assertManifestSummary(manifest);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function readManifest(manifestPath) {
|
|
517
|
+
const bytes = await readFile(manifestPath);
|
|
518
|
+
let text;
|
|
519
|
+
try {
|
|
520
|
+
text = FATAL_UTF8.decode(bytes);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
throw new Error("manifest 不是合法 UTF-8", { cause: error });
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(text);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
throw new Error("manifest 不是合法 JSON", { cause: error });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function reassembleManifestRecord(root, record) {
|
|
532
|
+
const chunks = [];
|
|
533
|
+
for (const chunkMeta of record.chunks) {
|
|
534
|
+
const chunkPath = ensureRelativeSafePath(root, chunkMeta.path);
|
|
535
|
+
if (relative(root, chunkPath).split(sep).join("/") !== chunkMeta.path) {
|
|
536
|
+
throw new Error("chunk 路径非规范");
|
|
537
|
+
}
|
|
538
|
+
await rejectSymlinkPath(chunkPath, "sidecar chunk");
|
|
539
|
+
const bytes = await readFile(chunkPath);
|
|
540
|
+
chunks.push({ ...chunkMeta, bytes });
|
|
541
|
+
}
|
|
542
|
+
const text = reassembleUtf8Chunks(chunks);
|
|
543
|
+
if (text.length !== record.charLength) throw new Error("字符长度不一致");
|
|
544
|
+
if (utf8Length(text) !== record.utf8ByteLength) throw new Error("UTF-8 字节长度不一致");
|
|
545
|
+
if (hashUtf8(text) !== record.sha256) throw new Error("SHA-256 不一致");
|
|
546
|
+
return { ...record, text };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function readAndValidateSidecar(sidecarDirOrManifest) {
|
|
550
|
+
let root;
|
|
551
|
+
let manifestPath;
|
|
552
|
+
let manifest;
|
|
553
|
+
try {
|
|
554
|
+
({ root, manifestPath } = await resolveSidecarPaths(sidecarDirOrManifest));
|
|
555
|
+
manifest = await readManifest(manifestPath);
|
|
556
|
+
validateManifestShape(manifest);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
errors: ["无法读取 sidecar: " + (error instanceof Error ? error.message : String(error))],
|
|
561
|
+
records: [],
|
|
562
|
+
recordCount: 0,
|
|
563
|
+
chunkCount: 0,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const records = [];
|
|
568
|
+
const errors = [];
|
|
569
|
+
let chunkCount = 0;
|
|
570
|
+
for (const record of manifest.records) {
|
|
571
|
+
try {
|
|
572
|
+
records.push(await reassembleManifestRecord(root, record));
|
|
573
|
+
chunkCount += record.chunks.length;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
errors.push(record.recordId + ": " + (error instanceof Error ? error.message : String(error)));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (manifest.summary.chunkCount !== chunkCount) errors.push("summary.chunkCount 不一致");
|
|
579
|
+
return { ok: errors.length === 0, errors, manifest, records, recordCount: manifest.records.length, chunkCount };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 读取 manifest、重组所有 chunk 并校验长度、顺序与 SHA-256。 */
|
|
583
|
+
export async function verifyPromptSidecar(sidecarDirOrManifest) {
|
|
584
|
+
const result = await readAndValidateSidecar(sidecarDirOrManifest);
|
|
585
|
+
return {
|
|
586
|
+
ok: result.ok,
|
|
587
|
+
errors: result.errors,
|
|
588
|
+
recordCount: result.recordCount,
|
|
589
|
+
chunkCount: result.chunkCount,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function readPromptSidecar(sidecarDirOrManifest) {
|
|
594
|
+
const result = await readAndValidateSidecar(sidecarDirOrManifest);
|
|
595
|
+
if (!result.ok) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
errors: result.errors,
|
|
599
|
+
...(result.manifest === undefined ? {} : { manifest: result.manifest }),
|
|
600
|
+
records: result.records,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { ok: true, manifest: result.manifest, records: result.records };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function defaultSidecarPath(inputPathOrDir) {
|
|
607
|
+
// 目录输入也使用同级 sibling,避免默认输出落入输入树而触发重叠保护。
|
|
608
|
+
const input = resolve(inputPathOrDir);
|
|
609
|
+
return join(dirname(input), basename(input) + ".dsh-prompt-sidecar");
|
|
610
|
+
}
|