@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.
package/lib/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/lib/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,老用户行为逐字节不变);不进
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.9.2",
4
+ "version": "0.9.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/api.js CHANGED
@@ -18,6 +18,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
18
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
19
  import { projectKey } from "./candidates.js";
20
20
  import { readCandidateDoc } from "./candidates.js";
21
+ // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
22
+ // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
23
+ import {
24
+ resolveProjectSessionDir,
25
+ nudgeForProject,
26
+ NUDGE_BOUNDARIES,
27
+ NUDGE_AUTO_BUDGET_MS
28
+ } from "./pipeline-nudge.js";
21
29
 
22
30
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
23
31
  function sendJson(res, status, payload, headers = {}) {
@@ -1849,7 +1857,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1849
1857
  }
1850
1858
  });
1851
1859
 
1852
- // --- M.O.(Modus Operandi · 手法)工作流 ---
1860
+ // --- M.O.(Modus Operandi)工作流 ---
1853
1861
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1854
1862
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1855
1863
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
@@ -1992,6 +2000,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1992
2000
  }
1993
2001
  });
1994
2002
 
2003
+ // --- M.O. 自动沉淀(nudge)---
2004
+ // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。
2005
+ // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份
2006
+ // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘
2007
+ // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。
2008
+ //
2009
+ // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的
2010
+ // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。
2011
+ register({
2012
+ kind: "exact",
2013
+ path: "/api/dsh-mneme/nudge",
2014
+ policy: "token-write",
2015
+ handler(req, res) {
2016
+ const method = String(req.method || "GET").toUpperCase();
2017
+ // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按
2018
+ // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议;
2019
+ // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。
2020
+ // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死
2021
+ // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。
2022
+ const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT";
2023
+ if (!allowed) {
2024
+ sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" });
2025
+ return;
2026
+ }
2027
+ const run = (body) => {
2028
+ try {
2029
+ const url = new URL(req.url, "http://localhost");
2030
+ const project = url.searchParams.get("project");
2031
+ // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。
2032
+ if (project !== null && !isAbsolute(project)) {
2033
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2034
+ return;
2035
+ }
2036
+ // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个)
2037
+ // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。
2038
+ // POST(显式真跑)给三倍预算,面板预览给短预算。
2039
+ const previewMethod = method === "GET" || method === "HEAD";
2040
+ const startedAt = Date.now();
2041
+ const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3;
2042
+ const deadline = startedAt + budgetMs;
2043
+ // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」
2044
+ // 还是「会话目录读不到」——两者在候选列表上都表现为空。
2045
+ const where = resolveProjectSessionDir({ projectRoot: project, deadline });
2046
+ if (!where.sessionDir) {
2047
+ // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题**
2048
+ // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案;
2049
+ // no-bucket / bad-project 是**关于这个项目的答案**,回 200。
2050
+ const envProblem = where.reason === "no-sessions-dir"
2051
+ || where.reason === "unreadable"
2052
+ || where.reason === "budget-exceeded"
2053
+ || where.reason === "bucket-limit";
2054
+ sendJson(res, envProblem ? 503 : 200, {
2055
+ error: envProblem
2056
+ ? (where.reason === "budget-exceeded"
2057
+ ? "nudge-attribution-budget-exceeded"
2058
+ : where.reason === "bucket-limit"
2059
+ ? "nudge-attribution-bucket-limit"
2060
+ : "nudge-sessions-unreadable")
2061
+ : undefined,
2062
+ contract: "mneme.nudge/v1",
2063
+ attribution: where,
2064
+ skipped: true,
2065
+ skippedReason: "no-session-attribution",
2066
+ counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
2067
+ available: [],
2068
+ drafts: [],
2069
+ boundaries: NUDGE_BOUNDARIES
2070
+ });
2071
+ return;
2072
+ }
2073
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
2074
+ ? config.memoryDir
2075
+ : "~/.dsh/memory";
2076
+ const doc = nudgeForProject({
2077
+ projectRoot: project,
2078
+ // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。
2079
+ attribution: where,
2080
+ // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样
2081
+ // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。
2082
+ now: startedAt,
2083
+ memoryDir,
2084
+ // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在
2085
+ // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。
2086
+ persistProposals: method !== "GET" && method !== "HEAD",
2087
+ // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。
2088
+ materialize: body && body.materialize === true,
2089
+ // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded,
2090
+ // 也不能让一次面板刷新把宿主主线程按住十几秒。
2091
+ budgetMs
2092
+ });
2093
+ sendJson(res, 200, doc);
2094
+ } catch (error) {
2095
+ sendJson(res, 500, {
2096
+ error: "nudge-failed",
2097
+ detail: String((error && error.message) || error)
2098
+ });
2099
+ }
2100
+ };
2101
+ if (method === "PUT" || method === "POST") {
2102
+ readBody(req, res).then((text) => {
2103
+ // readBody 已经回过 413:null 就是「别再往下走」。
2104
+ if (text === null) return;
2105
+ run(parseBody(text));
2106
+ });
2107
+ return;
2108
+ }
2109
+ run(null);
2110
+ }
2111
+ });
2112
+
1995
2113
  return {
1996
2114
  routes: countRoutes(),
1997
2115
  dispose: () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * M.O.(Modus Operandi · 手法)能力清单的**读侧**。
2
+ * M.O.(Modus Operandi)能力清单的**读侧**。
3
3
  *
4
4
  * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。
5
5
  * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里
package/src/config.js CHANGED
@@ -880,7 +880,7 @@ export const Config = z.object({
880
880
  enforce: z.boolean().default(false)
881
881
  }).default({}),
882
882
 
883
- // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
883
+ // --- v0.9.0 M.O.(Modus Operandi,beta)--------------------------
884
884
  // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
885
885
  // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
886
886
  // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。
@@ -902,6 +902,20 @@ export const Config = z.object({
902
902
  // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。
903
903
  actionRunEnabled: z.boolean().default(false),
904
904
 
905
+ // M.O. 自动沉淀(v0.9.0 第三批):会话跑完一轮后,把这个项目自己的会话日志挖一遍,
906
+ // 把「反复出现的同一段能力序列」落成提议(memoryDir 下),可选再落一份 draft 草稿。
907
+ //
908
+ // 与上面两个键三件事分开:actionPipelineEnabled 管「看图的座位」,actionRunEnabled
909
+ // 管「往账本里写」,这个管「从日志里长出候选」。三个都是独立能力,混成一个键会让
910
+ // 「只想看图」的人顺手获得写盘能力。
911
+ //
912
+ // 默认关(beta 必须 opt-in),并且进 LIGHT_MODE_OFF:它挂在 session/event 热路径上,
913
+ // 且解压会话日志是**同步**的(本机 118 份实测 16.2s),轻量档最不该背这条路径。
914
+ // 触发侧另有 10 分钟/项目的冷却与 budgetMs 上限(见 src/pipeline-nudge.js 的
915
+ // NUDGE_AUTO_MIN_INTERVAL_MS 与 src/index.js 的装配注释)——旗标只是总闸。
916
+ // (冷却值以常量为准:早前这里写「30 分钟」,与实际 10 分钟不符。)
917
+ actionNudgeEnabled: z.boolean().default(false),
918
+
905
919
  // --- 人格定义页(beta)---------------------------------------------------
906
920
  // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona"
907
921
  // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective
@@ -962,7 +976,11 @@ const LIGHT_MODE_OFF = [
962
976
  // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向
963
977
  // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。
964
978
  // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。
965
- "actionRunEnabled"
979
+ "actionRunEnabled",
980
+ // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。
981
+ // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉;
982
+ // 用户显式勾选仍然赢(合并顺序同上面几条)。
983
+ "actionNudgeEnabled"
966
984
  ];
967
985
 
968
986
  /**
package/src/index.js CHANGED
@@ -24,6 +24,9 @@ import { createSettings } from "./settings.js";
24
24
  import { createCommandManager } from "./commands.js";
25
25
  import { createExecutionRecorder } from "./pipeline-runner.js";
26
26
  import { registerMoRunCommand } from "./pipeline-command.js";
27
+ // M.O. 自动沉淀的触发侧(冷却 / 排队 / 兜底)。挖掘与落盘在 pipeline-nudge.js,
28
+ // 这里只管「什么时候跑」。两者分开的理由见 src/pipeline-nudge-auto.js 文件头。
29
+ import { createNudgeAutoRun } from "./pipeline-nudge-auto.js";
27
30
  import { createVectorIndex } from "./vector-index.js";
28
31
  // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
29
32
  // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
@@ -590,6 +593,18 @@ export const apply = (ctx, config) => {
590
593
  disposers.push(api.dispose);
591
594
  }
592
595
 
596
+ // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条
597
+ // 「提议」,等人确认后才可能进仓库。默认关(actionNudgeEnabled)。
598
+ //
599
+ // 为什么位置在这里而不是和 M.O. 记账同段:`disposers` 声明在 :532,而记账块在它
600
+ // 之前(TDZ)——照抄过去会在插件加载时直接抛 ReferenceError。这里已在其后。
601
+ //
602
+ // 为什么挂 session/event 却又要进轻量档黑名单:它读的是 zstd 会话日志(同步解压),
603
+ // 是本插件里最重的一条后台路径;且它只产「提议」,关掉不影响记忆本身的正确性。
604
+ if (cfg.actionNudgeEnabled === true) {
605
+ disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger }));
606
+ }
607
+
593
608
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
594
609
  // integrations outside the DSH host. Persisted external_api settings win
595
610
  // over the bundle config (enabled/port); the Bearer token lives in the same
@@ -0,0 +1,126 @@
1
+ /**
2
+ * M.O. 自动沉淀的**触发侧**:让「这个项目又重复走了一遍同一套动作」变成自动发生的观察,
3
+ * 而不是等人想起来去点一下。
4
+ *
5
+ * 为什么单独一个文件:pipeline-nudge.js 是纯函数式的挖掘 / 落盘层(不碰宿主上下文,
6
+ * 测试可以直接驱动);触发时机、冷却、去重、错误兜底是**宿主接线**问题,混进那一层会
7
+ * 让它不再纯粹。
8
+ *
9
+ * 三条执行侧边界(口径与 pipeline-nudge.js 的 NUDGE_BOUNDARIES 一致):
10
+ * - 只产 draft,且**自动跑一律不落草稿**(materialize 恒 false)——往别人仓库里塞文件
11
+ * 必须是人的显式动作;
12
+ * - 落进 memoryDir 的只是「提议」(proposal),升格成工作流仍然要人批;
13
+ * - 一次跑不完就带着已读到的部分收工并如实标 budgetExceeded,**不重试、不排队补跑**:
14
+ * 沉淀是锦上添花,卡住宿主是事故。
15
+ */
16
+ import { projectRootOf } from "./pipeline-runner.js";
17
+ import { nudgeForProject, NUDGE_AUTO_BUDGET_MS, NUDGE_AUTO_MIN_INTERVAL_MS } from "./pipeline-nudge.js";
18
+
19
+ /**
20
+ * 冷却不是防重复写盘(saveProposal 同 id 覆盖,本来就幂等),而是**观测新鲜度**:
21
+ * 同一个项目的会话日志十分钟内不会长出新的重复模式,重跑只是把同样的解压开销再付一次。
22
+ */
23
+ export function createNudgeAutoRun({
24
+ ctx = null,
25
+ memoryDir = null,
26
+ logger = null,
27
+ now = () => Date.now(),
28
+ runNudge = nudgeForProject,
29
+ minIntervalMs = NUDGE_AUTO_MIN_INTERVAL_MS,
30
+ budgetMs = NUDGE_AUTO_BUDGET_MS,
31
+ maxFiles = null
32
+ } = {}) {
33
+ const log = logger || (ctx && ctx.logger) || null;
34
+ let disposed = false;
35
+ let timer = null;
36
+ // 每个项目一条冷却记录;键是归一化后的项目根。
37
+ const lastRunAt = new Map();
38
+ // 已排队但还没跑的项目:同一个项目连来十个 turn/end,只跑最后一次。
39
+ const pending = new Set();
40
+
41
+ const normalize = (p) => {
42
+ let s = String(p).replace(/\\/g, "/").replace(/\/+$/, "");
43
+ if (/^[a-zA-Z]:\//.test(s)) s = s.toLowerCase();
44
+ return s;
45
+ };
46
+
47
+ const runOne = (projectRoot) => {
48
+ pending.delete(projectRoot);
49
+ if (disposed) return null;
50
+ lastRunAt.set(normalize(projectRoot), now());
51
+ try {
52
+ const doc = runNudge({
53
+ projectRoot,
54
+ memoryDir,
55
+ // 自动跑:永不落草稿。它只在记忆库的 proposals 分桶里留提议。
56
+ materialize: false,
57
+ persistProposals: true,
58
+ budgetMs,
59
+ ...(maxFiles ? { maxFiles } : {})
60
+ });
61
+ // 静默是默认,但不许无声无息:只有真挖到东西时才说一句。
62
+ if (doc && doc.counts && doc.counts.savedProposals > 0) {
63
+ log?.info?.("[dsh-mneme] M.O. 自动沉淀:" + projectRoot + " 记录到 " + doc.counts.savedProposals + " 条重复模式(待人工确认)");
64
+ }
65
+ return doc;
66
+ } catch (error) {
67
+ // 自动跑的失败绝不能冒泡到 session/event——那条链上还挂着摘要与续期。
68
+ log?.warn?.("[dsh-mneme] M.O. 自动沉淀失败(" + projectRoot + "):" + String((error && error.message) || error));
69
+ return null;
70
+ }
71
+ };
72
+
73
+ // 队列里一个一个推进:一次 setTimeout 里连跑多个项目会把一帧拉长。
74
+ const flush = () => {
75
+ timer = null;
76
+ if (disposed) return;
77
+ const first = pending.values().next();
78
+ if (first.done) return;
79
+ runOne(first.value);
80
+ if (pending.size > 0 && !disposed) defer();
81
+ };
82
+
83
+ const defer = () => {
84
+ if (disposed || timer) return;
85
+ // 解压会话日志是同步 I/O(本机 118 份实测 16.2s)。绝不在这里直接跑:当前这一帧
86
+ // 是宿主的 turn/end 处理链,占住它等于让整个回合收尾卡住。setTimeout(0) 把它挪到
87
+ // 下一个宏任务,宿主先收尾。
88
+ timer = setTimeout(flush, 0);
89
+ if (typeof timer.unref === "function") timer.unref();
90
+ };
91
+
92
+ const schedule = (projectRoot) => {
93
+ const key = normalize(projectRoot);
94
+ const last = lastRunAt.get(key);
95
+ if (last !== undefined && now() - last < minIntervalMs) return;
96
+ if (pending.has(projectRoot)) return;
97
+ pending.add(projectRoot);
98
+ defer();
99
+ };
100
+
101
+ const onSessionEvent = (session, event) => {
102
+ if (disposed) return;
103
+ // 只认 turn/end:一轮结束才算一个观测窗口,中途记的是半截。
104
+ if (!event || event.type !== "turn/end") return;
105
+ const projectRoot = projectRootOf(session);
106
+ if (!projectRoot) return;
107
+ schedule(projectRoot);
108
+ };
109
+
110
+ const unsubscribe = ctx && typeof ctx.on === "function" ? ctx.on("session/event", onSessionEvent) : null;
111
+
112
+ const dispose = () => {
113
+ disposed = true;
114
+ pending.clear();
115
+ if (timer) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ if (typeof unsubscribe === "function") unsubscribe();
120
+ };
121
+ // 手动入口与测试都要能直接驱动一次(不起 timer、不走冷却)。
122
+ dispose.runNow = (projectRoot) => (disposed || !projectRoot ? null : runOne(projectRoot));
123
+ dispose.pendingCount = () => pending.size;
124
+ dispose.lastRunAt = (projectRoot) => lastRunAt.get(normalize(projectRoot));
125
+ return dispose;
126
+ }