@modusensus/dsh-mneme 0.9.2 → 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.
@@ -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 的同一手法」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。
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
- * 造出候选,并把别人的手法算到这个项目头上(复核报告 §2.6 实测过)。
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
- * · 运行记录与失败记录:如果这条手法已经有 runs,或者人喂了记忆侧的失败行,一并报数。
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 || `(待补)由重复观察提出的手法:${(candidate.names || []).join(" → ")}`,
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
- const read = readSessionObservations({ sessionDir: dir, limit: maxFiles });
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
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * M.O.(Modus Operandi · 手法)工作流的**运行账**。
2
+ * M.O.(Modus Operandi)工作流的**运行账**。
3
3
  *
4
4
  * 读侧(`src/pipeline.js`)已经能把「一条工作流跑了怎么样」算成四态,但账本一直没人写,
5
5
  * 于是 `runs` 恒为空数组、节点状态恒为 `candidate`。本模块补的就是这一环:把一次 run
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
- * 「全局手法」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
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");
package/src/scope.js CHANGED
@@ -14,6 +14,28 @@
14
14
  // 只关「自动标注」,不能连读取身份一起关,否则 strictScope 硬墙被静默拆掉
15
15
  // (fail-open)。
16
16
 
17
+ /**
18
+ * 免 inject 读取一个可选服务。cordis 4 的 ctx 是 Proxy,裸读未在 inject 声明的
19
+ * 属性会抛 "cannot get property ... without inject"(不是返回 undefined),所以
20
+ * 有 reflect 时优先走 ctx.reflect.get;旧宿主没有 reflect、ctx 是普通对象,裸读
21
+ * 此时安全——与 api.js:205 / index.js:583 的 webServer 同一形状。
22
+ *
23
+ * 服务缺失是合法状态(headless / 旧宿主 / 服务未激活),返回 undefined 让调用方
24
+ * 走既有降级路径,不告警:这不是故障。
25
+ * @param {object} ctx
26
+ * @param {string} name
27
+ * @returns {*}
28
+ */
29
+ function readOptionalService(ctx, name) {
30
+ try {
31
+ if (typeof ctx?.reflect?.get === "function") return ctx.reflect.get(name);
32
+ return ctx?.[name];
33
+ } catch {
34
+ // 无 reflect 却又是 Proxy(或 accessor 自身抛错):按「未提供」处理。
35
+ return undefined;
36
+ }
37
+ }
38
+
17
39
  /**
18
40
  * @param {object} deps
19
41
  * @param {object} [deps.ctx] 宿主上下文(可能带 workspaceRegistry;旧版 dsh 没有)
@@ -62,9 +84,16 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
62
84
 
63
85
  // workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
64
86
  // 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
87
+ //
88
+ // issue #487:这里曾经是裸读 ctx?.workspaceRegistry,而 workspaceRegistry 不在
89
+ // inject 声明里(index.js:45)。cordis 4 的 ctx 是 Proxy,裸读未注入属性会抛
90
+ // "cannot get property ... without inject" 而不返回 undefined——于是这层反查
91
+ // 从 v0.8.0 起一直抛错走降级,canonical 归一与成员资格校验静默失效,只剩
92
+ // header.cwd。改用免 inject 读取(同 api.js:205 / index.js:583 的 webServer
93
+ // 先例);不加进 inject 的理由见 index.js:39-44(v0.7.23 的 apply 阶段崩溃)。
65
94
  let workspaceScope = null;
66
95
  try {
67
- const list = ctx?.workspaceRegistry?.list?.();
96
+ const list = readOptionalService(ctx, "workspaceRegistry")?.list?.();
68
97
  const sessionId = session?.id;
69
98
  if (Array.isArray(list) && sessionId != null) {
70
99
  const hit = list.find(
package/src/settings.js CHANGED
@@ -209,7 +209,7 @@ const FEATURE_FLAG_BOOLEANS = [
209
209
  // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。
210
210
  // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。
211
211
  "distillRecords.enabled",
212
- // v0.9.0:M.O.(Modus Operandi · 手法,beta)客户端视图开关。客户端读 /features
212
+ // v0.9.0:M.O.(Modus Operandi,beta)客户端视图开关。客户端读 /features
213
213
  // 的 effective
214
214
  // 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把
215
215
  // 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。
@@ -219,6 +219,10 @@ const FEATURE_FLAG_BOOLEANS = [
219
219
  // 与上面那个键分开:那个管「看图的座位」,这个管「往账本里写」。默认关(beta、
220
220
  // opt-in),顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。
221
221
  "actionRunEnabled",
222
+ // v0.9.0 第三批:M.O. 自动沉淀(会话跑完一轮 → 挖这个项目自己的会话日志 → 落提议)。
223
+ // 与前两个分开:这个管「从日志里长出候选」,那个管记账、另一个管看图。默认关
224
+ // (beta、opt-in);顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。进 LIGHT_MODE_OFF。
225
+ "actionNudgeEnabled",
222
226
  // 人格定义页(beta):注入器渲染人格段的顶层总闸(双闸门的 config 半边;对象内
223
227
  // 的 persona.enabled 是 per-agent 半边,存 user_settings 的 "persona" 键,见
224
228
  // getPersona/setPersona)。默认关(beta、opt-in,老用户行为逐字节不变);不进