oh-my-knowledge 0.34.0 → 0.35.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/README.md +3 -0
- package/README.zh.md +3 -0
- package/dist/assets/agent-skills/omk/references/commands.md +9 -0
- package/dist/authoring/evolver.js +12 -3
- package/dist/cli/commands/eval/index.d.ts +1 -0
- package/dist/cli/commands/eval/index.js +36 -2
- package/dist/cli/commands/install.d.ts +2 -0
- package/dist/cli/commands/install.js +45 -20
- package/dist/cli/commands/sample.d.ts +1 -1
- package/dist/cli/commands/sample.js +25 -12
- package/dist/cli/lib/cmd-flags.d.ts +1 -0
- package/dist/cli/lib/i18n-dict/install.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/install.js +20 -0
- package/dist/cli/lib/i18n-dict/run.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/run.js +8 -0
- package/dist/cli/lib/parse-run-config/variant-resolution.js +7 -0
- package/dist/doctor/index.js +5 -5
- package/dist/eval-core/cache.d.ts +11 -3
- package/dist/eval-core/cache.js +13 -5
- package/dist/eval-core/dependency-checker.js +5 -3
- package/dist/eval-core/evaluation-execution.js +4 -1
- package/dist/eval-core/evaluation-reporting.js +17 -8
- package/dist/eval-core/execution-strategy.js +6 -4
- package/dist/eval-core/task-planner.js +2 -1
- package/dist/eval-workflows/evaluation-preparation.js +3 -1
- package/dist/inputs/content-hash.d.ts +28 -0
- package/dist/inputs/content-hash.js +106 -0
- package/dist/inputs/eval-config.js +44 -11
- package/dist/inputs/materialize-copy.d.ts +37 -0
- package/dist/inputs/materialize-copy.js +193 -0
- package/dist/inputs/skill-loader.d.ts +65 -2
- package/dist/inputs/skill-loader.js +308 -13
- package/dist/inputs/source-resolver.d.ts +16 -9
- package/dist/inputs/source-resolver.js +57 -77
- package/dist/managed/evidence.d.ts +22 -0
- package/dist/managed/evidence.js +143 -0
- package/dist/managed/index.d.ts +1 -0
- package/dist/managed/index.js +1 -0
- package/dist/managed/store.d.ts +16 -20
- package/dist/managed/store.js +40 -76
- package/dist/renderer/layout.js +2 -2
- package/dist/types/eval.d.ts +12 -1
- package/dist/types/managed.d.ts +29 -4
- package/dist/types/report.d.ts +17 -3
- package/package.json +3 -3
|
@@ -159,10 +159,12 @@ function buildPreflightEnv(artifacts) {
|
|
|
159
159
|
return process.env;
|
|
160
160
|
const extraPaths = [];
|
|
161
161
|
for (const a of artifacts) {
|
|
162
|
-
//
|
|
163
|
-
|
|
162
|
+
// 与 extractSkillDir 一致:dir-skill 优先隔离副本 execRoot(副本无 node_modules、.bin 检查恒不命中);
|
|
163
|
+
// 否则本地文件-skill 取 .md 所在目录。git 文件-skill 的 locator 是 git spec、非在盘目录,无 execRoot
|
|
164
|
+
// 时跳过(取 dirname 会得伪路径)。
|
|
165
|
+
const dir = a.execRoot ?? (a.locator && a.source !== 'git' ? dirname(a.locator) : null);
|
|
166
|
+
if (!dir)
|
|
164
167
|
continue;
|
|
165
|
-
const dir = dirname(a.locator);
|
|
166
168
|
const nodeBin = join(dir, 'node_modules', '.bin');
|
|
167
169
|
if (existsSync(nodeBin))
|
|
168
170
|
extraPaths.push(nodeBin);
|
|
@@ -85,7 +85,10 @@ export async function executeTasks({ tasks, executor, executorName, model, noJud
|
|
|
85
85
|
// don't share cache entries, and include runtime fingerprint so a binary/SDK bump
|
|
86
86
|
// cannot replay old-runtime outputs under new-runtime report metadata.
|
|
87
87
|
// 同时把 mocks + mocksStrict 进 key:改 mock 配置必须重跑,不能命中老 cache。
|
|
88
|
-
const key = cacheKey(model, executionPlan.cacheSystem, executionPlan.input.prompt, executionPlan.input.cwd, task.artifact.allowedSkills, effectiveExecutorName, executorRuntime.fingerprint, executionPlan.input.mocks, executionPlan.input.mocksStrict, effort
|
|
88
|
+
const key = cacheKey(model, executionPlan.cacheSystem, executionPlan.input.prompt, executionPlan.input.cwd, task.artifact.allowedSkills, effectiveExecutorName, executorRuntime.fingerprint, executionPlan.input.mocks, executionPlan.input.mocksStrict, effort,
|
|
89
|
+
// artifact 内容指纹进 key:本地 dir-skill 改 references/ 资产只动 contentHash,system 不变,
|
|
90
|
+
// 不进 key 会命中旧输出贴到新 artifactHashes(静默污染)。
|
|
91
|
+
task.artifact.contentHash);
|
|
89
92
|
const cached = cache?.get(key);
|
|
90
93
|
const execStart = Date.now();
|
|
91
94
|
if (cached) {
|
|
@@ -92,13 +92,14 @@ function buildExecutorRuntimesByVariant({ variants, model, executorName, tasks,
|
|
|
92
92
|
if (runtimes[variant])
|
|
93
93
|
continue;
|
|
94
94
|
const artifact = artifacts.find((a) => a.name === variant);
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
const fallbackSkillDir = artifact?.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
// 与主路径 extractSkillDir 一致:dir-skill 优先隔离副本 execRoot(副本无 node_modules、PATH 不污染);
|
|
96
|
+
// 否则 baseline / git 文件-skill 取 null,本地文件-skill 取 .md 所在目录。
|
|
97
|
+
const fallbackSkillDir = artifact?.execRoot
|
|
98
|
+
?? (artifact?.kind === 'baseline' || artifact?.source === 'git'
|
|
99
|
+
? null
|
|
100
|
+
: artifact?.locator
|
|
101
|
+
? dirname(artifact.locator)
|
|
102
|
+
: request?.skillDir);
|
|
102
103
|
runtimes[variant] = getExecutorRuntimeFingerprint(executorName, model, {
|
|
103
104
|
skillDir: fallbackSkillDir,
|
|
104
105
|
});
|
|
@@ -151,7 +152,11 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
|
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
|
-
|
|
155
|
+
// 整树内容指纹:解析期已由 resolveArtifacts 用 hashArtifactSource 算好挂在 artifact.contentHash 上
|
|
156
|
+
// (目录-skill 覆盖整棵可分发树含 references/ 资产、文件-skill 为单文件字节),与 install 受管记录的
|
|
157
|
+
// contentHash 落在同一空间——证据可绑定的前提,也修掉「只哈 SKILL.md 正文、改资产指纹不变」的资产瞎。
|
|
158
|
+
// baseline / 无 skill 记 'no-skill'。
|
|
159
|
+
const artifactHashes = Object.fromEntries(artifacts.map((artifact) => [artifact.name, artifact.contentHash ?? 'no-skill']));
|
|
155
160
|
const sampleHashes = Object.fromEntries(samples.map((s) => [s.sample_id, hashSample(s)]));
|
|
156
161
|
const judgeRepeat = request?.judgeRepeat && request.judgeRepeat > 1 ? request.judgeRepeat : undefined;
|
|
157
162
|
const runtimeOptions = { skillDir: request?.skillDir };
|
|
@@ -191,6 +196,10 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
|
|
|
191
196
|
timestamp: new Date().toISOString(),
|
|
192
197
|
cliVersion: getCliVersion(),
|
|
193
198
|
nodeVersion: process.version,
|
|
199
|
+
// schemaVersion 3 起,所有 dir-skill(本地 + git)都经隔离副本物化、整棵可分发树哈,与 install
|
|
200
|
+
// 受管记录 contentHash 同空间(evidence 全绑)。2 是过渡纪元(本地 dir-skill 树哈、git dir-skill
|
|
201
|
+
// 仅 SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3 不可比。作判别位:消费方对缺位/旧报告不错配比对。
|
|
202
|
+
schemaVersion: 3,
|
|
194
203
|
artifactHashes,
|
|
195
204
|
sampleHashes,
|
|
196
205
|
...(noJudge ? {} : { judgePromptHash: getJudgePromptHash(lengthDebiasOn) }),
|
|
@@ -72,10 +72,12 @@ export function buildVariantConfig(artifact) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
function extractSkillDir(artifact) {
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// dir-skill(任意源)优先用隔离副本执行根 execRoot:agent cwd 锚副本、skillDir 也指副本。副本经
|
|
76
|
+
// distributable filter 物化、不含 node_modules → buildExecEnv 的 .bin 检查恒不命中 → PATH 不被污染。
|
|
77
|
+
if (artifact.execRoot)
|
|
78
|
+
return artifact.execRoot;
|
|
79
|
+
// git 文件-skill 无 execRoot 且 locator 是 git spec(`review` / `skills/review`)非磁盘目录,取 dirname
|
|
80
|
+
// 会得伪 skillDir 污染 PATH/runtime fingerprint → 与 baseline 一样返回 null。本地文件-skill 取 .md 所在目录。
|
|
79
81
|
if (!artifact.locator || artifact.source === 'git')
|
|
80
82
|
return null;
|
|
81
83
|
return dirname(artifact.locator);
|
|
@@ -62,7 +62,8 @@ export function buildTasksFromArtifacts(samples, artifacts) {
|
|
|
62
62
|
assertions: sample.assertions || null,
|
|
63
63
|
dimensions: sample.dimensions || null,
|
|
64
64
|
artifactContent: artifact.content,
|
|
65
|
-
|
|
65
|
+
// 用户显式 cwd > execRoot(隔离副本,dir-skill 执行根) > skillRoot(真源,兜底) > sample.cwd
|
|
66
|
+
cwd: artifact.cwd || artifact.execRoot || artifact.skillRoot || sample.cwd || null,
|
|
66
67
|
_sample: sample,
|
|
67
68
|
});
|
|
68
69
|
}
|
|
@@ -10,7 +10,9 @@ export async function prepareEvaluationRun({ samplesPath, skillDir, variantSpecs
|
|
|
10
10
|
// 结构化传入 {expr, cwd}(与 variantSpecs 顺序一一对应),cwd 不再编码进 expr 字符串。
|
|
11
11
|
// resolveArtifacts 只保留 strictBaseline 默认(baseline → []);per-variant 隔离声明走
|
|
12
12
|
// spec.allowedSkills,在下面按 spec 身份绑定。
|
|
13
|
-
const variantInputs = variantSpecs.map((spec) =>
|
|
13
|
+
const variantInputs = variantSpecs.map((spec) => spec.git
|
|
14
|
+
? { git: spec.git, cwd: spec.cwd, name: spec.name }
|
|
15
|
+
: { expr: spec.expr, cwd: spec.cwd });
|
|
14
16
|
const resolvedArtifacts = resolveArtifacts(resolve(skillDir), variantInputs, { strictBaseline });
|
|
15
17
|
// experimentRole / allowedSkills 按 spec 身份绑定。variantSpecs 与 resolvedArtifacts 顺序
|
|
16
18
|
// 一一对应(resolveArtifacts 每个 input 产出一个 artifact),按 index 绑定,并把 spec.name
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
|
|
3
|
+
* git 物化共用此一处,保证三者完全一致。
|
|
4
|
+
*
|
|
5
|
+
* 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
|
|
6
|
+
* 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
|
|
7
|
+
* 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
|
|
8
|
+
*/
|
|
9
|
+
export declare function isDistributablePath(segments: string[]): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* cpSync 的 filter,口径与 hashArtifactSource 的 walk 完全一致(「分发出去的 == 算进 hash 的」):
|
|
12
|
+
* 源根永远拷;软链跳过(避免软链目标改了却不触发 drift、回避软链环);其余按 isDistributablePath
|
|
13
|
+
* 逐段判定。install 分发(copyArtifactToTarget)与 eval 隔离副本物化(materializeIsolatedCopy)
|
|
14
|
+
* 共用此一处 —— copy / hash / 副本三者绝不发散。
|
|
15
|
+
*/
|
|
16
|
+
export declare function distributableCopyFilter(sourceRoot: string): (src: string) => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
|
|
19
|
+
* - 文件-skill:单个 .md 的字节;
|
|
20
|
+
* - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
|
|
21
|
+
* evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
|
|
22
|
+
* sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
|
|
23
|
+
* 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
|
|
24
|
+
* 读时(list / drift 检查 / eval 报告)用同一函数重算比对。
|
|
25
|
+
*/
|
|
26
|
+
/** sha256 前 12 位的字节摘要 —— 单文件 / 单 blob 内容指纹的共用底座(整树哈、git 单文件哈都走它)。 */
|
|
27
|
+
export declare function hashBytes(buf: Buffer): string;
|
|
28
|
+
export declare function hashArtifactSource(source: string, isDirectorySkill: boolean): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync, lstatSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* artifact「可分发树」与内容指纹 —— inputs 层的纯内容哈希工具。install(受管记录)与 eval
|
|
6
|
+
* (report 的 artifactHashes)共用同一处,保证「同一个 skill,装出来与测出来的指纹落在同一空间」,
|
|
7
|
+
* 是证据随 artifact 走(evidence.contentHash === record.contentHash)的承重基础。
|
|
8
|
+
*
|
|
9
|
+
* 放在 inputs 层而非 managed:这两个函数语义上属内容哈希、无 managed 专属依赖;skill-loader、
|
|
10
|
+
* source-resolver、managed/store 都在它之上消费,inputs 是更低层,不成环。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* 不进 artifact「可分发树」的条目 —— omk 的评测 / 迭代 / VCS / 系统产物,既不该被拷进 agent
|
|
14
|
+
* skill 目录,也不该计入 artifact contentHash。区分两类语义,避免误伤合法嵌套资产:
|
|
15
|
+
* - **任意层级排除**:隐藏元数据 / VCS / 系统 / 依赖目录,任何深度出现都是噪声
|
|
16
|
+
* (`.omk` = samples / managed / observations;`.git`;`node_modules`;OS 垃圾);
|
|
17
|
+
* - **仅源根第一层排除**:omk 保留的工作目录,只在 skill 根有保留语义,嵌套同名是用户合法资产
|
|
18
|
+
* (`evolve` 是 `<skillDir>/evolve/` 候选快照;但 `references/evolve/guide.md` 应正常分发并计入 hash)。
|
|
19
|
+
* spec 里 artifact content hash 与 sample-set hash 是分开的证据轴,故只补样本(.omk)不该改 hash。
|
|
20
|
+
*/
|
|
21
|
+
const GLOBAL_EXCLUDED_NAMES = new Set(['.omk', '.git', 'node_modules', '.DS_Store', 'Thumbs.db']);
|
|
22
|
+
const ROOT_ONLY_EXCLUDED_NAMES = new Set(['evolve']);
|
|
23
|
+
/**
|
|
24
|
+
* 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
|
|
25
|
+
* git 物化共用此一处,保证三者完全一致。
|
|
26
|
+
*
|
|
27
|
+
* 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
|
|
28
|
+
* 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
|
|
29
|
+
* 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
|
|
30
|
+
*/
|
|
31
|
+
export function isDistributablePath(segments) {
|
|
32
|
+
if (segments.length === 0)
|
|
33
|
+
return true; // 源根永远算
|
|
34
|
+
for (const seg of segments) {
|
|
35
|
+
if (GLOBAL_EXCLUDED_NAMES.has(seg))
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (ROOT_ONLY_EXCLUDED_NAMES.has(segments[0]))
|
|
39
|
+
return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* cpSync 的 filter,口径与 hashArtifactSource 的 walk 完全一致(「分发出去的 == 算进 hash 的」):
|
|
44
|
+
* 源根永远拷;软链跳过(避免软链目标改了却不触发 drift、回避软链环);其余按 isDistributablePath
|
|
45
|
+
* 逐段判定。install 分发(copyArtifactToTarget)与 eval 隔离副本物化(materializeIsolatedCopy)
|
|
46
|
+
* 共用此一处 —— copy / hash / 副本三者绝不发散。
|
|
47
|
+
*/
|
|
48
|
+
export function distributableCopyFilter(sourceRoot) {
|
|
49
|
+
return (src) => {
|
|
50
|
+
const rel = relative(sourceRoot, src);
|
|
51
|
+
if (rel === '')
|
|
52
|
+
return true;
|
|
53
|
+
if (lstatSync(src).isSymbolicLink())
|
|
54
|
+
return false;
|
|
55
|
+
return isDistributablePath(rel.split(sep));
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
|
|
60
|
+
* - 文件-skill:单个 .md 的字节;
|
|
61
|
+
* - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
|
|
62
|
+
* evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
|
|
63
|
+
* sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
|
|
64
|
+
* 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
|
|
65
|
+
* 读时(list / drift 检查 / eval 报告)用同一函数重算比对。
|
|
66
|
+
*/
|
|
67
|
+
/** sha256 前 12 位的字节摘要 —— 单文件 / 单 blob 内容指纹的共用底座(整树哈、git 单文件哈都走它)。 */
|
|
68
|
+
export function hashBytes(buf) {
|
|
69
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
|
70
|
+
}
|
|
71
|
+
export function hashArtifactSource(source, isDirectorySkill) {
|
|
72
|
+
if (!isDirectorySkill) {
|
|
73
|
+
return hashBytes(readFileSync(source));
|
|
74
|
+
}
|
|
75
|
+
const rels = [];
|
|
76
|
+
const walk = (dir, segments) => {
|
|
77
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
+
const segs = [...segments, entry.name];
|
|
79
|
+
if (!isDistributablePath(segs))
|
|
80
|
+
continue;
|
|
81
|
+
// 软链既非 isFile 也非 isDirectory,天然跳过 —— 与 copyArtifactToTarget 的 filter 一致
|
|
82
|
+
// (hash 覆盖的 == 分发出去的),避免软链目标改变却不触发 drift,也回避软链环。
|
|
83
|
+
if (entry.isDirectory())
|
|
84
|
+
walk(join(dir, entry.name), segs);
|
|
85
|
+
else if (entry.isFile())
|
|
86
|
+
rels.push(segs.join('/'));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(source, []);
|
|
90
|
+
rels.sort();
|
|
91
|
+
const h = createHash('sha256');
|
|
92
|
+
const sep = Buffer.from([0]);
|
|
93
|
+
for (const rel of rels) {
|
|
94
|
+
const content = readFileSync(join(source, rel));
|
|
95
|
+
// 路径与内容都做长度前缀,彻底排除"不同树拼出同一串"的歧义(文件名虽不含 NUL,核心层仍按可注入防)。
|
|
96
|
+
h.update(String(Buffer.byteLength(rel)));
|
|
97
|
+
h.update(sep);
|
|
98
|
+
h.update(rel);
|
|
99
|
+
h.update(sep);
|
|
100
|
+
h.update(String(content.length));
|
|
101
|
+
h.update(sep);
|
|
102
|
+
h.update(content);
|
|
103
|
+
h.update(sep);
|
|
104
|
+
}
|
|
105
|
+
return h.digest('hex').slice(0, 12);
|
|
106
|
+
}
|
|
@@ -23,13 +23,19 @@ export function loadEvalConfig(configPath) {
|
|
|
23
23
|
* so the downstream variant resolver can treat CLI and config uniformly.
|
|
24
24
|
*/
|
|
25
25
|
export function configVariantsToSpecs(variants) {
|
|
26
|
-
return variants.map((v) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
expr
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
return variants.map((v) => {
|
|
27
|
+
// 远端 git:expr 落规范身份串 `git+<url>@<ref>:<spec>`(仅供 variantIdentity 去重、绝不 re-split),
|
|
28
|
+
// 真正解析走结构化 spec.git;本地 artifact 直接用其字符串身份。
|
|
29
|
+
const expr = v.git ? `git+${v.git.url}@${v.git.ref ?? 'HEAD'}:${v.git.spec}` : v.artifact;
|
|
30
|
+
return {
|
|
31
|
+
name: v.name,
|
|
32
|
+
role: v.role,
|
|
33
|
+
expr,
|
|
34
|
+
...(v.git !== undefined && { git: v.git }),
|
|
35
|
+
...(v.cwd !== undefined && { cwd: v.cwd }),
|
|
36
|
+
...(v.allowedSkills !== undefined && { allowedSkills: v.allowedSkills }),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
33
39
|
}
|
|
34
40
|
function validateEvalConfig(parsed, configPath) {
|
|
35
41
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
@@ -56,8 +62,31 @@ function validateEvalConfig(parsed, configPath) {
|
|
|
56
62
|
if (typeof v.role !== 'string' || !VALID_ROLES.includes(v.role)) {
|
|
57
63
|
throw new Error(`${configPath}: variants[${i}].role must be 'control' or 'treatment' (got: ${JSON.stringify(v.role)})`);
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
// artifact(本地字符串身份)与 git(远端结构化)二选一、不可兼有。
|
|
66
|
+
const hasArtifact = v.artifact !== undefined;
|
|
67
|
+
const hasGit = v.git !== undefined;
|
|
68
|
+
if (hasArtifact === hasGit) {
|
|
69
|
+
throw new Error(`${configPath}: variants[${i}] must have exactly one of 'artifact' or 'git'`);
|
|
70
|
+
}
|
|
71
|
+
if (hasArtifact && (typeof v.artifact !== 'string' || !v.artifact)) {
|
|
72
|
+
throw new Error(`${configPath}: variants[${i}].artifact must be a non-empty string`);
|
|
73
|
+
}
|
|
74
|
+
let git;
|
|
75
|
+
if (hasGit) {
|
|
76
|
+
if (typeof v.git !== 'object' || v.git === null || Array.isArray(v.git)) {
|
|
77
|
+
throw new Error(`${configPath}: variants[${i}].git must be an object { url, ref?, spec }`);
|
|
78
|
+
}
|
|
79
|
+
const g = v.git;
|
|
80
|
+
if (typeof g.url !== 'string' || !g.url) {
|
|
81
|
+
throw new Error(`${configPath}: variants[${i}].git.url is required and must be a string`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof g.spec !== 'string' || !g.spec) {
|
|
84
|
+
throw new Error(`${configPath}: variants[${i}].git.spec is required and must be a string (in-repo skill path)`);
|
|
85
|
+
}
|
|
86
|
+
if (g.ref !== undefined && (typeof g.ref !== 'string' || !g.ref)) {
|
|
87
|
+
throw new Error(`${configPath}: variants[${i}].git.ref must be a non-empty string when present`);
|
|
88
|
+
}
|
|
89
|
+
git = { url: g.url, spec: g.spec, ...(g.ref !== undefined && { ref: g.ref }) };
|
|
61
90
|
}
|
|
62
91
|
if (v.cwd !== undefined && typeof v.cwd !== 'string') {
|
|
63
92
|
throw new Error(`${configPath}: variants[${i}].cwd must be a string`);
|
|
@@ -88,7 +117,8 @@ function validateEvalConfig(parsed, configPath) {
|
|
|
88
117
|
variants.push({
|
|
89
118
|
name: v.name,
|
|
90
119
|
role: v.role,
|
|
91
|
-
artifact: v.artifact,
|
|
120
|
+
...(hasArtifact && { artifact: v.artifact }),
|
|
121
|
+
...(git !== undefined && { git }),
|
|
92
122
|
cwd: v.cwd,
|
|
93
123
|
...(allowedSkills !== undefined && { allowedSkills }),
|
|
94
124
|
});
|
|
@@ -245,7 +275,10 @@ function resolveConfigPaths(config, configDir) {
|
|
|
245
275
|
goldDir: config.goldDir ? resolveRel(config.goldDir) : undefined,
|
|
246
276
|
variants: config.variants.map((v) => ({
|
|
247
277
|
...v,
|
|
248
|
-
|
|
278
|
+
// git 变体无 artifact(git.url 是 URL、git.spec 是仓库相对路径,都不按本地路径解析,原样经 ...v 携带)。
|
|
279
|
+
...(v.artifact !== undefined && {
|
|
280
|
+
artifact: isNonPathExpr(v.artifact) ? v.artifact : (looksLikePath(v.artifact) ? resolveRel(v.artifact) : v.artifact),
|
|
281
|
+
}),
|
|
249
282
|
cwd: v.cwd ? resolveRel(v.cwd) : undefined,
|
|
250
283
|
})),
|
|
251
284
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内容寻址隔离副本 —— eval 把任意源(本地目录-skill / 本地单文件-skill / 本地 git ref / 远端 git)
|
|
3
|
+
* 在测量前落地成的那一份。executor 的 cwd 锚到 copyRoot,被测 agent 在隔离副本里跑、不碰用户真实
|
|
4
|
+
* 目录,references/ 资产成为真实运行时输入。路径按 contentHash 命名 → 同内容同路径 → task.cwd
|
|
5
|
+
* 稳定 → cache key / runtime fingerprint 稳定。
|
|
6
|
+
*/
|
|
7
|
+
export interface IsolatedCopy {
|
|
8
|
+
/** 副本根:目录-skill 为目录 `<treesDir>/<hash>`、单文件-skill 为 `<treesDir>/<hash>/<name>.md`。 */
|
|
9
|
+
copyRoot: string;
|
|
10
|
+
/** = hashArtifactSource(copyRoot, isDirectorySkill);与 install 受管记录的 contentHash 同空间。 */
|
|
11
|
+
contentHash: string;
|
|
12
|
+
isDirectorySkill: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
|
|
15
|
+
* `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
|
|
16
|
+
export declare function treesDir(): string;
|
|
17
|
+
/**
|
|
18
|
+
* 内容寻址副本的 LRU 回收:`<treesDir>/<hash>` 数超过上限时,按 mtime 从旧到新淘汰,直到回到上限内。
|
|
19
|
+
* **绝不删**被活进程占用的副本(`.locks/<hash>.<pid>` 且 pid 在世)—— 这才是「正在跑的 eval 的 cwd 不被删」
|
|
20
|
+
* 的真实保证(不靠 mtime 当 liveness)。grace 窗口是第二道兜底。软上限:被占用/grace 内的大批新副本会暂时
|
|
21
|
+
* 超限,待其释放/老化后回收。`.tmp-` 暂存与 `.locks` 不计、不删。
|
|
22
|
+
* materializeIsolatedCopy 命中走 utimes 触碰(LRU touch)+ 落锁;未命中落盘后落锁并调本函数;也可独立调用。
|
|
23
|
+
*/
|
|
24
|
+
export declare function pruneTreesDir(opts?: {
|
|
25
|
+
maxEntries?: number;
|
|
26
|
+
graceMs?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
/**
|
|
29
|
+
* 把一棵已在盘的本地源树(目录或单 .md)落地成内容寻址隔离副本。
|
|
30
|
+
* - 先从**源**算整树 / 单文件指纹(`hashArtifactSource` 与副本同 filter → 同 hash),不必先 copy
|
|
31
|
+
* - `<treesDir>/<hash>` 已存在 → 内容寻址命中,直接复用、**零 copy**(同 hash 内容必然相同)
|
|
32
|
+
* - 未命中 → 物化到同卷临时目录 `<treesDir>/.tmp-xxx`(目录-skill 用 install 同一 distributable
|
|
33
|
+
* filter;单文件-skill copyFile 到 `<tmp>/<name>.md`,使 `<hash>` 恒为目录、消歧),再原子
|
|
34
|
+
* `rename` 到 `<hash>`;rename 撞 EEXIST/ENOTEMPTY(并发已落同 hash)即删临时复用。
|
|
35
|
+
* 物化只发生一次/variant(resolveArtifacts 解析期),不随 sample 数放大。
|
|
36
|
+
*/
|
|
37
|
+
export declare function materializeIsolatedCopy(localRoot: string, isDirectorySkill: boolean, name: string): IsolatedCopy;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, cpSync, copyFileSync, existsSync, renameSync, rmSync, readdirSync, statSync, utimesSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { hashArtifactSource, distributableCopyFilter } from './content-hash.js';
|
|
5
|
+
/** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
|
|
6
|
+
* `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
|
|
7
|
+
export function treesDir() {
|
|
8
|
+
return process.env.OMK_TREES_DIR || join(homedir(), '.oh-my-knowledge', 'trees');
|
|
9
|
+
}
|
|
10
|
+
/** 内容寻址副本默认上限(distinct 内容版本数);超出按 LRU(mtime)淘汰。
|
|
11
|
+
* `OMK_TREES_MAX_ENTRIES` 调整:0 / 负数 = 不限。 */
|
|
12
|
+
const DEFAULT_TREES_MAX_ENTRIES = 200;
|
|
13
|
+
/** 淘汰宽限:mtime 在此窗口内的副本不动。这是**第二道**保护(覆盖物化→落锁那几毫秒的窗口);
|
|
14
|
+
* 真正挡住「删掉正在跑的 eval 的 cwd」靠下面的 pid 占用锁,不靠「没有 eval 跑这么久」的假设。 */
|
|
15
|
+
const TREES_GRACE_MS = 60 * 60 * 1000;
|
|
16
|
+
function treesCap() {
|
|
17
|
+
const raw = process.env.OMK_TREES_MAX_ENTRIES;
|
|
18
|
+
if (raw == null || raw === '')
|
|
19
|
+
return DEFAULT_TREES_MAX_ENTRIES;
|
|
20
|
+
const n = Number(raw);
|
|
21
|
+
if (!Number.isFinite(n))
|
|
22
|
+
return DEFAULT_TREES_MAX_ENTRIES;
|
|
23
|
+
if (n <= 0)
|
|
24
|
+
return Infinity;
|
|
25
|
+
return Math.floor(n);
|
|
26
|
+
}
|
|
27
|
+
/** 占用锁目录:`<treesDir>/.locks/<hash>.<pid>`。dotfile → 不计入 cap、不被当副本删。 */
|
|
28
|
+
function locksDir() {
|
|
29
|
+
return join(treesDir(), '.locks');
|
|
30
|
+
}
|
|
31
|
+
function isPidAlive(pid) {
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0); // 信号 0:只探活、不真发信号
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// EPERM = 进程存在但无权限(仍算活);ESRCH = 不存在
|
|
40
|
+
return err.code === 'EPERM';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 标记本进程正在用某内容寻址副本作 executor cwd —— 落一个 `<treesDir>/.locks/<hash>.<pid>` 占用锁。
|
|
45
|
+
* pruneTreesDir 见到**活 pid** 的锁就绝不删对应副本(真实跨进程 liveness,不靠 mtime 猜)。进程崩溃也安全:
|
|
46
|
+
* 锁不显式回收,prune 惰性按 pid 探活清理死锁。同 hash 同 pid 幂等(覆盖写)。
|
|
47
|
+
*/
|
|
48
|
+
function markCopyInUse(contentHash) {
|
|
49
|
+
try {
|
|
50
|
+
const dir = locksDir();
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
writeFileSync(join(dir, `${contentHash}.${process.pid}`), '');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// 落锁失败不致命:还有 grace 窗口兜底
|
|
56
|
+
}
|
|
57
|
+
reapDeadLocks(); // 每次落锁顺带清死 pid 的锁 —— 不依赖 prune(prune 仅 cache-miss + 超上限才跑,
|
|
58
|
+
// under-cap 的常见情形下永不触发,死锁会无界堆积)。readdir 小目录,开销可忽略。
|
|
59
|
+
}
|
|
60
|
+
/** 扫 `.locks/`:删掉死 pid 的锁、返回被**活进程**占用的 hash 集合。每次物化(markCopyInUse)无条件调用,
|
|
61
|
+
* 保证死锁惰性回收的路径始终可达;pruneTreesDir 也用其返回的 live 集合保护 active cwd。 */
|
|
62
|
+
function reapDeadLocks() {
|
|
63
|
+
const dir = locksDir();
|
|
64
|
+
const live = new Set();
|
|
65
|
+
let names;
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(dir);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return live; // 无锁目录
|
|
71
|
+
}
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const dot = name.lastIndexOf('.');
|
|
74
|
+
if (dot <= 0)
|
|
75
|
+
continue;
|
|
76
|
+
const hash = name.slice(0, dot);
|
|
77
|
+
const pid = Number(name.slice(dot + 1));
|
|
78
|
+
if (isPidAlive(pid)) {
|
|
79
|
+
live.add(hash);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(join(dir, name)); // 死 pid 的锁惰性回收
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// 清理失败不致命
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return live;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 内容寻址副本的 LRU 回收:`<treesDir>/<hash>` 数超过上限时,按 mtime 从旧到新淘汰,直到回到上限内。
|
|
94
|
+
* **绝不删**被活进程占用的副本(`.locks/<hash>.<pid>` 且 pid 在世)—— 这才是「正在跑的 eval 的 cwd 不被删」
|
|
95
|
+
* 的真实保证(不靠 mtime 当 liveness)。grace 窗口是第二道兜底。软上限:被占用/grace 内的大批新副本会暂时
|
|
96
|
+
* 超限,待其释放/老化后回收。`.tmp-` 暂存与 `.locks` 不计、不删。
|
|
97
|
+
* materializeIsolatedCopy 命中走 utimes 触碰(LRU touch)+ 落锁;未命中落盘后落锁并调本函数;也可独立调用。
|
|
98
|
+
*/
|
|
99
|
+
export function pruneTreesDir(opts = {}) {
|
|
100
|
+
const cap = opts.maxEntries ?? treesCap();
|
|
101
|
+
if (!Number.isFinite(cap))
|
|
102
|
+
return;
|
|
103
|
+
const graceMs = opts.graceMs ?? TREES_GRACE_MS;
|
|
104
|
+
const root = treesDir();
|
|
105
|
+
let names;
|
|
106
|
+
try {
|
|
107
|
+
names = readdirSync(root);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return; // 目录不存在 = 无可回收
|
|
111
|
+
}
|
|
112
|
+
const dirs = names
|
|
113
|
+
.filter((n) => !n.startsWith('.')) // 排除 .tmp- 暂存与 .locks
|
|
114
|
+
.map((n) => {
|
|
115
|
+
try {
|
|
116
|
+
return { path: join(root, n), mtimeMs: statSync(join(root, n)).mtimeMs };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
.filter((x) => x !== null);
|
|
123
|
+
if (dirs.length <= cap)
|
|
124
|
+
return;
|
|
125
|
+
const locked = reapDeadLocks();
|
|
126
|
+
dirs.sort((a, b) => a.mtimeMs - b.mtimeMs); // 最旧在前
|
|
127
|
+
const cutoff = Date.now() - graceMs;
|
|
128
|
+
let removable = dirs.length - cap;
|
|
129
|
+
for (const d of dirs) {
|
|
130
|
+
if (removable <= 0)
|
|
131
|
+
break;
|
|
132
|
+
if (locked.has(basename(d.path)))
|
|
133
|
+
continue; // 活进程占用(可能是 active cwd),绝不删
|
|
134
|
+
if (d.mtimeMs > cutoff)
|
|
135
|
+
continue; // grace 内:第二道兜底
|
|
136
|
+
try {
|
|
137
|
+
rmSync(d.path, { recursive: true, force: true });
|
|
138
|
+
removable--;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// 删除失败(权限 / 并发)不致命,跳过
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 把一棵已在盘的本地源树(目录或单 .md)落地成内容寻址隔离副本。
|
|
147
|
+
* - 先从**源**算整树 / 单文件指纹(`hashArtifactSource` 与副本同 filter → 同 hash),不必先 copy
|
|
148
|
+
* - `<treesDir>/<hash>` 已存在 → 内容寻址命中,直接复用、**零 copy**(同 hash 内容必然相同)
|
|
149
|
+
* - 未命中 → 物化到同卷临时目录 `<treesDir>/.tmp-xxx`(目录-skill 用 install 同一 distributable
|
|
150
|
+
* filter;单文件-skill copyFile 到 `<tmp>/<name>.md`,使 `<hash>` 恒为目录、消歧),再原子
|
|
151
|
+
* `rename` 到 `<hash>`;rename 撞 EEXIST/ENOTEMPTY(并发已落同 hash)即删临时复用。
|
|
152
|
+
* 物化只发生一次/variant(resolveArtifacts 解析期),不随 sample 数放大。
|
|
153
|
+
*/
|
|
154
|
+
export function materializeIsolatedCopy(localRoot, isDirectorySkill, name) {
|
|
155
|
+
const root = treesDir();
|
|
156
|
+
const contentHash = hashArtifactSource(localRoot, isDirectorySkill);
|
|
157
|
+
const target = join(root, contentHash);
|
|
158
|
+
const copyRoot = isDirectorySkill ? target : join(target, `${name}.md`);
|
|
159
|
+
if (existsSync(target)) {
|
|
160
|
+
// 命中:零 copy。utimes 触碰 mtime 作 LRU 标记 + 落 pid 占用锁(本进程将拿它当 cwd,prune 不许删)。
|
|
161
|
+
try {
|
|
162
|
+
const now = new Date();
|
|
163
|
+
utimesSync(target, now, now);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// 触碰失败不致命
|
|
167
|
+
}
|
|
168
|
+
markCopyInUse(contentHash);
|
|
169
|
+
return { copyRoot, contentHash, isDirectorySkill };
|
|
170
|
+
}
|
|
171
|
+
mkdirSync(root, { recursive: true });
|
|
172
|
+
const tmp = mkdtempSync(join(root, '.tmp-'));
|
|
173
|
+
try {
|
|
174
|
+
if (isDirectorySkill) {
|
|
175
|
+
cpSync(localRoot, tmp, { recursive: true, filter: distributableCopyFilter(localRoot) });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
copyFileSync(localRoot, join(tmp, `${name}.md`));
|
|
179
|
+
}
|
|
180
|
+
renameSync(tmp, target);
|
|
181
|
+
markCopyInUse(contentHash); // 先落占用锁,再 prune —— 保证本进程刚落的副本绝不被自己的 prune 删
|
|
182
|
+
pruneTreesDir(); // 回收超限的旧副本(跳过活进程占用 + grace)
|
|
183
|
+
return { copyRoot, contentHash, isDirectorySkill };
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
187
|
+
// 并发竞态:另一进程已先 rename 到 target(非空)→ EEXIST/ENOTEMPTY;内容相同,复用。
|
|
188
|
+
const code = err.code;
|
|
189
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY')
|
|
190
|
+
return { copyRoot, contentHash, isDirectorySkill };
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|