@modusensus/dsh-mneme 0.9.1 → 0.9.3
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 +12 -10
- package/bin/cli.mjs +61 -7
- package/docs/CONFIGURATION.md +26 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +176 -3
- package/lib/capabilities.js +1 -1
- package/lib/client.js +170 -16
- package/lib/config.js +37 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +52 -22
- package/lib/index.js +15 -0
- package/lib/pipeline-nudge-auto.js +126 -0
- package/lib/pipeline-nudge.js +337 -28
- package/lib/pipeline-runs.js +1 -1
- package/lib/pipeline.js +4 -4
- package/lib/scope-state.js +277 -0
- package/lib/scope.js +30 -1
- package/lib/service.js +80 -3
- package/lib/settings.js +12 -1
- package/lib/store.js +139 -0
- package/lib/tools.js +71 -0
- package/package.json +1 -1
- package/src/api.js +176 -3
- package/src/capabilities.js +1 -1
- package/src/config.js +37 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +52 -22
- package/src/index.js +15 -0
- package/src/pipeline-nudge-auto.js +126 -0
- package/src/pipeline-nudge.js +337 -28
- package/src/pipeline-runs.js +1 -1
- package/src/pipeline.js +4 -4
- package/src/scope-state.js +277 -0
- package/src/scope.js +30 -1
- package/src/service.js +80 -3
- package/src/settings.js +12 -1
- package/src/store.js +139 -0
- package/src/tools.js +71 -0
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +118 -1
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +103 -4
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/readme-claims.test.js +68 -0
- package/test/scope-optional-service.test.js +110 -0
- package/test/scope-state.test.js +481 -0
- package/test/session-usage.test.js +11 -2
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { embedText, embedTextDetailed, createEmbedder } from "../src/embedding.js";
|
|
4
|
+
|
|
5
|
+
// distill-tx-embed-gap 的"坏了为什么看不出来"半边:embedText 此前把「配置缺」
|
|
6
|
+
// 「网络挂」「超时」「HTTP 4xx/5xx」「响应不是 JSON」「响应没有向量」全部压成
|
|
7
|
+
// null,回执账本只能统一写 embedding-request-returned-no-vector——分诊不可能。
|
|
8
|
+
// embedTextDetailed 把失败模式拆开;embedText 保留旧 null 契约(调用方不变)。
|
|
9
|
+
|
|
10
|
+
const FULL = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
baseUrl: "https://api.example.com/v1",
|
|
13
|
+
apiKey: "sk-test",
|
|
14
|
+
model: "text-embedding-3-small"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function withFetch(impl, fn) {
|
|
18
|
+
const original = globalThis.fetch;
|
|
19
|
+
globalThis.fetch = impl;
|
|
20
|
+
try {
|
|
21
|
+
return await fn();
|
|
22
|
+
} finally {
|
|
23
|
+
globalThis.fetch = original;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("detailed: 成功时 reason 为 null,vector 与旧契约一致", async () => {
|
|
28
|
+
await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
|
|
29
|
+
const out = await embedTextDetailed(FULL, "猫咪");
|
|
30
|
+
assert.deepEqual(out.vector, [1, 2, 3]);
|
|
31
|
+
assert.equal(out.reason, null);
|
|
32
|
+
// 旧包装的 null 契约不变
|
|
33
|
+
assert.deepEqual(await embedText(FULL, "猫咪"), [1, 2, 3]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("detailed: 配置缺失时逐字段点名,不伪装成网络故障", async () => {
|
|
38
|
+
assert.match((await embedTextDetailed({ ...FULL, model: "" }, "x")).reason, /^config-incomplete:model$/);
|
|
39
|
+
assert.match((await embedTextDetailed({ ...FULL, apiKey: "" }, "x")).reason, /^config-incomplete:apiKey$/);
|
|
40
|
+
assert.match((await embedTextDetailed({ ...FULL, baseUrl: "" }, "x")).reason, /^config-incomplete:baseUrl$/);
|
|
41
|
+
assert.match(await embedTextDetailed(FULL, "").then((o) => o.reason), /config-incomplete:text/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("detailed: HTTP 状态码进 reason(401 钥匙 / 404 路径 / 429 限流)", async () => {
|
|
45
|
+
await withFetch(async () => ({ ok: false, status: 404, text: async () => "Not Found" }), async () => {
|
|
46
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
47
|
+
assert.equal(out.vector, null);
|
|
48
|
+
assert.equal(out.reason, "http-404:Not Found");
|
|
49
|
+
});
|
|
50
|
+
await withFetch(async () => ({ ok: false, status: 429, text: async () => "" }), async () => {
|
|
51
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
52
|
+
assert.equal(out.reason, "http-429", "空响应体不拼尾巴");
|
|
53
|
+
});
|
|
54
|
+
// 旧包装在这些场景全部 null,不抛
|
|
55
|
+
await withFetch(async () => ({ ok: false, status: 401, text: async () => "denied" }), async () => {
|
|
56
|
+
assert.equal(await embedText(FULL, "x"), null);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detailed: 超时与网络错误分型,不混为一谈", async () => {
|
|
61
|
+
await withFetch(async () => {
|
|
62
|
+
const err = new Error("socket hang up");
|
|
63
|
+
err.name = "TypeError";
|
|
64
|
+
throw err;
|
|
65
|
+
}, async () => {
|
|
66
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
67
|
+
assert.match(out.reason, /^request-failed:network:socket hang up/);
|
|
68
|
+
});
|
|
69
|
+
await withFetch(async () => {
|
|
70
|
+
const err = new Error("The operation was aborted");
|
|
71
|
+
err.name = "TimeoutError";
|
|
72
|
+
throw err;
|
|
73
|
+
}, async () => {
|
|
74
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
75
|
+
assert.match(out.reason, /^request-failed:timeout:/, "慢和不通是两种病");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("detailed: 响应不是 JSON 与响应没有向量各自有名", async () => {
|
|
80
|
+
await withFetch(async () => ({ ok: true, json: async () => { throw new Error("bad"); } }), async () => {
|
|
81
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
82
|
+
assert.equal(out.reason, "response-not-json");
|
|
83
|
+
});
|
|
84
|
+
await withFetch(async () => ({ ok: true, json: async () => ({ data: [] }) }), async () => {
|
|
85
|
+
const out = await embedTextDetailed(FULL, "x");
|
|
86
|
+
assert.equal(out.reason, "response-no-vector");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("embedFor 回执账本带上可区分 reason(不再是统一的 no-vector)", async () => {
|
|
91
|
+
const receipts = [];
|
|
92
|
+
const embedder = createEmbedder({
|
|
93
|
+
store: { setEmbedding() {} },
|
|
94
|
+
settings: { getVectorConfig: () => FULL },
|
|
95
|
+
logger: { info() {}, warn() {} }
|
|
96
|
+
});
|
|
97
|
+
// embedReceipts 在账本上;rejection 已在 schedule() 内消化,这里直接等 schedule promise。
|
|
98
|
+
await withFetch(async () => ({ ok: false, status: 404, text: async () => "no route" }), async () => {
|
|
99
|
+
const r = await embedder.schedule({ id: "m1", title: "t", content: "c" });
|
|
100
|
+
assert.equal(r.status, "failed");
|
|
101
|
+
assert.equal(r.reason, "http-404:no route");
|
|
102
|
+
});
|
|
103
|
+
await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
|
|
104
|
+
const r = await embedder.schedule({ id: "m2", title: "t", content: "c" });
|
|
105
|
+
assert.equal(r.status, "ok");
|
|
106
|
+
assert.equal(r.dim, 3);
|
|
107
|
+
});
|
|
108
|
+
const snap = embedder.embedReceipts();
|
|
109
|
+
assert.equal(snap.totals.failed, 1);
|
|
110
|
+
assert.equal(snap.totals.ok, 1);
|
|
111
|
+
assert.equal(snap.lastFailReason, "http-404:no route", "账本记的是分诊后的原因");
|
|
112
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createNudgeAutoRun } from "../src/pipeline-nudge-auto.js";
|
|
4
|
+
|
|
5
|
+
// 这组守的是**触发侧**(v0.9.3):什么时候跑、跑几次、跑挂了怎么办。
|
|
6
|
+
// 挖掘算法本身在 pipeline-nudge.test.js 里守,这里只守接线,所以 runNudge 全部用假的。
|
|
7
|
+
|
|
8
|
+
/** 一个假 ctx:只提供 session/event 的订阅面(真宿主用同一个口)。 */
|
|
9
|
+
function fakeCtx() {
|
|
10
|
+
const listeners = [];
|
|
11
|
+
return {
|
|
12
|
+
logger: { info() {}, warn() {} },
|
|
13
|
+
on(name, fn) {
|
|
14
|
+
if (name !== "session/event") throw new Error("unexpected event: " + name);
|
|
15
|
+
listeners.push(fn);
|
|
16
|
+
return () => {
|
|
17
|
+
const i = listeners.indexOf(fn);
|
|
18
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
emit(session, event) {
|
|
22
|
+
for (const fn of listeners.slice()) fn(session, event);
|
|
23
|
+
},
|
|
24
|
+
count: () => listeners.length
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sessionAt(cwd) {
|
|
29
|
+
return { header: { cwd } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 把 setTimeout(0) 排的活跑完:本模块刻意不用同步跑(见文件头)。 */
|
|
33
|
+
function tick(ms = 5) {
|
|
34
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 等到条件成立。队列是**链式**排程(跑完一个再 defer 下一个),所以「两个项目都跑完」
|
|
39
|
+
* 至少要等两个宏任务;固定 tick(30) 在满载的 CI 上会假阴性——全量跑时其它测试文件
|
|
40
|
+
* 同时解压 zstd,一次 setTimeout(0) 被拉长就可能超过 30ms。这里只等该等的东西。
|
|
41
|
+
*/
|
|
42
|
+
async function waitFor(predicate, timeout = 2000) {
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
while (!predicate()) {
|
|
45
|
+
if (Date.now() - started > timeout) return false;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("自动沉淀只认 turn/end:中途事件不排队、不预跑", async () => {
|
|
52
|
+
const ctx = fakeCtx();
|
|
53
|
+
const calls = [];
|
|
54
|
+
const auto = createNudgeAutoRun({
|
|
55
|
+
ctx,
|
|
56
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
57
|
+
now: () => 1_000_000
|
|
58
|
+
});
|
|
59
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/start" });
|
|
60
|
+
ctx.emit(sessionAt("D:/proj"), { type: "message" });
|
|
61
|
+
await tick();
|
|
62
|
+
assert.deepEqual(calls, [], "一轮没结束就不是观测窗口");
|
|
63
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
64
|
+
await tick();
|
|
65
|
+
assert.equal(calls.length, 1);
|
|
66
|
+
auto();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("自动跑永远是「只落提议、不落草稿」:materialize 恒 false", async () => {
|
|
70
|
+
const ctx = fakeCtx();
|
|
71
|
+
const calls = [];
|
|
72
|
+
const auto = createNudgeAutoRun({
|
|
73
|
+
ctx,
|
|
74
|
+
memoryDir: "D:/memory",
|
|
75
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
76
|
+
now: () => 1_000_000
|
|
77
|
+
});
|
|
78
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
79
|
+
await tick();
|
|
80
|
+
assert.equal(calls.length, 1);
|
|
81
|
+
// 这三条是边界,不是偏好:往别人仓库里塞文件必须是人的显式动作。
|
|
82
|
+
assert.equal(calls[0].materialize, false, "自动跑不许落草稿");
|
|
83
|
+
assert.equal(calls[0].persistProposals, true, "提议要落进记忆库");
|
|
84
|
+
assert.equal(calls[0].memoryDir, "D:/memory");
|
|
85
|
+
auto();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("同一个项目在冷却期内不重跑(观测新鲜度,不是防重复写盘)", async () => {
|
|
89
|
+
const ctx = fakeCtx();
|
|
90
|
+
const calls = [];
|
|
91
|
+
let clock = 1_000_000;
|
|
92
|
+
const auto = createNudgeAutoRun({
|
|
93
|
+
ctx,
|
|
94
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
95
|
+
now: () => clock,
|
|
96
|
+
minIntervalMs: 10_000
|
|
97
|
+
});
|
|
98
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
99
|
+
await tick();
|
|
100
|
+
assert.equal(calls.length, 1);
|
|
101
|
+
// 冷却期内:再来几轮也不跑。
|
|
102
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
103
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
104
|
+
await tick();
|
|
105
|
+
assert.equal(calls.length, 1, "十分钟内同一个项目只跑一次");
|
|
106
|
+
// 过了冷却就该跑。
|
|
107
|
+
clock += 20_000;
|
|
108
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
109
|
+
await tick();
|
|
110
|
+
assert.equal(calls.length, 2);
|
|
111
|
+
auto();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("冷却按项目分账:另一个项目不受影响,也不许并成一次", async () => {
|
|
115
|
+
const ctx = fakeCtx();
|
|
116
|
+
const roots = [];
|
|
117
|
+
const auto = createNudgeAutoRun({
|
|
118
|
+
ctx,
|
|
119
|
+
runNudge: (args) => { roots.push(args.projectRoot); return { counts: { savedProposals: 0 } }; },
|
|
120
|
+
now: () => 1_000_000,
|
|
121
|
+
minIntervalMs: 10_000
|
|
122
|
+
});
|
|
123
|
+
ctx.emit(sessionAt("D:/a"), { type: "turn/end" });
|
|
124
|
+
ctx.emit(sessionAt("D:/b"), { type: "turn/end" });
|
|
125
|
+
assert.ok(await waitFor(() => roots.length === 2), "两个项目都该被跑到");
|
|
126
|
+
assert.deepEqual(roots.sort(), ["D:/a", "D:/b"], "两个项目各跑一次,跨项目不并账");
|
|
127
|
+
auto();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("冷却键不区分大小写与尾斜杠(Windows 同一个项目别算成两个)", async () => {
|
|
131
|
+
const ctx = fakeCtx();
|
|
132
|
+
const calls = [];
|
|
133
|
+
let clock = 1_000_000;
|
|
134
|
+
const auto = createNudgeAutoRun({
|
|
135
|
+
ctx,
|
|
136
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
137
|
+
now: () => clock,
|
|
138
|
+
minIntervalMs: 10_000
|
|
139
|
+
});
|
|
140
|
+
ctx.emit(sessionAt("D:/Proj"), { type: "turn/end" });
|
|
141
|
+
await tick();
|
|
142
|
+
ctx.emit(sessionAt("d:\\proj\\"), { type: "turn/end" });
|
|
143
|
+
await tick();
|
|
144
|
+
assert.equal(calls.length, 1, "同一个项目换个写法不该被当成新项目");
|
|
145
|
+
auto();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("挖挂了不许冒泡:session/event 那条链上还挂着摘要与续期", async () => {
|
|
149
|
+
const ctx = fakeCtx();
|
|
150
|
+
let ran = 0;
|
|
151
|
+
const auto = createNudgeAutoRun({
|
|
152
|
+
ctx,
|
|
153
|
+
logger: { info() {}, warn() {} },
|
|
154
|
+
runNudge: () => { ran += 1; throw new Error("boom"); },
|
|
155
|
+
now: () => 1_000_000
|
|
156
|
+
});
|
|
157
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
158
|
+
await assert.doesNotReject(tick().then(() => {}), "定时回调里的异常不许掀翻宿主");
|
|
159
|
+
assert.equal(ran, 1);
|
|
160
|
+
auto();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("没有 cwd 的会话不排队(归属不明就别猜)", async () => {
|
|
164
|
+
const ctx = fakeCtx();
|
|
165
|
+
const calls = [];
|
|
166
|
+
const auto = createNudgeAutoRun({
|
|
167
|
+
ctx,
|
|
168
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
169
|
+
now: () => 1_000_000
|
|
170
|
+
});
|
|
171
|
+
ctx.emit({ header: {} }, { type: "turn/end" });
|
|
172
|
+
ctx.emit(null, { type: "turn/end" });
|
|
173
|
+
await tick();
|
|
174
|
+
assert.deepEqual(calls, []);
|
|
175
|
+
assert.equal(auto.pendingCount(), 0);
|
|
176
|
+
auto();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("dispose 之后:取消订阅、清掉排队的活,runNow 也不再跑", async () => {
|
|
180
|
+
const ctx = fakeCtx();
|
|
181
|
+
const calls = [];
|
|
182
|
+
const auto = createNudgeAutoRun({
|
|
183
|
+
ctx,
|
|
184
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
185
|
+
now: () => 1_000_000
|
|
186
|
+
});
|
|
187
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
188
|
+
auto();
|
|
189
|
+
assert.equal(ctx.count(), 0, "订阅必须被摘掉");
|
|
190
|
+
assert.equal(auto.pendingCount(), 0, "排队的活必须被清掉");
|
|
191
|
+
await tick();
|
|
192
|
+
// 关键:dispose 之前排进 timer 的那一次也不许再跑(否则卸载后还会写盘)。
|
|
193
|
+
assert.deepEqual(calls, [], "卸载之后不许还有一次偷偷的写盘");
|
|
194
|
+
assert.equal(auto.runNow("D:/proj"), null);
|
|
195
|
+
assert.deepEqual(calls, []);
|
|
196
|
+
// 再发事件也不排队了。
|
|
197
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
198
|
+
await tick();
|
|
199
|
+
assert.deepEqual(calls, []);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("runNow 绕开冷却与 timer,手动入口可以直接驱动一次", () => {
|
|
203
|
+
const ctx = fakeCtx();
|
|
204
|
+
const calls = [];
|
|
205
|
+
const auto = createNudgeAutoRun({
|
|
206
|
+
ctx,
|
|
207
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
208
|
+
now: () => 1_000_000,
|
|
209
|
+
minIntervalMs: 10_000
|
|
210
|
+
});
|
|
211
|
+
const doc = auto.runNow("D:/proj");
|
|
212
|
+
assert.ok(doc, "手动入口要有同步回执");
|
|
213
|
+
assert.equal(calls.length, 1);
|
|
214
|
+
// runNow 刻意**不走冷却**:它是人显式要的一次跑,人比定时器知道自己要什么。
|
|
215
|
+
assert.ok(auto.runNow("D:/proj"));
|
|
216
|
+
assert.equal(calls.length, 2);
|
|
217
|
+
// 但它要把时间记上——否则紧接着的自动跑会立刻再来一次。
|
|
218
|
+
assert.equal(typeof auto.lastRunAt("D:/proj"), "number");
|
|
219
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
220
|
+
return tick().then(() => {
|
|
221
|
+
assert.equal(calls.length, 2, "runNow 之后冷却生效,自动跑不许立刻重跑");
|
|
222
|
+
auto();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -33,10 +33,12 @@ import {
|
|
|
33
33
|
loadProposals,
|
|
34
34
|
summarizeProposal,
|
|
35
35
|
retirementProposals,
|
|
36
|
-
nudgeFromSessions
|
|
36
|
+
nudgeFromSessions,
|
|
37
|
+
nudgeForProject,
|
|
38
|
+
resolveProjectSessionDir
|
|
37
39
|
} from "../src/pipeline-nudge.js";
|
|
38
40
|
|
|
39
|
-
// 这组测试守的是 M.O.
|
|
41
|
+
// 这组测试守的是 M.O. 的**沉淀链**:一条工作流能不能从真实数据里自己长出来。
|
|
40
42
|
// 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守:
|
|
41
43
|
// 1. 机器只写它真观察到的东西(措辞里不许出现编出来的判据,也不许漏本机路径);
|
|
42
44
|
// 2. 反事实判据的结论**永远不由机器给**(verdict 必须是 null,问题必须带着选项);
|
|
@@ -210,7 +212,7 @@ test("挖掘:至少两个不同会话才算候选;同一段路出现两次
|
|
|
210
212
|
});
|
|
211
213
|
});
|
|
212
214
|
|
|
213
|
-
test("
|
|
215
|
+
test("默认只挖能力级:内置工具的通用动作不算工作流(本机 520 份日志实测那是噪音)", () => {
|
|
214
216
|
withDir((dir) => {
|
|
215
217
|
const sessions = join(dir, "sessions");
|
|
216
218
|
for (const id of ["sess-a", "sess-b", "sess-c"]) {
|
|
@@ -221,7 +223,7 @@ test("默认只挖能力级:内置工具的通用动作不算手法(本机 5
|
|
|
221
223
|
const read = readSessionObservations({ sessionDir: sessions });
|
|
222
224
|
const segments = segmentObservations(read.observations);
|
|
223
225
|
assert.deepEqual(DEFAULT_NUDGE_KINDS, ["skill", "mcp"]);
|
|
224
|
-
// 三个会话都出现了 read → pwsh
|
|
226
|
+
// 三个会话都出现了 read → pwsh,支持度足够高,但它是通用动作习惯,不是项目工作流。
|
|
225
227
|
assert.deepEqual(mineRepeatedPatterns(segments), [], "默认口径下这类序列不该冒出来");
|
|
226
228
|
// 显式开工具级才挖得到——而且要把「同一步连着走」重新折叠起来。
|
|
227
229
|
const toolLevel = mineRepeatedPatterns(segments, { kinds: null });
|
|
@@ -522,7 +524,7 @@ test("清单行:反事实覆盖率与风险下限照实投影", () => {
|
|
|
522
524
|
counterfactual: { verdict: null }
|
|
523
525
|
};
|
|
524
526
|
const row = summarizeProposal(proposal);
|
|
525
|
-
// 19/19 与 6/19
|
|
527
|
+
// 19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。
|
|
526
528
|
assert.deepEqual(row.contrast, { answered: 1, total: 2 });
|
|
527
529
|
assert.equal(row.riskFloor, "R2", "整条的风险取下限里的最高档");
|
|
528
530
|
assert.equal(row.verdict, null, "判据没有结论时清单里也是 null,不显示一个假结论");
|
|
@@ -729,7 +731,7 @@ test("回归·空回答不算「已答」:一条都没接受就不许推进状
|
|
|
729
731
|
assert.equal(ok.applied.length, 1);
|
|
730
732
|
});
|
|
731
733
|
|
|
732
|
-
test("回归·跨工作区不并账:两个桶各一次不是「支持度 2
|
|
734
|
+
test("回归·跨工作区不并账:两个桶各一次不是「支持度 2 的同一工作流」", () => {
|
|
733
735
|
withDir((dir) => {
|
|
734
736
|
const sessions = join(dir, "sessions");
|
|
735
737
|
const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
|
|
@@ -1034,3 +1036,156 @@ test("回归·文件上限按排序截断,不按遍历顺序(选择偏差要
|
|
|
1034
1036
|
assert.equal(read.scannedFiles, 2);
|
|
1035
1037
|
});
|
|
1036
1038
|
});
|
|
1039
|
+
|
|
1040
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1041
|
+
// nudgeForProject:把「归属解析」和「挖掘」绑成一次调用(v0.9.3 接线的入口)。
|
|
1042
|
+
// 守两件事:没有归属就**不许跑**(不能退化成无归属普查),预览不许写盘。
|
|
1043
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
/** 造一个假 DSH_HOME:sessions/<桶>/<会话>/session.v4.jsonl.zstd,头行带 cwd。 */
|
|
1046
|
+
function fakeDshHome(dir, { bucket = "ws-bucket", cwd = null, sessions = ["sess-a"] } = {}) {
|
|
1047
|
+
const home = join(dir, "dsh-home");
|
|
1048
|
+
for (const id of sessions) {
|
|
1049
|
+
const sub = join(home, "sessions", bucket, id);
|
|
1050
|
+
mkdirSync(sub, { recursive: true });
|
|
1051
|
+
const head = JSON.stringify({ time: 1700000000000, cwd });
|
|
1052
|
+
const body = sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), pwsh("npm test")] });
|
|
1053
|
+
writeFileSync(join(sub, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from([head, body].join("\n"), "utf8")));
|
|
1054
|
+
}
|
|
1055
|
+
return home;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
test("nudgeForProject:归属解析不到就整跑跳过,绝不退化成「无归属普查」", () => {
|
|
1059
|
+
withDir((dir) => {
|
|
1060
|
+
const projectRoot = join(dir, "proj");
|
|
1061
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1062
|
+
const dshHome = fakeDshHome(dir, { cwd: join(dir, "somewhere-else") });
|
|
1063
|
+
const doc = nudgeForProject({ projectRoot, dshHome, memoryDir: join(dir, "memory"), persistProposals: true });
|
|
1064
|
+
assert.equal(doc.skipped, true);
|
|
1065
|
+
assert.equal(doc.skippedReason, "no-session-attribution");
|
|
1066
|
+
assert.equal(doc.attribution.reason, "no-bucket");
|
|
1067
|
+
assert.equal(doc.counts.candidates, 0);
|
|
1068
|
+
assert.equal(doc.counts.savedProposals, 0);
|
|
1069
|
+
// 边界话术:跳过与跑完必须是同一句(同一个 NUDGE_BOUNDARIES 常量)。
|
|
1070
|
+
assert.equal(doc.boundaries.autoActivate, false);
|
|
1071
|
+
assert.equal(doc.boundaries.writesOnlyDrafts, true);
|
|
1072
|
+
assert.equal(existsSync(join(dir, "memory", "proposals")), false, "没归属就不该建提议桶");
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("nudgeForProject:命中归属就真跑,预览(persistProposals:false)一个文件都不写", () => {
|
|
1077
|
+
withDir((dir) => {
|
|
1078
|
+
const projectRoot = join(dir, "proj");
|
|
1079
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1080
|
+
const memoryDir = join(dir, "memory");
|
|
1081
|
+
const dshHome = fakeDshHome(dir, { cwd: projectRoot });
|
|
1082
|
+
// 两个会话走同一段序列才够 NUDGE_MIN_SUPPORT=2(support 数的是**会话**,不是次数)。
|
|
1083
|
+
fakeDshHome(dir, { cwd: projectRoot, sessions: ["sess-b"] });
|
|
1084
|
+
|
|
1085
|
+
const preview = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: false });
|
|
1086
|
+
assert.equal(preview.skipped, false);
|
|
1087
|
+
assert.equal(preview.attribution.reason, "matched");
|
|
1088
|
+
assert.ok(preview.counts.candidates >= 1, "真数据里必须挖得出候选");
|
|
1089
|
+
// 这一条是接线的关键:面板每刷一次就 GET 一次,预览绝不能往 memoryDir 里塞文件。
|
|
1090
|
+
assert.equal(preview.counts.savedProposals, 0);
|
|
1091
|
+
assert.equal(existsSync(join(memoryDir, "proposals")), false, "预览不落盘");
|
|
1092
|
+
|
|
1093
|
+
const real = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: true });
|
|
1094
|
+
assert.equal(real.counts.savedProposals, real.counts.candidates);
|
|
1095
|
+
assert.ok(existsSync(real.proposalsDir));
|
|
1096
|
+
// 自动跑永不落草稿:materialize 默认 false,项目里不该多出 pipelines 目录。
|
|
1097
|
+
assert.equal(real.counts.draftsWritten, 0);
|
|
1098
|
+
assert.equal(existsSync(projectPipelineDir(projectRoot)), false);
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("nudgeForProject:「读不到」与「确实没有」必须分开报", () => {
|
|
1103
|
+
withDir((dir) => {
|
|
1104
|
+
const projectRoot = join(dir, "proj");
|
|
1105
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1106
|
+
// 桶在,但里面一个会话文件都没有 → 读不出任何 cwd → unreadable(故障),不是 no-bucket。
|
|
1107
|
+
const homeEmpty = join(dir, "home-empty");
|
|
1108
|
+
mkdirSync(join(homeEmpty, "sessions", "ws-bucket"), { recursive: true });
|
|
1109
|
+
assert.equal(resolveProjectSessionDir({ dshHome: homeEmpty, projectRoot }).reason, "unreadable");
|
|
1110
|
+
// 会话根根本不存在 → no-sessions-dir(新机器),同样是故障侧。
|
|
1111
|
+
const homeMissing = join(dir, "home-missing");
|
|
1112
|
+
assert.equal(resolveProjectSessionDir({ dshHome: homeMissing, projectRoot }).reason, "no-sessions-dir");
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("归属解析也吃 budgetMs:预算用光报 budget-exceeded,不冒充 no-bucket", () => {
|
|
1117
|
+
// 评审抓出:resolveProjectSessionDir 最多同步扫 500 个桶 × 3 个文件,原先完全不看
|
|
1118
|
+
// budgetMs,于是 PR 正文那句「真卡就调 budgetMs」对这段路径是空话。
|
|
1119
|
+
// 这里用「截止点已经过去」来构造,不依赖真实耗时,确定性。
|
|
1120
|
+
withDir((dir) => {
|
|
1121
|
+
const projectRoot = join(dir, "proj");
|
|
1122
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1123
|
+
const home = fakeDshHome(dir, { cwd: projectRoot });
|
|
1124
|
+
// 已经过期的截止点 → 连一个桶都不该扫。
|
|
1125
|
+
const stalled = resolveProjectSessionDir({ dshHome: home, projectRoot, deadline: Date.now() - 1 });
|
|
1126
|
+
assert.equal(stalled.reason, "budget-exceeded");
|
|
1127
|
+
assert.equal(stalled.sessionDir, null);
|
|
1128
|
+
assert.equal(stalled.filesRead, 0, "超预算时一个会话文件都不该读");
|
|
1129
|
+
// 不给 deadline = 扫到底,行为与改动前一致(测试与离线批处理要完整结果)。
|
|
1130
|
+
assert.equal(resolveProjectSessionDir({ dshHome: home, projectRoot }).reason, "matched");
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test("nudgeForProject:归属解析超预算时 skipped 并如实带 budget-exceeded", () => {
|
|
1135
|
+
withDir((dir) => {
|
|
1136
|
+
const projectRoot = join(dir, "proj");
|
|
1137
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1138
|
+
const home = fakeDshHome(dir, { cwd: projectRoot });
|
|
1139
|
+
// now 往前推 10s + budget 4s → 截止点在过去,模拟「预算已经被前面的活花掉了」。
|
|
1140
|
+
const doc = nudgeForProject({
|
|
1141
|
+
projectRoot,
|
|
1142
|
+
dshHome: home,
|
|
1143
|
+
memoryDir: join(dir, "memory"),
|
|
1144
|
+
now: Date.now() - 10000,
|
|
1145
|
+
budgetMs: 4000,
|
|
1146
|
+
persistProposals: false
|
|
1147
|
+
});
|
|
1148
|
+
assert.equal(doc.skipped, true);
|
|
1149
|
+
assert.equal(doc.skippedReason, "no-session-attribution");
|
|
1150
|
+
// 关键:原因必须能区别于「这个项目确实没跑过会话」(no-bucket)——否则一次
|
|
1151
|
+
// 扫不完会被读成「这个项目很干净」。
|
|
1152
|
+
assert.equal(doc.attribution.reason, "budget-exceeded");
|
|
1153
|
+
assert.equal(doc.counts.candidates, 0);
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("桶数超过 maxBuckets:先排序再截断,截断没命中报 bucket-limit 而不是 no-bucket", () => {
|
|
1158
|
+
// 评审抓出的第二个「读不到装成确实没有」:早前是边收边截(到 500 个就 break、
|
|
1159
|
+
// 然后才 sort),留下哪 500 个由文件系统遍历顺序决定;目标项目的桶一旦落在被
|
|
1160
|
+
// 截掉的那部分,就返回 no-bucket —— 一次截断被报成「这个项目没有会话」。
|
|
1161
|
+
withDir((dir) => {
|
|
1162
|
+
const projectRoot = join(dir, "proj");
|
|
1163
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1164
|
+
const elsewhere = join(dir, "somewhere-else");
|
|
1165
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
1166
|
+
// 四个桶:字母序 aaa-1, aaa-2, ws-bucket, zz-1。目标在 ws-bucket。
|
|
1167
|
+
// 无关桶里也放**真会话**(cwd 指向别处),这样 bucketsWithCwd > 0,
|
|
1168
|
+
// 唯一剩下的原因就是截断本身——否则会先命中 unreadable,测不到这条。
|
|
1169
|
+
const home = fakeDshHome(dir, { bucket: "ws-bucket", cwd: projectRoot });
|
|
1170
|
+
fakeDshHome(dir, { bucket: "aaa-1", cwd: elsewhere });
|
|
1171
|
+
fakeDshHome(dir, { bucket: "aaa-2", cwd: elsewhere });
|
|
1172
|
+
fakeDshHome(dir, { bucket: "zz-1", cwd: elsewhere });
|
|
1173
|
+
|
|
1174
|
+
// 只扫前 2 个(aaa-1、aaa-2):目标被挤出,且这不是「它没有会话」。
|
|
1175
|
+
const miss = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 2 });
|
|
1176
|
+
assert.equal(miss.reason, "bucket-limit", "被截断 ≠ 这个项目没有会话");
|
|
1177
|
+
assert.notEqual(miss.reason, "no-bucket");
|
|
1178
|
+
assert.equal(miss.bucketsTruncated, true);
|
|
1179
|
+
assert.equal(miss.bucketsScanned, 2);
|
|
1180
|
+
assert.equal(miss.bucketsTotal, 4, "总数与扫描数都要报,截断才看得见");
|
|
1181
|
+
|
|
1182
|
+
// 放宽到 3 个:字母序把 ws-bucket 包进来 → 命中。
|
|
1183
|
+
const hit = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 3 });
|
|
1184
|
+
assert.equal(hit.reason, "matched", "留下谁由字母序决定,不由遍历序决定");
|
|
1185
|
+
|
|
1186
|
+
// 不截断时依然是真答案那套,且 truncated 明确为 false。
|
|
1187
|
+
const full = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 999 });
|
|
1188
|
+
assert.equal(full.reason, "matched");
|
|
1189
|
+
assert.equal(full.bucketsTruncated, false);
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 两份 README 曾经都写着「默认全离线」「offline by default」「默认零网络依赖」——
|
|
2
|
+
// 这与代码默认值相反:autoDream 与 autoSummarize 默认开(src/config.js),
|
|
3
|
+
// 两者都把记忆正文交给 ctx.llm.stream(...)(src/dream.js / src/summarize.js);
|
|
4
|
+
// embedProvider 默认还是 "openai",只有显式改成 "local"(ONNX)才真正离线。
|
|
5
|
+
// 一句假话比不写更坏:读者会据此以为装完就能断网用,而它默认会把正文发给宿主模型。
|
|
6
|
+
// 所以这里锁两件事:一、绝对离线声明不许回来;二、隐私节必须写出「巩固走宿主模型」这个出站事实——
|
|
7
|
+
// 删掉假话后如果什么都不说,误导是一样的。
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
16
|
+
const rootReadmePath = path.join(repoRoot, "README.md");
|
|
17
|
+
|
|
18
|
+
// npm 的 files 清单把整个 test/ 打进包里,所以在 packed / installed 包里跑 npm test 时,
|
|
19
|
+
// 这个文件照样会被执行——但那里没有仓库根 README(只有包内 dsh-mneme/README.md)。
|
|
20
|
+
// 直接 readFileSync 会让整个测试文件在加载阶段 ENOENT 崩掉,把「不是这个仓」报成「断言失败」。
|
|
21
|
+
// 所以仓库根不在时显式 skip:这条守卫的适用面本来就只是仓库,不该在包内假红。
|
|
22
|
+
const hasRepoRoot = existsSync(rootReadmePath);
|
|
23
|
+
const rootReadme = hasRepoRoot ? readFileSync(rootReadmePath, "utf8") : "";
|
|
24
|
+
const pkgReadme = readFileSync(path.join(pkgRoot, "README.md"), "utf8");
|
|
25
|
+
const outsideRepo = hasRepoRoot ? {} : { skip: "not a repo checkout: 仓库根 README 不存在(包内安装态)" };
|
|
26
|
+
|
|
27
|
+
// 只锁「默认 / 无条件」的说法。带条件的离线表述(把 embedProvider 改成 local、离线巩固论文、
|
|
28
|
+
// 运行时离线取件)都是真话,不在这个列表里,否则守卫会把正确写法也判红。
|
|
29
|
+
const ABSOLUTE_CLAIMS = [
|
|
30
|
+
"默认全离线",
|
|
31
|
+
"默认零网络依赖",
|
|
32
|
+
"offline by default",
|
|
33
|
+
"Zero network dependency by default",
|
|
34
|
+
"完全离线的语义记忆引擎",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function section(text, heading) {
|
|
38
|
+
const start = text.indexOf(heading);
|
|
39
|
+
assert.ok(start >= 0, `README 里应有「${heading}」一节`);
|
|
40
|
+
const rest = text.slice(start + heading.length);
|
|
41
|
+
const next = rest.search(/\r?\n## /);
|
|
42
|
+
return next === -1 ? rest : rest.slice(0, next);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("两份 README 都不许再把「离线」写成默认行为", outsideRepo, () => {
|
|
46
|
+
for (const [name, text] of [
|
|
47
|
+
["README.md", rootReadme],
|
|
48
|
+
["dsh-mneme/README.md", pkgReadme],
|
|
49
|
+
]) {
|
|
50
|
+
for (const claim of ABSOLUTE_CLAIMS) {
|
|
51
|
+
assert.ok(!text.includes(claim), `${name} 仍含绝对离线声明:${claim}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事实", outsideRepo, () => {
|
|
57
|
+
const zh = section(rootReadme, "## 隐私承诺");
|
|
58
|
+
assert.ok(zh.includes("宿主模型"), "中文隐私节应说明巩固与摘要走宿主模型");
|
|
59
|
+
assert.ok(
|
|
60
|
+
zh.includes("autoDream") || zh.includes("autoSummarize"),
|
|
61
|
+
"中文隐私节应点名 autoDream / autoSummarize,否则读者不知道是谁在出站",
|
|
62
|
+
);
|
|
63
|
+
const en = section(rootReadme, "## Privacy");
|
|
64
|
+
assert.ok(
|
|
65
|
+
en.includes("host model"),
|
|
66
|
+
"English Privacy section should disclose that consolidation uses the host model",
|
|
67
|
+
);
|
|
68
|
+
});
|