@shgroup/dsh-serenity-hooks 1.45.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.45.1",
3
+ "version": "1.46.0",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -1915,14 +1915,16 @@ function waitAgentIdle(ctx, agent) {
1915
1915
  /**
1916
1916
  * skills-discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
1917
1917
  *
1918
- * 发现全部入口技能(原文全量,不截断):
1918
+ * 发现全部入口技能(原文全量,不截断)—— **现行三个来源**(原为四个,**来源 3 已于 2026-09-21 删除**):
1919
1919
  * 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义,
1920
1920
  * tiangong-serenity 的 .serenity 内容为 `tg-serenity`)—— 最高优先
1921
1921
  * 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
1922
1922
  * 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
1923
1923
  * (home-serenity / tg-serenity / pangu-serenity …,命名模式 `*-serenity`)
1924
- * 3. `.dsh/skills/*-serenity/SKILL.md` —— 自动扫描 ACC/harness 入口(acc-serenity 等)
1925
- * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口 → dsh 入口。
1924
+ * ~~3. `.dsh/skills/*-serenity/SKILL.md`(扫 ACC 装进 CCC 的技能副本)~~ —— 🔴 **已删**:
1925
+ * owner 2026-09-21 令「连模板和安装命令一起删」(B 案)。**判据、实测证据与日期逐字记在
1926
+ * `findEntrySkills()` 体内那一段注释里**——本头部只留结论,**不复述判据**(避免两处会漂的真相源)。
1927
+ * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口。
1926
1928
  *
1927
1929
  * 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
1928
1930
  */
@@ -1991,7 +1993,6 @@ function findEntrySkills(root) {
1991
1993
  }
1992
1994
  }
1993
1995
  for (const dir of scanSerenityDirs(resolve(root, ".opencode", "skills"))) add(join(dir, "SKILL.md"), "opencode");
1994
- for (const dir of scanSerenityDirs(resolve(root, ".dsh", "skills"))) add(join(dir, "SKILL.md"), "dsh");
1995
1996
  return out;
1996
1997
  }
