@modusensus/dsh-mneme 0.9.2 → 0.9.4
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 +11 -7
- package/docs/CONFIGURATION.md +9 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +178 -3
- package/lib/capabilities.js +1 -1
- package/lib/client.js +315 -27
- package/lib/config.js +70 -3
- package/lib/dream.js +1 -1
- package/lib/index.js +21 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +25 -0
- package/lib/organize.js +4 -2
- 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/profile-extract.js +161 -0
- package/lib/scope.js +30 -1
- package/lib/service.js +3 -2
- package/lib/settings.js +22 -14
- package/lib/summarize.js +64 -3
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api.js +178 -3
- package/src/capabilities.js +1 -1
- package/src/config.js +70 -3
- package/src/dream.js +1 -1
- package/src/index.js +21 -1
- package/src/inject.js +10 -25
- package/src/lang.js +25 -0
- package/src/organize.js +4 -2
- 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/profile-extract.js +161 -0
- package/src/scope.js +30 -1
- package/src/service.js +3 -2
- package/src/settings.js +22 -14
- package/src/summarize.js +64 -3
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +19 -1
- package/test/client.test.js +115 -7
- package/test/lang.test.js +11 -0
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/profile-extract.test.js +269 -0
- package/test/scope-optional-service.test.js +110 -0
- package/test/session-usage.test.js +11 -2
- package/test/summarize.test.js +225 -0
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
package/src/pipeline-nudge.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M.O. 沉淀链(nudge
|
|
2
|
+
* M.O. 沉淀链(nudge):让工作流**自己从数据里长出来**。
|
|
3
3
|
*
|
|
4
4
|
* 这一层补的是读侧之外缺的那一头。`pipeline.js` 能把人手写的 JSON 渲染成图、能从
|
|
5
|
-
*
|
|
5
|
+
* 运行记录算状态;但「一条新工作流从哪来」在那之前一直是零代码。本文件就是那个来源:
|
|
6
6
|
*
|
|
7
7
|
* 会话/记忆里的观察 → 反复出现的同一段序列(候选) → 反事实判据(请人回答)
|
|
8
8
|
* → status:"draft" 的草稿 →(人点头)→ active
|
|
@@ -79,9 +79,9 @@ export const NUDGE_MAX_CANDIDATES = 20;
|
|
|
79
79
|
*
|
|
80
80
|
* 这不是想当然定的,是拿本机 520 份真实会话日志跑出来的:工具级序列的支持度高得离谱
|
|
81
81
|
* (`read → pwsh` 出现在 322 个会话、`write → pwsh` 253 个),但它们全是 agent 的
|
|
82
|
-
*
|
|
82
|
+
* 通用动作习惯,不是项目工作流——按那个口径挖,推给人的前八条全是这类噪音。
|
|
83
83
|
* 能力级稀疏得多(真实数据里同一段能力序列只有两三个会话支持),但每一条都是
|
|
84
|
-
*
|
|
84
|
+
* 「有人特意选了这个技能」的选择,那才可能是工作流。
|
|
85
85
|
*
|
|
86
86
|
* 想挖工具级就显式传 `kinds: ["tool", ...]` 或 `kinds: null`(不过滤)——**别改默认值**,
|
|
87
87
|
* 默认值一改,面板第一屏就变回噪音。
|
|
@@ -91,6 +91,36 @@ export const DEFAULT_NUDGE_KINDS = ["skill", "mcp"];
|
|
|
91
91
|
/** 单次扫描最多读多少会话文件(目录膨胀时宁可报截断,也不要把一次刷新变成全盘扫描)。 */
|
|
92
92
|
const MAX_SESSION_FILES = 2000;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* 自动沉淀的**最小间隔**(同一个项目两次自动跑之间)。
|
|
96
|
+
*
|
|
97
|
+
* 为什么需要它:解压会话日志是**同步** I/O,本机 118 份日志实测 16.2s。挂到
|
|
98
|
+
* `session/event` 上如果每轮都跑,长会话就是把宿主主线程反复按住。十分钟这个数是
|
|
99
|
+
* 取的「够久到不会在同一个工作段里反复触发,够短到当天的新习惯还来得及被看见」——
|
|
100
|
+
* 它不是性能参数,是**观测新鲜度**参数;真卡的话先调 budgetMs(那一条保证不卡)。
|
|
101
|
+
*/
|
|
102
|
+
export const NUDGE_AUTO_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 自动沉淀的单次时间预算。超了就**带着已读到的部分收工**并如实标 `budgetExceeded`,
|
|
106
|
+
* 而不是把这一跑作废——半份观察好过零份,只要调用方知道它只看了后半截。
|
|
107
|
+
*/
|
|
108
|
+
export const NUDGE_AUTO_BUDGET_MS = 4000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 沉淀链的**边界**,写进每一份 return 的契约里。抽成一份常量是防「两条出口说法不一样」:
|
|
112
|
+
* 走完一整跑和提前收工给出的边界必须是同一句话,否则读契约的人得先猜自己拿到的是哪一种。
|
|
113
|
+
*/
|
|
114
|
+
export const NUDGE_BOUNDARIES = Object.freeze({
|
|
115
|
+
autoActivate: false,
|
|
116
|
+
autoAnswerCounterfactual: false,
|
|
117
|
+
writesOnlyDrafts: true,
|
|
118
|
+
// 机器给的风险只是下限;R3 永远只能由人给。
|
|
119
|
+
riskIsFloor: true,
|
|
120
|
+
// 支持度只在同一个工作区桶内累加;跨桶不并账。
|
|
121
|
+
crossProjectSupport: false
|
|
122
|
+
});
|
|
123
|
+
|
|
94
124
|
/** 会话日志文件名前缀。**不写死版本号**:本机同一棵树下并存 v4 / v3 / 无版本号三种。 */
|
|
95
125
|
const SESSION_FILE_PREFIX = "session";
|
|
96
126
|
|
|
@@ -139,6 +169,207 @@ export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homed
|
|
|
139
169
|
return join(String(dshHome), "sessions");
|
|
140
170
|
}
|
|
141
171
|
|
|
172
|
+
/**
|
|
173
|
+
* 路径规范化:只用来**比相等**,不用来读写文件(读写永远用原串)。
|
|
174
|
+
*
|
|
175
|
+
* 为什么要折:同一份工作目录在会话头里可能是 `C:\\Users\\x\\proj`、`c:/users/x/proj`、
|
|
176
|
+
* 末尾带不带分隔符三种写法之一,直接 `===` 会让归属解析**随机**命中或落空,而落空
|
|
177
|
+
* 的症状和「这个项目确实没会话」完全同形。
|
|
178
|
+
* 只在 win32 上折小写:POSIX 的文件名大小写敏感,在那里折小写会把
|
|
179
|
+
* `/a/Proj` 与 `/a/proj` 两个不同项目并成一个——那正是本仓最防的一类「合并两个项目的账」。
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeProjectPath(p) {
|
|
182
|
+
if (typeof p !== "string") return null;
|
|
183
|
+
const trimmed = p.trim();
|
|
184
|
+
if (trimmed === "") return null;
|
|
185
|
+
let s = trimmed.replace(/\\/g, "/");
|
|
186
|
+
// 去掉末尾分隔符,但根("/" 或 "C:/")要留着——去掉就不是一个路径了。
|
|
187
|
+
s = s.replace(/\/+$/, "");
|
|
188
|
+
if (/^[A-Za-z]:$/.test(s)) s += "/";
|
|
189
|
+
if (s === "") s = "/";
|
|
190
|
+
return process.platform === "win32" ? s.toLowerCase() : s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 一个会话文件里记的 `cwd`(会话日志首行的 `{"type":"session",...,"cwd":"…"}`)。
|
|
195
|
+
*
|
|
196
|
+
* 为什么读内容而不是反解桶名:桶名是工作区路径的**有损**变形(盘符后的 `-`、
|
|
197
|
+
* 空格被吃掉、中文按 UTF-16 码元转义),本机 19 个桶里反解实测多处对不上——
|
|
198
|
+
* 反解错的时候**不会报错**,只会把别的项目的账归到这个项目名下。会话头里的
|
|
199
|
+
* `cwd` 是宿主自己写的原文,没有歧义。
|
|
200
|
+
*
|
|
201
|
+
* 只解第一帧是性能选择(一份会话日志能到 1.5 MB、上百帧),但第一帧解不出时
|
|
202
|
+
* **继续扫后面几帧**:宁可多解几帧,也不要把「拿不准」报成「这个桶没有项目」。
|
|
203
|
+
*/
|
|
204
|
+
export function readSessionCwd(file) {
|
|
205
|
+
let buf;
|
|
206
|
+
try {
|
|
207
|
+
buf = readFileSync(file);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
let cwd = null;
|
|
212
|
+
const take = (text) => {
|
|
213
|
+
if (cwd) return;
|
|
214
|
+
for (const line of text.split("\n")) {
|
|
215
|
+
const t = line.trim();
|
|
216
|
+
// 快判:没有 "cwd" 的行连 JSON.parse 都不用做(一帧里绝大多数行是消息)。
|
|
217
|
+
if (!t || t[0] !== "{" || !t.includes('"cwd"')) continue;
|
|
218
|
+
try {
|
|
219
|
+
const obj = JSON.parse(t);
|
|
220
|
+
if (obj && typeof obj.cwd === "string" && obj.cwd.trim() !== "") {
|
|
221
|
+
cwd = obj.cwd;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// 半行/坏行:单行脏数据不该决定归属。
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const start = buf.indexOf(ZSTD_MAGIC, 0);
|
|
230
|
+
if (start !== -1) {
|
|
231
|
+
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
232
|
+
if (end === -1) end = buf.length;
|
|
233
|
+
try {
|
|
234
|
+
take(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
// 第一帧坏了:落到下面的整文件扫描。
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!cwd) forEachZstdFrame(buf, take);
|
|
240
|
+
buf.fill(0);
|
|
241
|
+
return cwd;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 这个项目**自己的**会话桶目录(`<DSH_HOME>/sessions/<工作区桶>`)。
|
|
246
|
+
*
|
|
247
|
+
* 为什么非要它:`nudgeFromSessions` 只有在拿到「本项目自己的 sessionDir」时才敢给候选
|
|
248
|
+
* 贴项目名、写提议桶、落草稿;给不出就会降级成「无归属普查」(见那里的 `refused`)。
|
|
249
|
+
* 而桶名 → 项目这条路,除了会话头里的 `cwd` 没有可信来源。
|
|
250
|
+
*
|
|
251
|
+
* 返回里 `reason` 是给人看的那句话:
|
|
252
|
+
* matched 找到了
|
|
253
|
+
* bad-project 调用方没给项目根 / 给的不是路径
|
|
254
|
+
* no-sessions-dir 会话根不存在(新机器、DSH 还没写过东西)
|
|
255
|
+
* no-bucket 扫完了,没有任何桶的会话头指向这个项目(这个项目确实还没跑过会话)
|
|
256
|
+
* unreadable 桶在,但所有会话头的 cwd 都读不出来——**这一条必须与 no-bucket 分开**:
|
|
257
|
+
* 前者是「读不到」,后者是「确实没有」,混起来就等于把故障报成空结果。
|
|
258
|
+
* budget-exceeded 预算用光还没扫完——同样**不是**关于这个项目的答案(见 deadline 参数)。
|
|
259
|
+
* bucket-limit 桶数超过 maxBuckets 被截断,且截断范围内没找到这个项目——**也不是**
|
|
260
|
+
* 「确实没有」:目标可能就在被截掉的那部分里(同日评审抓出)。
|
|
261
|
+
*
|
|
262
|
+
* `deadline` 是这次解析的绝对截止时刻(毫秒)。不给就扫到底(测试与离线批处理要完整
|
|
263
|
+
* 结果)。为什么要它:这一层是**同步**的,最多 500 个桶 × 3 个文件,全都走同步 I/O;
|
|
264
|
+
* 调用方手里的 `budgetMs` 原先只约束后面的挖掘,约束不到这里——一条「真卡就调
|
|
265
|
+
* budgetMs」的建议对这段路径是空话(v0.9.3 评审抓出)。
|
|
266
|
+
*/
|
|
267
|
+
export function resolveProjectSessionDir({
|
|
268
|
+
dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"),
|
|
269
|
+
projectRoot = null,
|
|
270
|
+
maxBuckets = 500,
|
|
271
|
+
filesPerBucket = 3,
|
|
272
|
+
deadline = null
|
|
273
|
+
} = {}) {
|
|
274
|
+
const target = normalizeProjectPath(projectRoot);
|
|
275
|
+
if (!target) {
|
|
276
|
+
return { sessionDir: null, bucket: null, reason: "bad-project", bucketsScanned: 0, filesRead: 0 };
|
|
277
|
+
}
|
|
278
|
+
const root = resolveSessionDir({ dshHome });
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
282
|
+
} catch {
|
|
283
|
+
return { sessionDir: null, bucket: null, reason: "no-sessions-dir", sessionsRoot: root, bucketsScanned: 0, filesRead: 0 };
|
|
284
|
+
}
|
|
285
|
+
const allBuckets = [];
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (!entry.isDirectory()) continue;
|
|
288
|
+
allBuckets.push(entry.name);
|
|
289
|
+
}
|
|
290
|
+
// 先收全、再排序、最后截断——与 findSessionFiles 同一条规矩。早前是「边收边截」:
|
|
291
|
+
// 到 500 个就 break,然后才 sort(),于是**留下哪 500 个由文件系统遍历顺序决定**。
|
|
292
|
+
// 目标项目的桶一旦落在被截掉的那部分,函数就返回 no-bucket——把一次截断报成
|
|
293
|
+
// 「这个项目确实没有会话」,正好是本文件反复在防的「读不到 vs 确实没有」
|
|
294
|
+
// (v0.9.3 评审抓出)。
|
|
295
|
+
allBuckets.sort();
|
|
296
|
+
const bucketsTruncated = allBuckets.length > maxBuckets;
|
|
297
|
+
const buckets = bucketsTruncated ? allBuckets.slice(0, maxBuckets) : allBuckets;
|
|
298
|
+
let filesRead = 0;
|
|
299
|
+
let bucketsWithCwd = 0;
|
|
300
|
+
const outOfBudget = () => deadline !== null && Number.isFinite(deadline) && Date.now() > deadline;
|
|
301
|
+
for (const bucket of buckets) {
|
|
302
|
+
// 桶循环里查预算:每个桶都可能要 readdir + 读 3 个文件,全同步,
|
|
303
|
+
// 一次解析跑满 500 个桶就是几百毫秒到几秒不等。
|
|
304
|
+
if (outOfBudget()) {
|
|
305
|
+
return {
|
|
306
|
+
sessionDir: null,
|
|
307
|
+
bucket: null,
|
|
308
|
+
reason: "budget-exceeded",
|
|
309
|
+
sessionsRoot: root,
|
|
310
|
+
bucketsScanned: buckets.length,
|
|
311
|
+
bucketsTotal: allBuckets.length,
|
|
312
|
+
bucketsTruncated,
|
|
313
|
+
filesRead
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const dir = join(root, bucket);
|
|
317
|
+
// 每个桶只读前几个文件:同一个桶里所有会话的 cwd 必然相同(桶是按工作区分的),
|
|
318
|
+
// 读三个只是给「第一个文件正好是截断的」留余量,不是要把整个桶扫一遍。
|
|
319
|
+
const found = findSessionFiles(dir, { limit: filesPerBucket });
|
|
320
|
+
for (const file of found.files.slice(0, filesPerBucket)) {
|
|
321
|
+
// 文件循环里也查:filesPerBucket 可被调用方调大,3 是最小值不是上限。
|
|
322
|
+
if (outOfBudget()) {
|
|
323
|
+
return {
|
|
324
|
+
sessionDir: null,
|
|
325
|
+
bucket: null,
|
|
326
|
+
reason: "budget-exceeded",
|
|
327
|
+
sessionsRoot: root,
|
|
328
|
+
bucketsScanned: buckets.length,
|
|
329
|
+
bucketsTotal: allBuckets.length,
|
|
330
|
+
bucketsTruncated,
|
|
331
|
+
filesRead
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const cwd = readSessionCwd(file);
|
|
335
|
+
filesRead += 1;
|
|
336
|
+
if (!cwd) continue;
|
|
337
|
+
bucketsWithCwd += 1;
|
|
338
|
+
if (normalizeProjectPath(cwd) === target) {
|
|
339
|
+
return {
|
|
340
|
+
sessionDir: dir,
|
|
341
|
+
bucket,
|
|
342
|
+
reason: "matched",
|
|
343
|
+
matchedCwd: cwd,
|
|
344
|
+
sessionsRoot: root,
|
|
345
|
+
bucketsScanned: buckets.length,
|
|
346
|
+
bucketsTotal: allBuckets.length,
|
|
347
|
+
bucketsTruncated,
|
|
348
|
+
filesRead
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
sessionDir: null,
|
|
355
|
+
bucket: null,
|
|
356
|
+
// 桶里一个 cwd 都读不出来 → 是读不了,不是没有。
|
|
357
|
+
// 截断且没命中 → 也不是「确实没有」:目标可能就在被截掉的那部分里。
|
|
358
|
+
reason: buckets.length === 0
|
|
359
|
+
? "no-sessions-dir"
|
|
360
|
+
: bucketsWithCwd === 0
|
|
361
|
+
? "unreadable"
|
|
362
|
+
: bucketsTruncated
|
|
363
|
+
? "bucket-limit"
|
|
364
|
+
: "no-bucket",
|
|
365
|
+
sessionsRoot: root,
|
|
366
|
+
bucketsScanned: buckets.length,
|
|
367
|
+
bucketsTotal: allBuckets.length,
|
|
368
|
+
bucketsTruncated,
|
|
369
|
+
filesRead
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
142
373
|
/**
|
|
143
374
|
* 逐帧解一个**多帧** zstd buffer,边解边回调。
|
|
144
375
|
*
|
|
@@ -235,7 +466,7 @@ export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) {
|
|
|
235
466
|
* `bucket` 是**工作区桶**(会话文件往上数第三层目录名)。为什么非要带上它:
|
|
236
467
|
* 会话目录在 DSH 侧按工作区分桶,`sessionId` 只是桶内那个会话目录名——两条不同工作区
|
|
237
468
|
* 里的会话会拿到两个不同的 `sessionId`,于是一段序列在两个项目里各出现一次,就会被
|
|
238
|
-
* 并成「支持度 2
|
|
469
|
+
* 并成「支持度 2 的同一工作流」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。
|
|
239
470
|
* 带上 bucket 之后,支持度只在同一个桶内累加。
|
|
240
471
|
*/
|
|
241
472
|
export function observationsFromCalls(calls, { sessionId = null, bucket = null } = {}) {
|
|
@@ -305,7 +536,7 @@ export function toolCallsInLine(line) {
|
|
|
305
536
|
* 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」
|
|
306
537
|
* 或「同一个项目」。
|
|
307
538
|
*/
|
|
308
|
-
export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES } = {}) {
|
|
539
|
+
export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null } = {}) {
|
|
309
540
|
const found = findSessionFiles(sessionDir, { limit });
|
|
310
541
|
const files = found.files.slice(0, limit);
|
|
311
542
|
const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length;
|
|
@@ -315,6 +546,9 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi
|
|
|
315
546
|
// 「目录在」与「目录真能列出来」分开报:否则「不可读」与「空目录」同形。
|
|
316
547
|
readable: found.present && found.unreadable.length === 0,
|
|
317
548
|
truncated: found.truncated === true || totalFiles > files.length,
|
|
549
|
+
// 时间预算用光而提前收工:与「文件数上限」是两种截断,必须分开报——否则
|
|
550
|
+
// 「扫了一部分」会伪装成「扫完了但没挖到」。面板与调用方据此说「只看了最近这些」。
|
|
551
|
+
budgetExceeded: false,
|
|
318
552
|
totalFiles,
|
|
319
553
|
scannedFiles: 0,
|
|
320
554
|
frames: 0,
|
|
@@ -326,6 +560,14 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi
|
|
|
326
560
|
};
|
|
327
561
|
|
|
328
562
|
for (const file of files) {
|
|
563
|
+
// 预算到点就停:解压是**同步**的,一次全量扫描会把宿主主线程按住十几秒
|
|
564
|
+
// (本机 118 份日志实测 16.2s)。宁可少看几份并如实报出来,也不能让一次
|
|
565
|
+
// 面板点击变成整窗卡死——那会被当成插件坏了。
|
|
566
|
+
if (deadline !== null && Date.now() > deadline) {
|
|
567
|
+
result.budgetExceeded = true;
|
|
568
|
+
result.truncated = true;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
329
571
|
let buf;
|
|
330
572
|
try {
|
|
331
573
|
buf = readFileSync(file);
|
|
@@ -387,7 +629,7 @@ export function segmentObservations(observations) {
|
|
|
387
629
|
/**
|
|
388
630
|
* 一段里某个位置的观察样本怎么并进模式:命令**取并**——风险下限要看过全部命令才敢说
|
|
389
631
|
* 「没命中兜底清单」,只看第一条会把第三条里的 `git push` 漏掉。
|
|
390
|
-
* 上限 12
|
|
632
|
+
* 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是工作流。
|
|
391
633
|
*/
|
|
392
634
|
const MAX_COMMANDS_PER_POSITION = 12;
|
|
393
635
|
|
|
@@ -580,12 +822,12 @@ export function filterSegmentsByKinds(segments, kinds) {
|
|
|
580
822
|
* **极大模式**的交代。
|
|
581
823
|
*
|
|
582
824
|
* 极大模式(maximal pattern):`A → B` 与 `A → B → C` 支持度相同时,只留长的那条。
|
|
583
|
-
* 理由:`A → B` 是 `A → B → C`
|
|
825
|
+
* 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条工作流说两遍。
|
|
584
826
|
* 判据是**包含 + 会话集是子集**——只按字符串包含会把「支持度更低的短模式」也吞掉。
|
|
585
827
|
*
|
|
586
828
|
* **支持度只在同一个工作区桶内累加**(`key` 前缀带 bucket):两个不同项目里各见过一次
|
|
587
|
-
* 的同一段序列,不是「支持度 2
|
|
588
|
-
*
|
|
829
|
+
* 的同一段序列,不是「支持度 2 的同一工作流」,而是两条各自只有一次的观察。混桶会凭空
|
|
830
|
+
* 造出候选,并把别人的工作流算到这个项目头上(复核报告 §2.6 实测过)。
|
|
589
831
|
*/
|
|
590
832
|
export function mineRepeatedPatterns(segments, { minSupport = NUDGE_MIN_SUPPORT, minLength = NUDGE_MIN_LENGTH, maxLength = NUDGE_MAX_LENGTH, maxResults = NUDGE_MAX_CANDIDATES, kinds = DEFAULT_NUDGE_KINDS } = {}) {
|
|
591
833
|
const scoped = filterSegmentsByKinds(segments, kinds);
|
|
@@ -786,7 +1028,7 @@ function runsForCandidate(candidate, runs) {
|
|
|
786
1028
|
* 能给的证据三类:
|
|
787
1029
|
* · 复现:同一段序列在几个会话、几次回合、什么时间跨度里出现过(硬事实);
|
|
788
1030
|
* · 缺席对照:中间那一步「走过几次 / 少走几次」(硬事实,见 omissionContrast);
|
|
789
|
-
* ·
|
|
1031
|
+
* · 运行记录与失败记录:如果这条工作流已经有 runs,或者人喂了记忆侧的失败行,一并报数。
|
|
790
1032
|
*
|
|
791
1033
|
* `failures` 传 `null`(默认)表示**记忆侧的失败记录还没接进来**,与「接进来了、
|
|
792
1034
|
* 确实一条都没有」是两件事,所以这里不给 `null`,而是给 `{ available, reason }`
|
|
@@ -905,12 +1147,12 @@ export function draftFromCandidate(candidate, { project = "", name = "", done =
|
|
|
905
1147
|
}
|
|
906
1148
|
const raw = {
|
|
907
1149
|
id: String(candidate.id || ""),
|
|
908
|
-
name: name ||
|
|
1150
|
+
name: name || `(待补)由重复观察提出的工作流:${(candidate.names || []).join(" → ")}`,
|
|
909
1151
|
project: project || TODO,
|
|
910
1152
|
version,
|
|
911
1153
|
status: "draft",
|
|
912
1154
|
done: done || `${TODO}整条怎么算完成`,
|
|
913
|
-
trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}
|
|
1155
|
+
trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条工作流` }],
|
|
914
1156
|
steps: candidate.steps.map((s, index) => ({
|
|
915
1157
|
id: s.id || `S${index + 1}`,
|
|
916
1158
|
phase: s.phase || "观察段",
|
|
@@ -1082,7 +1324,7 @@ export function summarizeProposal(proposal) {
|
|
|
1082
1324
|
occurrences: proposal.occurrences ?? null,
|
|
1083
1325
|
firstSeenAt: proposal.firstSeenAt || null,
|
|
1084
1326
|
lastSeenAt: proposal.lastSeenAt || null,
|
|
1085
|
-
// 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19
|
|
1327
|
+
// 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。
|
|
1086
1328
|
contrast: {
|
|
1087
1329
|
answered: (proposal.steps || []).filter((s) => Array.isArray(s.evidence) && s.evidence.length > 0).length,
|
|
1088
1330
|
total: (proposal.steps || []).length
|
|
@@ -1096,7 +1338,7 @@ export function summarizeProposal(proposal) {
|
|
|
1096
1338
|
/**
|
|
1097
1339
|
* 已经在跑的工作流的自精简提议:跳过 ≥3 次 → 提议删掉这一步;绕开 ≥3 次 → 提议重画路径。
|
|
1098
1340
|
* 计数与阈值全部来自 `pipeline.js`(那份是权威),这里只做两件事:把提议包成
|
|
1099
|
-
*
|
|
1341
|
+
* **要人点头的形状**(带一个封闭问题),并把它和「新工作流候选」放进同一条通路。
|
|
1100
1342
|
*/
|
|
1101
1343
|
export function retirementProposals(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
|
|
1102
1344
|
// 四类提议各自的封闭问题与选项。判据都不能自动算(每类都是),把答案空间
|
|
@@ -1160,6 +1402,74 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs
|
|
|
1160
1402
|
return { contract: "mneme.nudge/v1", pipelineId: pipeline.id, generatedAt: new Date(now).toISOString(), items };
|
|
1161
1403
|
}
|
|
1162
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* 一个项目的**自动沉淀入口**:先把项目解析到它自己的会话桶,再跑一次 `nudgeFromSessions`。
|
|
1407
|
+
*
|
|
1408
|
+
* 为什么这一层要单独存在:`nudgeFromSessions` 要求调用方**已经知道**这个项目的 `sessionDir`
|
|
1409
|
+
* (给了 projectRoot 却不给 sessionDir 会被降级成无归属普查)。而真实调用方手里只有一个
|
|
1410
|
+
* cwd。把「项目根 → 桶」和「跑一轮」焊在一起,是为了让调用方**没有机会**漏掉那一步而
|
|
1411
|
+
* 静默退化成普查——退化的输出看上去一切正常,只是候选全都不属于任何项目。
|
|
1412
|
+
*
|
|
1413
|
+
* 解析不到桶时**不跑**,直接回一份空结果并带上 `attribution.reason`:不是省事,是因为
|
|
1414
|
+
* 跑了也只能得到无归属普查,而它对「这个项目的工作流」这个问题的答案永远是错的。
|
|
1415
|
+
* `skipped: true` 是关键——空列表必须能区分「没跑」与「跑了但没挖到」,否则调用方会把
|
|
1416
|
+
* 一次归属失败当成「这个项目很干净」。
|
|
1417
|
+
*
|
|
1418
|
+
* `attribution` 参数允许调用方把**已经算好**的桶解析结果传进来:`/nudge` 路由要先做一遍
|
|
1419
|
+
* 解析才能决定要不要回 400,再传进来就跑两遍(一遍扫桶、一遍真挖)。
|
|
1420
|
+
*/
|
|
1421
|
+
export function nudgeForProject({
|
|
1422
|
+
projectRoot = null,
|
|
1423
|
+
dshHome = undefined,
|
|
1424
|
+
memoryDir = null,
|
|
1425
|
+
project = "",
|
|
1426
|
+
now = Date.now(),
|
|
1427
|
+
budgetMs = NUDGE_AUTO_BUDGET_MS,
|
|
1428
|
+
materialize = false,
|
|
1429
|
+
persistProposals = true,
|
|
1430
|
+
maxFiles = MAX_SESSION_FILES,
|
|
1431
|
+
kinds = DEFAULT_NUDGE_KINDS,
|
|
1432
|
+
attribution = null
|
|
1433
|
+
} = {}) {
|
|
1434
|
+
// 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的
|
|
1435
|
+
// attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。
|
|
1436
|
+
// 这里**自己算**截止点而不是多接一个 deadline 参数:两处各算一次会出现「解析用 A 截止、
|
|
1437
|
+
// 挖掘用 B 截止」的双预算,那种不一致以后没人查得出来。
|
|
1438
|
+
const budgetDeadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1439
|
+
const where = attribution || resolveProjectSessionDir({ dshHome, projectRoot, deadline: budgetDeadline });
|
|
1440
|
+
if (!where || !where.sessionDir) {
|
|
1441
|
+
return {
|
|
1442
|
+
contract: "mneme.nudge/v1",
|
|
1443
|
+
generatedAt: new Date(now).toISOString(),
|
|
1444
|
+
attribution: where || { sessionDir: null, bucket: null, reason: "bad-project" },
|
|
1445
|
+
skipped: true,
|
|
1446
|
+
skippedReason: "no-session-attribution",
|
|
1447
|
+
refused: null,
|
|
1448
|
+
source: null,
|
|
1449
|
+
segments: 0,
|
|
1450
|
+
kinds: Array.isArray(kinds) ? kinds.slice() : null,
|
|
1451
|
+
project: null,
|
|
1452
|
+
proposalsDir: null,
|
|
1453
|
+
counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
|
|
1454
|
+
available: [],
|
|
1455
|
+
drafts: [],
|
|
1456
|
+
boundaries: NUDGE_BOUNDARIES
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
const doc = nudgeFromSessions({
|
|
1460
|
+
sessionDir: where.sessionDir,
|
|
1461
|
+
projectRoot,
|
|
1462
|
+
memoryDir,
|
|
1463
|
+
project,
|
|
1464
|
+
now,
|
|
1465
|
+
maxFiles,
|
|
1466
|
+
kinds,
|
|
1467
|
+
materialize,
|
|
1468
|
+
persistProposals,
|
|
1469
|
+
budgetMs
|
|
1470
|
+
});
|
|
1471
|
+
return { ...doc, attribution: where, skipped: false };
|
|
1472
|
+
}
|
|
1163
1473
|
/**
|
|
1164
1474
|
* 一次完整的沉淀跑:读真实会话日志 → 挖候选 → 算能算的对照 → 把提议落进 memoryDir →
|
|
1165
1475
|
* (可选)把草稿落进项目。
|
|
@@ -1168,7 +1478,7 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs
|
|
|
1168
1478
|
* 会把「提议」变成「既成事实」。所以 `materialize` 默认 false——要落就得有人开口。
|
|
1169
1479
|
*
|
|
1170
1480
|
* **会话范围与项目归属是一件事,不是两件**:会话目录在 DSH 侧按工作区分桶,桶名是
|
|
1171
|
-
*
|
|
1481
|
+
* 工作区路径的有损变形、规则不在本仓。所以「这个项目的工作流」只有在调用方给出
|
|
1172
1482
|
* **这个项目自己的 `sessionDir`** 时才成立。给了 `projectRoot` 却没给 `sessionDir`
|
|
1173
1483
|
* 时,这一跑会被**降级成「无归属的普查」**:候选不带项目名、不写任何项目的提议桶、
|
|
1174
1484
|
* 不落草稿——绝不允许拿全机会话去冒充某个项目的账(复核报告 §2.6)。
|
|
@@ -1187,7 +1497,11 @@ export function nudgeFromSessions({
|
|
|
1187
1497
|
maxFiles = MAX_SESSION_FILES,
|
|
1188
1498
|
failures = null,
|
|
1189
1499
|
kinds = DEFAULT_NUDGE_KINDS,
|
|
1190
|
-
materialize = false
|
|
1500
|
+
materialize = false,
|
|
1501
|
+
budgetMs = 0,
|
|
1502
|
+
// 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件:
|
|
1503
|
+
// 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。
|
|
1504
|
+
persistProposals = true
|
|
1191
1505
|
} = {}) {
|
|
1192
1506
|
const scoped = typeof sessionDir === "string" && sessionDir.trim() !== "";
|
|
1193
1507
|
const dir = scoped ? sessionDir : resolveSessionDir({ dshHome });
|
|
@@ -1195,7 +1509,9 @@ export function nudgeFromSessions({
|
|
|
1195
1509
|
// 那同样是「拿一批跨项目的观察去贴一个项目名」)。
|
|
1196
1510
|
const attributionRefused = !scoped && Boolean(projectRoot || project);
|
|
1197
1511
|
const allowedProjectRoot = attributionRefused ? null : projectRoot;
|
|
1198
|
-
|
|
1512
|
+
// budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。
|
|
1513
|
+
const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1514
|
+
const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline });
|
|
1199
1515
|
const segments = segmentObservations(read.observations);
|
|
1200
1516
|
const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds });
|
|
1201
1517
|
const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : "");
|
|
@@ -1208,7 +1524,7 @@ export function nudgeFromSessions({
|
|
|
1208
1524
|
|
|
1209
1525
|
const propDir = attributionRefused ? null : proposalDir({ memoryDir, projectRoot: allowedProjectRoot });
|
|
1210
1526
|
const saved = [];
|
|
1211
|
-
if (propDir) {
|
|
1527
|
+
if (propDir && persistProposals !== false) {
|
|
1212
1528
|
for (const c of enriched) saved.push(saveProposal(propDir, c).id);
|
|
1213
1529
|
}
|
|
1214
1530
|
|
|
@@ -1244,6 +1560,7 @@ export function nudgeFromSessions({
|
|
|
1244
1560
|
present: read.present,
|
|
1245
1561
|
readable: read.readable,
|
|
1246
1562
|
truncated: read.truncated,
|
|
1563
|
+
budgetExceeded: read.budgetExceeded === true,
|
|
1247
1564
|
totalFiles: read.totalFiles,
|
|
1248
1565
|
scannedFiles: read.scannedFiles,
|
|
1249
1566
|
frames: read.frames,
|
|
@@ -1268,14 +1585,6 @@ export function nudgeFromSessions({
|
|
|
1268
1585
|
available: enriched.map(summarizeProposal),
|
|
1269
1586
|
drafts,
|
|
1270
1587
|
// 「这一刀不做的事」写进契约里,免得以后有人以为忘了做。
|
|
1271
|
-
boundaries:
|
|
1272
|
-
autoActivate: false,
|
|
1273
|
-
autoAnswerCounterfactual: false,
|
|
1274
|
-
writesOnlyDrafts: true,
|
|
1275
|
-
// 机器给的风险只是下限;R3 永远只能由人给。
|
|
1276
|
-
riskIsFloor: true,
|
|
1277
|
-
// 支持度只在同一个工作区桶内累加;跨桶不并账。
|
|
1278
|
-
crossProjectSupport: false
|
|
1279
|
-
}
|
|
1588
|
+
boundaries: NUDGE_BOUNDARIES
|
|
1280
1589
|
};
|
|
1281
1590
|
}
|
package/src/pipeline-runs.js
CHANGED
package/src/pipeline.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M.O.(Modus Operandi
|
|
2
|
+
* M.O.(Modus Operandi)工作流的**读侧**。
|
|
3
3
|
*
|
|
4
4
|
* 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库:
|
|
5
5
|
* 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退;
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。
|
|
17
17
|
* 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套
|
|
18
|
-
*
|
|
18
|
+
* 「全局工作流」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
|
|
19
19
|
* `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一
|
|
20
|
-
*
|
|
20
|
+
* 理由是接那些**跨项目复用**的工作流,而跨项目复用是一个要留痕的显式动作。
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
23
23
|
import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path";
|
|
@@ -33,7 +33,7 @@ const basenameWin32 = pathWin32.basename;
|
|
|
33
33
|
*
|
|
34
34
|
* 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天
|
|
35
35
|
* 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**:
|
|
36
|
-
*
|
|
36
|
+
* 一条工作流该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。
|
|
37
37
|
*/
|
|
38
38
|
export function defaultPipelineDir() {
|
|
39
39
|
return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines");
|