1997
1998
  function truncateContent(content, maxChars) {
@@ -2153,11 +2154,13 @@ function mergeSkillNames(cccSkills, trajSkills) {
2153
2154
  * @param mdPath 轨迹的 SESSION.md 路径(**来自绑定的 `mdPath`**,不用 label 拼)
2154
2155
  * @param maxChars 总长上限(字符)
2155
2156
  * @param cccSkills CCC 级声明(`readTrajectorySkills`;缺省空 = 只有轨迹级,行为与 v1.43 一致)
2157
+ * @param onInjectedNames 观测回调(本次请求实际带上的 skill 名;**纯观测**,供用量统计用——
2158
+ * 回调抛错被吞,**不影响注入正文**)
2156
2159
  * @returns 注入正文;两条来源皆无声明 → `''`(空串 = 宿主不产出该 section 块);
2157
2160
  * SESSION.md 缺失/读失败 → 响亮提示(若同时有 CCC 级声明,则提示在前、正文随后——
2158
2161
  * **提示不因"别处有内容"而被吞掉**)
2159
2162
  */
2160
- function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS_MAX_CHARS, cccSkills = []) {
2163
+ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS_MAX_CHARS, cccSkills = [], onInjectedNames) {
2161
2164
  const abs = absoluteMdPath(root, mdPath);
2162
2165
  let trajNames = [];
2163
2166
  let notice = "";
@@ -2169,6 +2172,9 @@ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS
2169
2172
  }
2170
2173
  const names = mergeSkillNames(cccSkills, trajNames);
2171
2174
  if (names.length === 0) return notice;
2175
+ if (onInjectedNames) try {
2176
+ onInjectedNames(names);
2177
+ } catch {}
2172
2178
  const blocks = [];
2173
2179
  for (const name of names) {
2174
2180
  if (!isSafeSkillName(name)) {
@@ -2189,6 +2195,274 @@ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS
2189
2195
  const body = blocks.join("\n\n");
2190
2196
  return truncateContent(notice ? `${notice}\n\n${body}` : body, maxChars);
2191
2197
  }
2198
+ //#endregion
2199
+ //#region src/usage-stats.ts
2200
+ /**
2201
+ * usage-stats.ts — ACC 用量统计(**skill 加载次数** + **MSM 执行次数**;落该 CCC 的 `_tmp`)
2202
+ *
2203
+ * ## 所有者令(本模块的存在理由)
2204
+ *
2205
+ * 「我需要对 ACC 增加一个统计功能,包括 **skill 加载次数统计,msm 执行次数统计**;
2206
+ * 要求这个统计**自动生成在 CCC 的 `_tmp` 内的指定文件**」
2207
+ * +(对齐时确认)「**skill 加载**两个口径**都记、用字段区分**」「**只记名字、不记参数**」
2208
+ * 「**重启后读旧文件续算**」。
2209
+ *
2210
+ * ## 它解决什么麻烦(白话)
2211
+ *
2212
+ * 在此之前,「**哪些 skill 真的被加载过、哪些 MSM 真的在跑**」在 ACC 侧**一个数都没有** ——
2213
+ * 要判断「这东西还有人用吗」,只能靠翻会话记录**猜**。
2214
+ * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
2215
+ * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
2216
+ *
2217
+ * ## 🔴 铁律(与 `cro-log.ts` 同族)
2218
+ *
2219
+ * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
2220
+ * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
2221
+ * 调用点在 `tools/post-execute` 的中途 ⇒ **更不能抛**(否则等于用统计打断会话)。
2222
+ *
2223
+ * ## 🔴 只记名字(所有者令;也是本模块唯一的"不入盘"约束)
2224
+ *
2225
+ * **绝不写 `arguments`**:MSM 参数里可能有凭据 / 路径 / 正文。
2226
+ * ⇒ 本模块只保留**计数键**(skill 名 / MSM 名),并对其做**长度与数量上限**(见常量),
2227
+ * 使文件规模**结构上有界**(不需要轮转,也不会被异常名字撑爆)。
2228
+ *
2229
+ * ## 🔴 重启后必须续算(所有者令)
2230
+ *
2231
+ * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
2232
+ * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake-registry.ts` /
2233
+ * `cro-log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
2234
+ *
2235
+ * ## 口径(两个字段,勿合并)
2236
+ *
2237
+ * | 字段 | 含义 | 喂它的地方 |
2238
+ * |---|---|---|
2239
+ * | `skill.loads` | 模型**主动**调宿主 `skill` 工具(把某 skill 的 `SKILL.md` 全文载入) | `tools/post-execute`(工具名 = `skill`) |
2240
+ * | `skill.injections` | ACC **每请求注入**的 skill 段(`trajectory.skills` / 轨迹 frontmatter 声明) | `seams/system-prompt.ts` 的 trajectory-skills section 求值回调 |
2241
+ * | `msm` | `msm` 工具的一次执行,按 **MSM 名**分桶 | `tools/post-execute`(工具名 = `msm`) |
2242
+ *
2243
+ * 🔴 **两者差好几个数量级**:`loads` 是模型的**动作**(稀疏),`injections` 是**请求的属性**(稠密)。
2244
+ * 合并成一个数就**再也读不出**「这个 skill 是被人主动用的,还是只是被配上了」。
2245
+ */
2246
+ /** 落点目录(所有者令:CCC 的 `_tmp` 内) */
2247
+ const USAGE_STATS_SUBDIR = "_tmp";
2248
+ /** 固定文件名(所有者令「指定文件」;对齐时我取的名字,owner 未反对 ⇒ 定为常量) */
2249
+ const USAGE_STATS_FILENAME = "acc-usage.json";
2250
+ /** 每个桶的键数上限(**结构上界**,见文件头"只记名字"节) */
2251
+ const USAGE_MAX_KEYS_PER_BUCKET = 1e3;
2252
+ const USAGE_STATS_VERSION = 1;
2253
+ /** 统计文件绝对路径(**纯函数**) */
2254
+ function usageStatsPath(root) {
2255
+ return join(root, USAGE_STATS_SUBDIR, USAGE_STATS_FILENAME);
2256
+ }
2257
+ /** 空账本(**纯函数**) */
2258
+ function emptyUsageStats() {
2259
+ return {
2260
+ version: USAGE_STATS_VERSION,
2261
+ updatedAt: isoLocal(0),
2262
+ skill: {
2263
+ loads: {},
2264
+ injections: {}
2265
+ },
2266
+ msm: {}
2267
+ };
2268
+ }
2269
+ /** 单条计数形状校验(容忍手改:字段缺失/类型不符 ⇒ 丢弃该条,不阻断整档) */
2270
+ function asCounter(value) {
2271
+ const c = value;
2272
+ if (!c || typeof c !== "object") return null;
2273
+ if (typeof c.count !== "number" || !Number.isFinite(c.count) || c.count < 0) return null;
2274
+ const firstAt = typeof c.firstAt === "string" ? c.firstAt : "";
2275
+ const lastAt = typeof c.lastAt === "string" ? c.lastAt : firstAt;
2276
+ return {
2277
+ count: Math.floor(c.count),
2278
+ firstAt,
2279
+ lastAt
2280
+ };
2281
+ }
2282
+ /** 桶校验(非对象 ⇒ 空桶;逐键过滤非法条) */
2283
+ function asBucket(value) {
2284
+ const out = {};
2285
+ if (!value || typeof value !== "object") return out;
2286
+ for (const [k, v] of Object.entries(value)) {
2287
+ const c = asCounter(v);
2288
+ if (c) out[k] = c;
2289
+ }
2290
+ return out;
2291
+ }
2292
+ /**
2293
+ * 读取账本(**永不抛**;读坏 ⇒ 空账本 + 返回 error 文本,**不静默吞掉**)。
2294
+ * 文件不存在 = 正常初态(`error: null` 且空账本)。
2295
+ */
2296
+ function loadUsageStats(root) {
2297
+ const path = usageStatsPath(root);
2298
+ if (!existsSync(path)) return {
2299
+ stats: emptyUsageStats(),
2300
+ error: null
2301
+ };
2302
+ try {
2303
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
2304
+ const skill = parsed.skill ?? {};
2305
+ return {
2306
+ stats: {
2307
+ version: USAGE_STATS_VERSION,
2308
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : isoLocal(0),
2309
+ skill: {
2310
+ loads: asBucket(skill.loads),
2311
+ injections: asBucket(skill.injections)
2312
+ },
2313
+ msm: asBucket(parsed.msm)
2314
+ },
2315
+ error: null
2316
+ };
2317
+ } catch (err) {
2318
+ return {
2319
+ stats: emptyUsageStats(),
2320
+ error: `用量统计解析失败(${String(err?.message ?? err)})`
2321
+ };
2322
+ }
2323
+ }
2324
+ /** 键是否可记(非空 + 不超长;**不截断**——截断会造出一个不存在的名字) */
2325
+ function isRecordableUsageName(name) {
2326
+ return typeof name === "string" && name !== "" && [...name].length <= 120;
2327
+ }
2328
+ /**
2329
+ * 在桶上自增一格(**纯函数**:不改入参,返回新桶)。
2330
+ * 新键超桶上限 ⇒ **不新增**(但仍自增已有键)⇒ 键数**结构上有界**。
2331
+ */
2332
+ function bumpBucket(bucket, name, at, maxKeys = USAGE_MAX_KEYS_PER_BUCKET) {
2333
+ const prev = bucket[name];
2334
+ if (!prev && Object.keys(bucket).length >= maxKeys) return bucket;
2335
+ const next = prev ? {
2336
+ count: prev.count + 1,
2337
+ firstAt: prev.firstAt || at,
2338
+ lastAt: at
2339
+ } : {
2340
+ count: 1,
2341
+ firstAt: at,
2342
+ lastAt: at
2343
+ };
2344
+ return {
2345
+ ...bucket,
2346
+ [name]: next
2347
+ };
2348
+ }
2349
+ /** 从工具入参里取名字(首个命中的键胜出;取不到 ⇒ `null`) */
2350
+ function nameFromArgs(args, keys) {
2351
+ if (args === null || typeof args !== "object") return null;
2352
+ const a = args;
2353
+ for (const k of keys) if (isRecordableUsageName(a[k])) return a[k];
2354
+ return null;
2355
+ }
2356
+ /** `msm` 工具的名字键(`msm(name, args)` 的第一个参数) */
2357
+ const MSM_NAME_KEYS = ["name"];
2358
+ /** `skill` 工具的名字键(宿主 `skill` 工具用 `name`;另两个是防御性容错) */
2359
+ const SKILL_NAME_KEYS = [
2360
+ "name",
2361
+ "skill",
2362
+ "skillName"
2363
+ ];
2364
+ const NOTHING = {
2365
+ ok: true,
2366
+ error: null,
2367
+ recorded: null,
2368
+ name: null,
2369
+ wrote: false
2370
+ };
2371
+ /**
2372
+ * 读 → 自增 → 原子写(**本模块唯一写盘通道;永不抛**)。
2373
+ *
2374
+ * @param root CCC 根
2375
+ * @param mutate 在已载入的账本上做一次自增(返回值 = 本次记录的结论;返回 `NOTHING` 则不写盘)
2376
+ * @param nowMs 当前毫秒(可注入 ⇒ 便于测 firstAt/lastAt)
2377
+ */
2378
+ function writeThrough(root, mutate, nowMs = Date.now()) {
2379
+ const path = usageStatsPath(root);
2380
+ try {
2381
+ const at = isoLocal(nowMs);
2382
+ const loaded = loadUsageStats(root);
2383
+ const outcome = mutate(loaded.stats, at);
2384
+ if (outcome.recorded === null) return outcome;
2385
+ loaded.stats.updatedAt = at;
2386
+ mkdirSync(join(root, USAGE_STATS_SUBDIR), { recursive: true });
2387
+ const tmp = `${path}.tmp`;
2388
+ writeFileSync(tmp, `${JSON.stringify(loaded.stats, null, 2)}\n`, "utf-8");
2389
+ renameSync(tmp, path);
2390
+ return {
2391
+ ...outcome,
2392
+ ok: true,
2393
+ error: null,
2394
+ wrote: true
2395
+ };
2396
+ } catch (err) {
2397
+ return {
2398
+ ok: false,
2399
+ error: `用量统计写入失败(${String(err?.message ?? err)})`,
2400
+ recorded: null,
2401
+ name: null,
2402
+ wrote: false
2403
+ };
2404
+ }
2405
+ }
2406
+ /**
2407
+ * 记录一次工具调用的用量(**`tools/post-execute` 的喂入口**)。
2408
+ *
2409
+ * 只认两类工具名;其余一律 `NOTHING`(**不产生文件、不产生噪声**)。
2410
+ * 🔴 **不看 `arguments` 的其余内容**——只取名字键。
2411
+ */
2412
+ function recordToolUsage(root, toolName, args, nowMs = Date.now()) {
2413
+ if (toolName === "msm") {
2414
+ const name = nameFromArgs(args, MSM_NAME_KEYS);
2415
+ if (!name) return NOTHING;
2416
+ return writeThrough(root, (stats, at) => {
2417
+ stats.msm = bumpBucket(stats.msm, name, at);
2418
+ return {
2419
+ ok: true,
2420
+ error: null,
2421
+ recorded: "msm",
2422
+ name,
2423
+ wrote: false
2424
+ };
2425
+ }, nowMs);
2426
+ }
2427
+ if (toolName === "skill") {
2428
+ const name = nameFromArgs(args, SKILL_NAME_KEYS);
2429
+ if (!name) return NOTHING;
2430
+ return writeThrough(root, (stats, at) => {
2431
+ stats.skill.loads = bumpBucket(stats.skill.loads, name, at);
2432
+ return {
2433
+ ok: true,
2434
+ error: null,
2435
+ recorded: "skill.load",
2436
+ name,
2437
+ wrote: false
2438
+ };
2439
+ }, nowMs);
2440
+ }
2441
+ return NOTHING;
2442
+ }
2443
+ /**
2444
+ * 记录一次 **skill 注入**(每个请求的 skill 段装配时调用)。
2445
+ *
2446
+ * ⚠️ 与 `recordToolUsage` 不同,本函数的调用频度 = **请求频度**(稠密):
2447
+ * 调用方应**只在真的注入了非空内容时**调用(空 section 由宿主丢弃 ⇒ 不产生噪声)。
2448
+ * 一次调用只写一次盘(`names` 内的多个 skill 合并在**同一次**读改写里)。
2449
+ */
2450
+ function recordSkillInjections(root, names, nowMs = Date.now()) {
2451
+ const usable = names.filter((n) => isRecordableUsageName(n));
2452
+ if (usable.length === 0) return NOTHING;
2453
+ return writeThrough(root, (stats, at) => {
2454
+ let bucket = stats.skill.injections;
2455
+ for (const n of usable) bucket = bumpBucket(bucket, n, at);
2456
+ stats.skill.injections = bucket;
2457
+ return {
2458
+ ok: true,
2459
+ error: null,
2460
+ recorded: "skill.injection",
2461
+ name: usable.join(","),
2462
+ wrote: false
2463
+ };
2464
+ }, nowMs);
2465
+ }
2192
2466
  /** 验证窗口:当前时步 ± 1(容忍时钟漂移/生成延迟) */
2193
2467
  const TOTP_WINDOW = 1;
2194
2468
  /** 输出位数(标准 6 位) */
@@ -3159,7 +3433,7 @@ function registerTrajectorySkillSection(agent, root) {
3159
3433
  text: () => {
3160
3434
  const current = readLastBound(session);
3161
3435
  if (!current) return "";
3162
- return buildTrajectorySkillsSection(root, current.mdPath, TRAJECTORY_SKILLS_MAX_CHARS, readTrajectorySkills(root));
3436
+ return buildTrajectorySkillsSection(root, current.mdPath, TRAJECTORY_SKILLS_MAX_CHARS, readTrajectorySkills(root), (names) => recordSkillInjections(root, names));
3163
3437
  }
3164
3438
  });
3165
3439
  trajectorySkillAgents.add(key);
@@ -6364,6 +6638,9 @@ function registerKeeper(ctx, opts = {}) {
6364
6638
  const tracker = trackerFor(exec);
6365
6639
  const shouldRemind = skiffKeeper ? tracker.step(exec.name) : false;
6366
6640
  const downstream = await next();
6641
+ try {
6642
+ recordToolUsage(root, exec.name, exec.arguments);
6643
+ } catch {}
6367
6644
  const blocks = [];
6368
6645
  if (shouldRemind) {
6369
6646
  const code = tracker.ack();
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * skills-discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
3
3
  *
4
- * 发现全部入口技能(原文全量,不截断):
4
+ * 发现全部入口技能(原文全量,不截断)—— **现行三个来源**(原为四个,**来源 3 已于 2026-09-21 删除**):
5
5
  * 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义,
6
6
  * tiangong-serenity 的 .serenity 内容为 `tg-serenity`)—— 最高优先
7
7
  * 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
8
8
  * 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
9
9
  * (home-serenity / tg-serenity / pangu-serenity …,命名模式 `*-serenity`)
10
- * 3. `.dsh/skills/*-serenity/SKILL.md` —— 自动扫描 ACC/harness 入口(acc-serenity 等)
11
- * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口 → dsh 入口。
10
+ * ~~3. `.dsh/skills/*-serenity/SKILL.md`(扫 ACC 装进 CCC 的技能副本)~~ —— 🔴 **已删**:
11
+ * owner 2026-09-21 令「连模板和安装命令一起删」(B 案)。**判据、实测证据与日期逐字记在
12
+ * `findEntrySkills()` 体内那一段注释里**——本头部只留结论,**不复述判据**(避免两处会漂的真相源)。
13
+ * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口。
12
14
  *
13
15
  * 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
14
16
  */
@@ -81,8 +81,10 @@ export declare function mergeSkillNames(cccSkills: readonly string[], trajSkills
81
81
  * @param mdPath 轨迹的 SESSION.md 路径(**来自绑定的 `mdPath`**,不用 label 拼)
82
82
  * @param maxChars 总长上限(字符)
83
83
  * @param cccSkills CCC 级声明(`readTrajectorySkills`;缺省空 = 只有轨迹级,行为与 v1.43 一致)
84
+ * @param onInjectedNames 观测回调(本次请求实际带上的 skill 名;**纯观测**,供用量统计用——
85
+ * 回调抛错被吞,**不影响注入正文**)
84
86
  * @returns 注入正文;两条来源皆无声明 → `''`(空串 = 宿主不产出该 section 块);
85
87
  * SESSION.md 缺失/读失败 → 响亮提示(若同时有 CCC 级声明,则提示在前、正文随后——
86
88
  * **提示不因"别处有内容"而被吞掉**)
87
89
  */
88
- export declare function buildTrajectorySkillsSection(root: string, mdPath: string, maxChars?: number, cccSkills?: readonly string[]): string;
90
+ export declare function buildTrajectorySkillsSection(root: string, mdPath: string, maxChars?: number, cccSkills?: readonly string[], onInjectedNames?: (names: readonly string[]) => void): string;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * usage-stats.ts — ACC 用量统计(**skill 加载次数** + **MSM 执行次数**;落该 CCC 的 `_tmp`)
3
+ *
4
+ * ## 所有者令(本模块的存在理由)
5
+ *
6
+ * 「我需要对 ACC 增加一个统计功能,包括 **skill 加载次数统计,msm 执行次数统计**;
7
+ * 要求这个统计**自动生成在 CCC 的 `_tmp` 内的指定文件**」
8
+ * +(对齐时确认)「**skill 加载**两个口径**都记、用字段区分**」「**只记名字、不记参数**」
9
+ * 「**重启后读旧文件续算**」。
10
+ *
11
+ * ## 它解决什么麻烦(白话)
12
+ *
13
+ * 在此之前,「**哪些 skill 真的被加载过、哪些 MSM 真的在跑**」在 ACC 侧**一个数都没有** ——
14
+ * 要判断「这东西还有人用吗」,只能靠翻会话记录**猜**。
15
+ * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
16
+ * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
17
+ *
18
+ * ## 🔴 铁律(与 `cro-log.ts` 同族)
19
+ *
20
+ * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
21
+ * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
22
+ * 调用点在 `tools/post-execute` 的中途 ⇒ **更不能抛**(否则等于用统计打断会话)。
23
+ *
24
+ * ## 🔴 只记名字(所有者令;也是本模块唯一的"不入盘"约束)
25
+ *
26
+ * **绝不写 `arguments`**:MSM 参数里可能有凭据 / 路径 / 正文。
27
+ * ⇒ 本模块只保留**计数键**(skill 名 / MSM 名),并对其做**长度与数量上限**(见常量),
28
+ * 使文件规模**结构上有界**(不需要轮转,也不会被异常名字撑爆)。
29
+ *
30
+ * ## 🔴 重启后必须续算(所有者令)
31
+ *
32
+ * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
33
+ * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake-registry.ts` /
34
+ * `cro-log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
35
+ *
36
+ * ## 口径(两个字段,勿合并)
37
+ *
38
+ * | 字段 | 含义 | 喂它的地方 |
39
+ * |---|---|---|
40
+ * | `skill.loads` | 模型**主动**调宿主 `skill` 工具(把某 skill 的 `SKILL.md` 全文载入) | `tools/post-execute`(工具名 = `skill`) |
41
+ * | `skill.injections` | ACC **每请求注入**的 skill 段(`trajectory.skills` / 轨迹 frontmatter 声明) | `seams/system-prompt.ts` 的 trajectory-skills section 求值回调 |
42
+ * | `msm` | `msm` 工具的一次执行,按 **MSM 名**分桶 | `tools/post-execute`(工具名 = `msm`) |
43
+ *
44
+ * 🔴 **两者差好几个数量级**:`loads` 是模型的**动作**(稀疏),`injections` 是**请求的属性**(稠密)。
45
+ * 合并成一个数就**再也读不出**「这个 skill 是被人主动用的,还是只是被配上了」。
46
+ */
47
+ /** 落点目录(所有者令:CCC 的 `_tmp` 内) */
48
+ export declare const USAGE_STATS_SUBDIR = "_tmp";
49
+ /** 固定文件名(所有者令「指定文件」;对齐时我取的名字,owner 未反对 ⇒ 定为常量) */
50
+ export declare const USAGE_STATS_FILENAME = "acc-usage.json";
51
+ /** 单个计数的形态 */
52
+ export interface UsageCounter {
53
+ /** 累计次数 */
54
+ count: number;
55
+ /** 首次计入时刻(当地 RFC3339 带偏移,遵 D67) */
56
+ firstAt: string;
57
+ /** 最近一次计入时刻 */
58
+ lastAt: string;
59
+ }
60
+ /** 文件形态(`{version, updatedAt, skill:{loads,injections}, msm}`) */
61
+ export interface UsageStats {
62
+ version: number;
63
+ /** 最近一次写盘时刻 */
64
+ updatedAt: string;
65
+ skill: {
66
+ /** 模型主动调 `skill` 工具(全文载入) */
67
+ loads: Record<string, UsageCounter>;
68
+ /** ACC 每请求注入的 skill 段 */
69
+ injections: Record<string, UsageCounter>;
70
+ };
71
+ /** 按 MSM 名分桶的执行次数 */
72
+ msm: Record<string, UsageCounter>;
73
+ }
74
+ /** 计数键长度上限(防异常名字把文件撑大;超长 ⇒ **不记**,不截断成假名字) */
75
+ export declare const USAGE_NAME_MAX_CHARS = 120;
76
+ /** 每个桶的键数上限(**结构上界**,见文件头"只记名字"节) */
77
+ export declare const USAGE_MAX_KEYS_PER_BUCKET = 1000;
78
+ /** 统计文件绝对路径(**纯函数**) */
79
+ export declare function usageStatsPath(root: string): string;
80
+ /** 空账本(**纯函数**) */
81
+ export declare function emptyUsageStats(): UsageStats;
82
+ /**
83
+ * 读取账本(**永不抛**;读坏 ⇒ 空账本 + 返回 error 文本,**不静默吞掉**)。
84
+ * 文件不存在 = 正常初态(`error: null` 且空账本)。
85
+ */
86
+ export declare function loadUsageStats(root: string): {
87
+ stats: UsageStats;
88
+ error: string | null;
89
+ };
90
+ /** 键是否可记(非空 + 不超长;**不截断**——截断会造出一个不存在的名字) */
91
+ export declare function isRecordableUsageName(name: unknown): name is string;
92
+ /**
93
+ * 在桶上自增一格(**纯函数**:不改入参,返回新桶)。
94
+ * 新键超桶上限 ⇒ **不新增**(但仍自增已有键)⇒ 键数**结构上有界**。
95
+ */
96
+ export declare function bumpBucket(bucket: Record<string, UsageCounter>, name: string, at: string, maxKeys?: number): Record<string, UsageCounter>;
97
+ /** 从工具入参里取名字(首个命中的键胜出;取不到 ⇒ `null`) */
98
+ export declare function nameFromArgs(args: unknown, keys: readonly string[]): string | null;
99
+ /** `msm` 工具的名字键(`msm(name, args)` 的第一个参数) */
100
+ export declare const MSM_NAME_KEYS: readonly ["name"];
101
+ /** `skill` 工具的名字键(宿主 `skill` 工具用 `name`;另两个是防御性容错) */
102
+ export declare const SKILL_NAME_KEYS: readonly ["name", "skill", "skillName"];
103
+ /** 一次记录的结论(供调用方决定要不要留痕;**本模块不自己打日志**) */
104
+ export interface UsageRecordResult {
105
+ ok: boolean;
106
+ error: string | null;
107
+ /** 记入的是哪一类;`null` = 本次没有可记的东西(不算失败) */
108
+ recorded: 'skill.load' | 'skill.injection' | 'msm' | null;
109
+ /** 记入的键(`recorded` 为 `null` 时也为 `null`) */
110
+ name: string | null;
111
+ /** 本次是否真的落盘(`recorded` 非空即 true;失败时 false) */
112
+ wrote: boolean;
113
+ }
114
+ /**
115
+ * 记录一次工具调用的用量(**`tools/post-execute` 的喂入口**)。
116
+ *
117
+ * 只认两类工具名;其余一律 `NOTHING`(**不产生文件、不产生噪声**)。
118
+ * 🔴 **不看 `arguments` 的其余内容**——只取名字键。
119
+ */
120
+ export declare function recordToolUsage(root: string, toolName: string, args: unknown, nowMs?: number): UsageRecordResult;
121
+ /**
122
+ * 记录一次 **skill 注入**(每个请求的 skill 段装配时调用)。
123
+ *
124
+ * ⚠️ 与 `recordToolUsage` 不同,本函数的调用频度 = **请求频度**(稠密):
125
+ * 调用方应**只在真的注入了非空内容时**调用(空 section 由宿主丢弃 ⇒ 不产生噪声)。
126
+ * 一次调用只写一次盘(`names` 内的多个 skill 合并在**同一次**读改写里)。
127
+ */
128
+ export declare function recordSkillInjections(root: string, names: readonly string[], nowMs?: number): UsageRecordResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.45.1",
3
+ "version": "1.46.0",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2。",
5
5
  "license": "MIT",
6
6
  "repository": {