@modusensus/dsh-mneme 0.9.0 → 0.9.1

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.
Files changed (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
package/lib/config.js CHANGED
@@ -415,6 +415,13 @@ export const Config = z.object({
415
415
  // in the background after a short delay, rate-limited in batches. On by
416
416
  // default; set false to keep the backfill manual only.
417
417
  autoReindexOnBoot: z.boolean().default(true),
418
+ // Backfill pacing (previously hardcoded 500 / 10 / 200 in semantic.js). A
419
+ // fixed 500-per-boot cap meant a 4k-row backlog needed nine restarts to
420
+ // clear, and nothing reported that the run had been capped.
421
+ // reindexMaxPerBoot = 0 means "no cap, drain the whole backlog this run".
422
+ reindexMaxPerBoot: z.natural().default(500),
423
+ reindexBatchSize: z.natural().min(1).max(64).default(10),
424
+ reindexRateLimitMs: z.natural().default(200),
418
425
  // Semantic-first injection (Bug4): when enabled, injectCandidates with a
419
426
  // non-empty query recalls via the vector index first and falls back to the
420
427
  // rule-based pick to fill/dedupe. Empty query / no vector → legacy behavior.
@@ -867,7 +874,27 @@ export const Config = z.object({
867
874
  // 它只是只读视图、不引入任何重型后台路径,轻量档用户显式勾选仍然赢(预设
868
875
  // 该关的是重型能力,不该替用户关掉一个纯 UI 入口)。
869
876
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
870
- actionPipelineEnabled: z.boolean().default(false)
877
+ actionPipelineEnabled: z.boolean().default(false),
878
+
879
+ // M.O. 执行记账(v0.9.0 第二批):`/mo-run` 显式开一次执行窗口,窗口内的命令执行
880
+ // 才可能被记成一条 run。与上面那个键**分开**:上面管「看图的座位」,这个管
881
+ // 「往账本里写」——看图和写账是两件事,混成一个键会让「只想看图」的人不知不觉
882
+ // 获得写入能力。
883
+ //
884
+ // 默认关(beta 必须 opt-in)。它挂在热路径上(每个会话事件都过一次监听),
885
+ // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。
886
+ actionRunEnabled: z.boolean().default(false),
887
+
888
+ // --- 人格定义页(beta)---------------------------------------------------
889
+ // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona"
890
+ // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective
891
+ // 必须含这个键——schema 里没有它 effective 就静默少键,面板开关形同虚设
892
+ // (#254 漏登记 NESTED_FLAG_PATHS 是同一个坑,计数锁 test/api.test.js 钉住)。
893
+ // 默认关:beta 功能必须 opt-in,老用户行为逐字节不变。不进 LIGHT_MODE_OFF——
894
+ // 人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台路径,轻量档用户
895
+ // 显式勾选仍然赢(同 actionPipelineEnabled 的定性)。
896
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
897
+ personaEnabled: z.boolean().default(false)
871
898
  });
872
899
 
873
900
  // Fields forced to false by the light-mode preset. Everything not listed here
@@ -914,7 +941,11 @@ const LIGHT_MODE_OFF = [
914
941
  // #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面),
915
942
  // 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是
916
943
  // 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。
917
- "distillRecords.enabled"
944
+ "distillRecords.enabled",
945
+ // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向
946
+ // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。
947
+ // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。
948
+ "actionRunEnabled"
918
949
  ];
919
950
 
920
951
  /**
package/lib/embedding.js CHANGED
@@ -69,9 +69,51 @@ export async function embedText({ baseUrl, apiKey, model }, text) {
69
69
  * successful embed the model that produced the vectors is recorded, so the
70
70
  * index can detect drift and the auto-reindex backfill knows what to rebuild.
71
71
  */
72
+ /**
73
+ * Bounded receipt ring for embedding attempts.
74
+ *
75
+ * Why a ring instead of llm_audit_logs: a full backfill is thousands of
76
+ * attempts, and one audit row each would bury the LLM calls that table exists
77
+ * to trace. What the user actually needs in order to answer "is the vector
78
+ * layer silently dead right now?" is the recent window plus running totals —
79
+ * which costs nothing and cannot flood.
80
+ */
81
+ const RECEIPT_LIMIT = 200;
82
+
83
+ function createReceiptLedger() {
84
+ const recent = [];
85
+ const totals = { ok: 0, failed: 0, skipped: 0 };
86
+ let lastOkAt = null;
87
+ let lastFailAt = null;
88
+ let lastFailReason = null;
89
+ return {
90
+ record(entry) {
91
+ recent.push(entry);
92
+ if (recent.length > RECEIPT_LIMIT) recent.shift();
93
+ if (entry.status in totals) totals[entry.status]++;
94
+ if (entry.status === "ok") lastOkAt = entry.at;
95
+ else if (entry.status === "failed") {
96
+ lastFailAt = entry.at;
97
+ lastFailReason = entry.reason ?? null;
98
+ }
99
+ },
100
+ snapshot() {
101
+ return {
102
+ totals: { ...totals },
103
+ lastOkAt,
104
+ lastFailAt,
105
+ lastFailReason,
106
+ recent: recent.slice(-RECEIPT_LIMIT)
107
+ };
108
+ }
109
+ };
110
+ }
111
+
72
112
  export function createEmbedder({ store, settings, logger, vectorIndex }) {
73
113
  // Dimension of the most recent successful embed, exposed for fingerprinting.
74
114
  let _dimension = 0;
115
+ // Per-write embed receipts (see createReceiptLedger above).
116
+ const ledger = createReceiptLedger();
75
117
 
76
118
  /** Record the producing model fingerprint in vector_meta (best-effort). */
77
119
  function markModel(cfg, dimension) {
@@ -85,19 +127,52 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
85
127
  // ready/configured 两个 getter 共用,避免以后改一处漏一处。
86
128
  const configComplete = (cfg) => !!(cfg?.enabled && cfg.baseUrl && cfg.apiKey && cfg.model);
87
129
 
130
+ /**
131
+ * Embed one memory and store the vector. Never throws — a flaky endpoint
132
+ * must not break memory writes — but it now REPORTS what happened instead
133
+ * of returning undefined on both success and failure. Callers that ignore
134
+ * the result are unaffected; callers that care get a receipt.
135
+ *
136
+ * @returns {Promise<{status:"ok"|"failed"|"skipped", id:string,
137
+ * at:string, reason?:string, dim?:number, model?:string}>}
138
+ */
88
139
  async function embedFor(id, title, content) {
140
+ const at = new Date().toISOString();
89
141
  const cfg = settings.getVectorConfig();
90
- if (!configComplete(cfg)) return;
142
+ if (!configComplete(cfg)) {
143
+ const entry = { status: "skipped", id, at, reason: "vector-config-incomplete" };
144
+ ledger.record(entry);
145
+ return entry;
146
+ }
91
147
  const text = [title, content].filter(Boolean).join("\n");
92
- const vector = await embedText(cfg, text);
93
- if (vector) {
94
- store.setEmbedding(id, vector);
95
- _dimension = vector.length;
96
- // Bug3: record which model produced the current vectors so the index can
97
- // detect drift and skip a redundant backfill when nothing changed.
98
- markModel(cfg, vector.length);
99
- logger?.info?.(`[dsh-mneme] embedded memory ${id} (dim=${vector.length})`);
148
+ let vector = null;
149
+ try {
150
+ vector = await embedText(cfg, text);
151
+ } catch (error) {
152
+ const entry = { status: "failed", id, at, reason: String(error) };
153
+ ledger.record(entry);
154
+ logger?.warn?.(`[dsh-mneme] embed memory ${id} failed: ${String(error)}`);
155
+ return entry;
156
+ }
157
+ if (!vector) {
158
+ // embedText swallows the HTTP detail (404 / timeout / malformed body all
159
+ // resolve to null). The receipt still has to say "no vector", which is
160
+ // exactly the fact the write path never used to report.
161
+ const entry = { status: "failed", id, at, reason: "embedding-request-returned-no-vector" };
162
+ ledger.record(entry);
163
+ logger?.warn?.(`[dsh-mneme] embed memory ${id} produced no vector`);
164
+ return entry;
100
165
  }
166
+ const model = modelHashOf(cfg.model);
167
+ store.setEmbedding(id, vector, model);
168
+ _dimension = vector.length;
169
+ // Bug3: record which model produced the current vectors so the index can
170
+ // detect drift and skip a redundant backfill when nothing changed.
171
+ markModel(cfg, vector.length);
172
+ const entry = { status: "ok", id, at, dim: vector.length, model };
173
+ ledger.record(entry);
174
+ logger?.info?.(`[dsh-mneme] embedded memory ${id} (dim=${vector.length})`);
175
+ return entry;
101
176
  }
102
177
 
103
178
  return {
@@ -120,10 +195,26 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
120
195
  get configured() {
121
196
  return configComplete(settings.getVectorConfig());
122
197
  },
123
- /** Fire-and-forget re-embed of a memory after any write. */
198
+ /**
199
+ * Re-embed a memory after any write. Still non-blocking — the caller does
200
+ * not have to await it — but it now RETURNS the receipt promise so a
201
+ * caller that wants to know can. Previously this returned undefined and
202
+ * swallowed the rejection, so "did this write get a vector?" had no answer
203
+ * anywhere in the process. Swallowing still applies to the rejection
204
+ * itself: a vector failure must never break the memory write.
205
+ */
124
206
  schedule(memory) {
125
- if (!memory?.id) return;
126
- embedFor(memory.id, memory.title, memory.content).catch(() => {});
207
+ if (!memory?.id) {
208
+ return Promise.resolve({ status: "skipped", id: null, at: new Date().toISOString(), reason: "no-id" });
209
+ }
210
+ return embedFor(memory.id, memory.title, memory.content).catch((error) => ({
211
+ status: "failed", id: memory.id, at: new Date().toISOString(), reason: String(error)
212
+ }));
213
+ },
214
+
215
+ /** Recent embed receipts + running totals (see createReceiptLedger). */
216
+ embedReceipts() {
217
+ return ledger.snapshot();
127
218
  },
128
219
 
129
220
  /** Embed one text and return its vector (null on failure/disabled). */
@@ -155,17 +246,27 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
155
246
  return _dimension || undefined;
156
247
  },
157
248
 
158
- /** Batch re-index rows still missing an embedding. */
249
+ /**
250
+ * Batch re-index rows whose vector is missing OR came from another model.
251
+ * Uses needsEmbeddingFor (not needsEmbedding) so a model switch actually
252
+ * re-embeds the old vectors instead of leaving them eligible for "has a
253
+ * vector" forever while they poison similarity ranking.
254
+ */
159
255
  async reindexMissing(limit = 50) {
160
256
  const cfg = settings.getVectorConfig();
161
257
  if (!cfg?.enabled) return { indexed: 0, skipped: 0 };
162
- const rows = store.needsEmbedding(limit);
258
+ const model = modelHashOf(cfg.model);
259
+ // Fall back to the plain selector for stores that predate the
260
+ // provenance column (older hosts, test doubles).
261
+ const rows = typeof store.needsEmbeddingFor === "function"
262
+ ? store.needsEmbeddingFor(model, limit)
263
+ : store.needsEmbedding(limit);
163
264
  let indexed = 0;
164
265
  for (const row of rows) {
165
266
  const text = [row.title, row.content].filter(Boolean).join("\n");
166
267
  const vector = await embedText(cfg, text);
167
268
  if (vector) {
168
- store.setEmbedding(row.id, vector);
269
+ store.setEmbedding(row.id, vector, model);
169
270
  _dimension = vector.length;
170
271
  indexed++;
171
272
  }
package/lib/index.js CHANGED
@@ -22,6 +22,8 @@ import { createStandaloneApi } from "./api-standalone.js";
22
22
  import { createMaintenance } from "./maintenance.js";
23
23
  import { createSettings } from "./settings.js";
24
24
  import { createCommandManager } from "./commands.js";
25
+ import { createExecutionRecorder } from "./pipeline-runner.js";
26
+ import { registerMoRunCommand } from "./pipeline-command.js";
25
27
  import { createVectorIndex } from "./vector-index.js";
26
28
  // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
27
29
  // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
@@ -388,11 +390,40 @@ export const apply = (ctx, config) => {
388
390
  // Custom commands: register persisted commands into the DSH command registry
389
391
  // on boot; add/remove re-register live through the API.
390
392
  let commands = null;
393
+ // M.O. 执行记账器:默认 null(旗标关),开了才装配。
394
+ let recorder = null;
391
395
  if (ctx.commands) {
392
396
  commands = createCommandManager({ ctx, settings, logger: ctx.logger, language: langOf(cfg) });
393
397
  commands.sync();
394
398
  }
395
399
 
400
+ // M.O. 执行记账(v0.9.0 第二批):`/mo-run` 显式开一次执行窗口,窗口内的命令执行
401
+ // 才可能被记成一条 run。挂的是 `session/event`——本仓已有三处消费者(continuity /
402
+ // summarize / api),不是新引入的宿主依赖。
403
+ //
404
+ // 为什么必须显式开窗:工作流的 trigger 是人话,拿它去猜「用户这句话是不是要开始走
405
+ // 某条流程」只能猜错,而猜错会把一条从没跑过的流程记成跑过。所以窗口边界由人的
406
+ // 显式动作划,**没开窗时一个字都不记**。
407
+ //
408
+ // 门控:默认关(actionRunEnabled),并且它挂热路径,轻量档也关。
409
+ if (cfg.actionRunEnabled === true) {
410
+ recorder = createExecutionRecorder({ ctx, logger: ctx.logger });
411
+ const disposeMoRun = registerMoRunCommand({
412
+ ctx,
413
+ recorder,
414
+ enabled: true,
415
+ language: () => langOf(cfg)
416
+ });
417
+ if (disposeMoRun) {
418
+ disposers.push(disposeMoRun);
419
+ } else {
420
+ // 注册不上(宿主没有命令注册表)时别留一个只开着窗口却没人能操作的记录器。
421
+ recorder.dispose();
422
+ recorder = null;
423
+ ctx.logger?.warn?.("[dsh-mneme] actionRunEnabled=true 但宿主没有命令注册表,M.O. 记账未启用");
424
+ }
425
+ }
426
+
396
427
  // Dream scheduler: automatic consolidation + summary runs, triggered by
397
428
  // store growth. Writes through the service fire the dream hook, which asks
398
429
  // the scheduler to (re)schedule a run once absolute and since-last-run
@@ -582,6 +613,7 @@ export const apply = (ctx, config) => {
582
613
  if (typeof dispose === "function") dispose();
583
614
  }
584
615
  commands?.dispose();
616
+ recorder?.dispose();
585
617
  if (dream) await dream.dispose();
586
618
  if (sleep) sleep.dispose();
587
619
  store.close();
package/lib/inject.js CHANGED
@@ -306,13 +306,44 @@ export function createInjector(ctx, service, settings, config, gate = null) {
306
306
  }).catch(() => { /* prefetch is best-effort */ });
307
307
  }
308
308
 
309
+ // 会话身份解析(v1.2 人格寻址用):读 session.header.agentPreset——冻结的
310
+ // SessionHeader(issue #17 身份契约)。⚠️ 不能先读 requestHeader():那返回
311
+ // EpochHeader(请求级头,首请求后为真),会把 session.header 遮蔽掉,身份取空
312
+ // (scope.js 同坑,本地实测踩过)。任何失败静默 → null(落 default 卡)。
313
+ function sessionAgentId(ctx) {
314
+ try {
315
+ const session = ctx?.agent?.session;
316
+ const header = session?.header ?? null;
317
+ const preset = header?.agentPreset;
318
+ return typeof preset === "string" && preset.trim() ? preset.trim() : null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
309
324
  // User profile + rules: injected ahead of the memory block because they are
310
325
  // always-relevant instructions the agent should follow every turn.
311
- function renderUserSettings() {
326
+ // 人格段(beta):双闸门(config.personaEnabled && 卡.enabled)开时渲染在最前
327
+ // ——设计稿⑥:特化指令(人格)先出,画像与规则后出、后写覆盖占上风,rules 恒
328
+ // 最硬。v1.2 多卡:按会话身份取 byAgent 卡,身份缺失/无卡/卡关落 default。
329
+ // 任一闸关着时本函数输出与历史版本逐字节一致(老用户零变化)。人格段非空即
330
+ // 触发整块渲染(与 profile 同权)。
331
+ function renderUserSettings(ctx) {
312
332
  const profile = settings.getProfile().trim();
313
333
  const rules = settings.getRules();
314
- if (!profile && !rules.length) return "";
334
+ const persona = config?.personaEnabled === true
335
+ ? settings.getPersonaFor(sessionAgentId(ctx))
336
+ : {};
337
+ const personaLines = persona?.enabled === true
338
+ ? [
339
+ ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
340
+ ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
341
+ ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
342
+ ]
343
+ : [];
344
+ if (!profile && !rules.length && !personaLines.length) return "";
315
345
  const lines = [STR.userSettingsHeader[language]];
346
+ lines.push(...personaLines);
316
347
  if (profile) lines.push(STR.profileLine[language](profile));
317
348
  for (const rule of rules) lines.push(STR.ruleLine[language](rule));
318
349
  return escapePromptVars(lines.join("\n"));
@@ -418,7 +449,7 @@ export function createInjector(ctx, service, settings, config, gate = null) {
418
449
  ctx.systemPrompt.context({
419
450
  name: "user-settings",
420
451
  order: 85,
421
- text: renderUserSettings
452
+ text: (ctx) => renderUserSettings(ctx)
422
453
  })
423
454
  ];
424
455
 
package/lib/lang.js CHANGED
@@ -122,6 +122,20 @@ export const STR = {
122
122
  zh: (rule) => `- 规则:${rule}`,
123
123
  en: (rule) => `- Rule: ${rule}`
124
124
  },
125
+ // 人格定义页(beta):人格段行(renderUserSettings 拼装,双闸门开时渲染)。
126
+ // 设计稿⑥:人格段在 profile/rules 之前——特化指令后出、通用底座压轴,rules 恒最硬。
127
+ personaNameLine: {
128
+ zh: (name) => `- 你的人格名字:${name}`,
129
+ en: (name) => `- Your persona name: ${name}`
130
+ },
131
+ personaPersonalityLine: {
132
+ zh: (text) => `- 人格性格设定:${text}`,
133
+ en: (text) => `- Persona personality: ${text}`
134
+ },
135
+ personaReplyStyleLine: {
136
+ zh: (text) => `- 回复要求:${text}`,
137
+ en: (text) => `- Reply style: ${text}`
138
+ },
125
139
 
126
140
  // --- summarize.js:蒸馏转录标签(进入 LLM 上下文) --------------------------
127
141
  transcriptUser: { zh: (t) => `用户:${t}`, en: (t) => `User: ${t}` },
@@ -223,6 +237,116 @@ export const STR = {
223
237
  en: (name) => `Failed to submit command ${name}; see logs`
224
238
  },
225
239
 
240
+ // --- pipeline-command.js:/mo-run 的开窗命令 -----------------------------------
241
+ // 回执是这个功能唯一的对外窗口(面板侧还没做),所以每一条都要说清「发生了什么、
242
+ // 为什么」——尤其失败时不许含糊成「完成」。
243
+ moRunDescription: {
244
+ zh: "M.O. 记账:显式开一次执行窗口,之后跑过的命令才可能被记下来",
245
+ en: "M.O. ledger: explicitly open an execution window; only commands run inside it can be recorded"
246
+ },
247
+ moRunInputHint: {
248
+ zh: "start <工作流 id> | stop | confirm <做了的步骤> | status",
249
+ en: "start <pipeline id> | stop | confirm <steps that ran> | status"
250
+ },
251
+ moRunUsage: {
252
+ zh: "用法:/mo-run start <工作流 id> → 走一遍 → /mo-run stop → /mo-run confirm <做了的步骤 id>\n\n停止时会给出一份待确认清单(含本次观察到的业务动作);**确认之后才写账**。\n确认写法:`/mo-run confirm P6 V2` 表示这两步做了;`V2:-` 表示没做(记 skipped),`V2:?` 表示不确定(不写)。\n未提到的步骤什么都不写——「没确认」不等于「没发生」。\n\n它不会自己判断「你是不是在走某条流程」,也不会自己判断「哪几步走了」:这两件事都会猜错。",
253
+ en: "Usage: /mo-run start <pipeline id> → walk through it → /mo-run stop → /mo-run confirm <step ids that ran>\n\nStop gives you a checklist (with the business actions observed); **the ledger is only written after you confirm**.\nConfirm syntax: `/mo-run confirm P6 V2` means both ran; `V2:-` means not done (recorded as skipped), `V2:?` means unsure (not written).\nSteps you do not mention are written as nothing — unconfirmed is not the same as not-happened.\n\nIt never guesses whether you are walking a workflow, nor which steps ran: both guesses would be wrong."
254
+ },
255
+ moRunStart: {
256
+ zh: {
257
+ ok: (id, steps) => `已开窗:${id}(${steps} 步)。走完用 /mo-run stop 结账——它会给你一份待确认清单,**确认之后才写账**。`,
258
+ alreadyOpen: (id) => `这个会话已经开着窗口了:${id}。先用 /mo-run stop 结账,再开新的。`,
259
+ notFound: (ids) => `找不到这条工作流。可用:${ids}`,
260
+ noneAvailable: "(当前项目里没有读到任何工作流)",
261
+ unreadable: (detail) => `工作流读不出来:${detail}`,
262
+ noProject: "拿不到这个会话的工作目录,无法定位工作流。",
263
+ noSession: "拿不到会话身份,无法记账。",
264
+ failed: (reason) => `开窗失败:${reason}`
265
+ },
266
+ en: {
267
+ ok: (id, steps) => `Window opened: ${id} (${steps} steps). When done, run /mo-run stop — it gives you a checklist, and **the ledger is only written after you confirm**.`,
268
+ alreadyOpen: (id) => `This session already has an open window: ${id}. Run /mo-run stop first.`,
269
+ notFound: (ids) => `No such workflow. Available: ${ids}`,
270
+ noneAvailable: "(no workflow found in this project)",
271
+ unreadable: (detail) => `Workflow unreadable: ${detail}`,
272
+ noProject: "Cannot determine this session's working directory, so the workflow cannot be located.",
273
+ noSession: "No session identity available; nothing can be recorded.",
274
+ failed: (reason) => `Failed to open window: ${reason}`
275
+ }
276
+ },
277
+ moRunStop: {
278
+ zh: {
279
+ ok: (id, steps) => `已结账并落盘:${id},记下 ${steps} 步。`,
280
+ awaiting: (id, steps) => `已结账:${id}(${steps} 步)。**还没有写账**——请确认哪几步真的走了:`,
281
+ confirmHow: (sample) =>
282
+ `\n用 /mo-run confirm ${sample} 列出「做了」的步骤;要标「没做」就写 ${String(sample).split(" ")[0]}:-\n未提到的步骤什么都不写(「没确认」不等于「没发生」)。`,
283
+ notCommands: (n) => `${n} 次调用不是命令执行器(读文件、检索这类),与工作流步骤无关。`,
284
+ truncated: "⚠️ 事件缓冲到上限被截断,这次记账可能不完整。",
285
+ noWindow: "这个会话没有开着窗口,没什么可结账的。",
286
+ activities: (n) => `本次观察到 ${n} 种业务动作(只留动作与旗标,位置参数略去):`,
287
+ shellNoise: (n) => `另有 ${n} 段是 shell 语法(赋值、管道、输出),不是工作流的一步。`,
288
+ writeFailed: (detail) => `写盘失败:${detail}`,
289
+ failed: (reason) => `结账失败:${reason}`
290
+ },
291
+ en: {
292
+ ok: (id, steps) => `Closed and persisted: ${id}, ${steps} step(s) recorded.`,
293
+ awaiting: (id, steps) => `Closed: ${id} (${steps} steps). **Nothing persisted yet** — confirm which steps really ran:`,
294
+ confirmHow: (sample) =>
295
+ `\nUse /mo-run confirm ${sample} to list the steps that ran; mark one as not-done with ${String(sample).split(" ")[0]}:-\nSteps you do not mention are written as nothing (unconfirmed is not the same as not-happened).`,
296
+ notCommands: (n) => `${n} call(s) were not command executors (file reads, searches) and are irrelevant to workflow steps.`,
297
+ truncated: "⚠️ Event buffer hit its cap and was truncated; this record may be incomplete.",
298
+ noWindow: "No window is open for this session, so there is nothing to close.",
299
+ activities: (n) => `${n} kind(s) of business action observed this pass (actions and flags only; positional arguments omitted):`,
300
+ shellNoise: (n) => `Another ${n} segment(s) were shell syntax (assignments, pipes, output), not steps of a workflow.`,
301
+ writeFailed: (detail) => `Failed to write: ${detail}`,
302
+ failed: (reason) => `Failed to close: ${reason}`
303
+ }
304
+ },
305
+ moRunConfirm: {
306
+ zh: {
307
+ ok: (id, steps) => `已记入账本:${id},${steps} 步。`,
308
+ notDone: (list) => `记为「这次没做」(skipped):${list}`,
309
+ unsure: (list) => `你说不确定,没有写进账:${list}`,
310
+ unanswered: (list) => `没提到的步骤没有写进账:${list}`,
311
+ nothingToConfirm: "没有待确认的清单。先 /mo-run start <工作流 id>,走一遍,再 /mo-run stop。",
312
+ mismatch: (n, rows) => `⚠️ 有 ${n} 步你说做了、但声明的命令这次一次都没出现(是声明该改、还是本来不必敲?由你判):\n${rows}`,
313
+ obsSaved: (file) => `本次观察已旁挂:${file}`, noAnswers: (notDone, unsure, unanswered) =>
314
+ `一条都没确认,**没有落盘**。你说「没做」的 ${notDone} 步、不确定的 ${unsure} 步、没提到的 ${unanswered} 步。\n确认「做了」的步骤才会写账:/mo-run confirm <步骤 id>`,
315
+ badValue: (stepId, value) => `${stepId} 的回答「${value}」不合法。只认「做了」(或不写标记)/「没做」写作 -/「不确定」写作 ?`,
316
+ unknownStep: (stepId) => `没有这一步:${stepId}`,
317
+ writeFailed: (detail) => `写盘失败:${detail}`,
318
+ failed: (reason) => `确认失败:${reason}`
319
+ },
320
+ en: {
321
+ ok: (id, steps) => `Recorded: ${id}, ${steps} step(s).`,
322
+ notDone: (list) => `Marked as not done this pass (skipped): ${list}`,
323
+ unsure: (list) => `You were unsure; not written to the ledger: ${list}`,
324
+ unanswered: (list) => `Steps you did not mention were not written to the ledger: ${list}`,
325
+ nothingToConfirm: "Nothing to confirm. Run /mo-run start <pipeline id>, walk through it, then /mo-run stop.",
326
+ mismatch: (n, rows) => `⚠️ ${n} step(s) you marked done never showed their declared command this pass (stale declaration, or simply not needed? your call):\n${rows}`,
327
+ obsSaved: (file) => `Observations side-written to: ${file}`, noAnswers: (notDone, unsure, unanswered) =>
328
+ `Nothing was confirmed; **nothing was persisted**. Not-done: ${notDone}, unsure: ${unsure}, unmentioned: ${unanswered}.\nOnly steps confirmed as done are recorded: /mo-run confirm <step id>`,
329
+ badValue: (stepId, value) => `Answer "${value}" for ${stepId} is invalid. Use done (no marker) / - for not-done / ? for unsure.`,
330
+ unknownStep: (stepId) => `No such step: ${stepId}`,
331
+ writeFailed: (detail) => `Failed to write: ${detail}`,
332
+ failed: (reason) => `Failed to confirm: ${reason}`
333
+ }
334
+ },
335
+ moRunStatus: {
336
+ zh: {
337
+ closed: "当前没有开着的执行窗口。用 /mo-run start <工作流 id> 开一个。",
338
+ awaiting: (id, steps) => `等你在确认:${id}(${steps} 步)。用 /mo-run confirm <做了的步骤 id> 写账。`,
339
+ open: (s, recorded) => `窗口开着:${s.pipelineId}\n开始于 ${s.startedAt}\n已缓冲 ${s.bufferedEvents} 条事件,识别出 ${recorded} 步${s.truncated ? "(缓冲已截断)" : ""}\n认不出 ${s.unattributed},待确认 ${s.ambiguous}`,
340
+ none: "(还没有)"
341
+ },
342
+ en: {
343
+ closed: "No execution window is open. Open one with /mo-run start <pipeline id>.",
344
+ awaiting: (id, steps) => `Waiting for your confirmation: ${id} (${steps} steps). Write the ledger with /mo-run confirm <step ids that ran>.`,
345
+ open: (s, recorded) => `Window open: ${s.pipelineId}\nStarted ${s.startedAt}\n${s.bufferedEvents} event(s) buffered, ${recorded} step(s) recognized${s.truncated ? " (buffer truncated)" : ""}\nUnattributed ${s.unattributed}, ambiguous ${s.ambiguous}`,
346
+ none: "(none yet)"
347
+ }
348
+ },
349
+
226
350
  // --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
227
351
  mirrorLabel: {
228
352
  zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源", scope: "作用域", sensitivity: "敏感度" },
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 把 shell 观察压成**人能扫的「业务动作」清单**。
3
+ *
4
+ * ## 为什么需要这一层(三组实测逼出来的)
5
+ *
6
+ * 1. 拿单条命令去匹配手写步骤表,命中率只有 14.2~15.2%(非发版会话 4.4%,
7
+ * 区分力仅 3 倍)→ **拿清单套行为这条路走不通**。
8
+ * 2. 想用「整段命令」当分组键:每个会话中位 **539 组**、最多 7373 组
9
+ * → 文献那套「聚类 + 人命名」直接照搬也不成立(网页活动本来只有几十个页面)。
10
+ * 3. 换「动词」当键:密度最高的全是 **PowerShell 管道语法**
11
+ * (`write-output` 10619、`select-object` 10422、`}` 4864…)
12
+ * → **「命令 ≈ 动作」这个预设是错的**:大部分"命令"不是活动,是句子成分。
13
+ * 4. 只留业务命令(git/gh/npm/node/docker…)后:业务段占 **6.5%**,
14
+ * 不同动词 **8 个**、不同前两词组 **1010 个** —— 这才是人能扫的量级。
15
+ *
16
+ * ## 这个模块的产出:一份「本次窗口观察到什么」清单
17
+ *
18
+ * 形态:`git status ×12`、`gh pr ×5`、`npm test ×3`。
19
+ * **不猜它对应哪一步**——那件事交给人(见 mneme-research 的 09 号文档)。
20
+ *
21
+ * ## 🔴 隐私:为什么只输出「动词 + 子命令」
22
+ *
23
+ * 完整命令行里**几乎必然含绝对路径**,而路径里含本机用户名。例如实测里真实出现过
24
+ * `set-location "c:\users\<用户名>\desktop\..."`。这条回执是要显示在界面上、
25
+ * 可能被截图的那一面——所以本模块有一条硬规则:
26
+ *
27
+ * > **位置参数一律丢弃,只保留动词、子命令与 `-` 开头的旗标。**
28
+ *
29
+ * 旗标是语义(`--rebase` / `--force` / `-m`),位置参数是数据(路径、文件名、id)。
30
+ * 只留前者,清单既表意又不泄露。这不是"顺手过滤",是这一层存在的第二个理由。
31
+ */
32
+
33
+ /**
34
+ * 业务命令的动词白名单。
35
+ *
36
+ * 判据不是"常见",而是"**它的执行会构成工作流的一步**"——即它有语义
37
+ * (改仓库状态、发版、跑测试),而不是在组装文本(`write-output`、`foreach-object`)。
38
+ *
39
+ * 这张名单是**拍的**(实测里 6.5% 的段落在名单内),未验证覆盖率:
40
+ * 可能有业务动作用了别的工具(`python`、`curl`、`cargo`)。认不出来不产清单项,
41
+ * 是安全的方向——它只会让清单更短,不会让它更假。
42
+ */
43
+ const BUSINESS_VERBS = new Set([
44
+ // 实测 21723 次调用里,真正命中的只有这四个:git / gh / npm / node。
45
+ // 其余先不收——认不出来只会让清单更短,不会让它更假;
46
+ // 等真有项目用上了再加(这一版刻意不做"猜未来会用到"的预留)。
47
+ "git", "gh", "npm", "npx", "node", "docker"
48
+ ]);
49
+
50
+ /** 命令分隔符:与 pipeline-attribution 同一口径(只认两侧带空白的)。 */
51
+ const SEPARATOR_RE = /\s*(?:;|&&|\|\||\|)\s*|\n/u;
52
+
53
+ /** 重定向尾巴(`2>&1`、`> file`、`2>nul`):不是命令的一部分。 */
54
+ const REDIRECT_RE = /\s*\d?>\s*&?\s*\S+/gu;
55
+
56
+ /** `cd <路径>`:前导定位语句,不是活动。 */
57
+ const CD_RE = /^cd\s/iu;
58
+
59
+ /**
60
+ * 这个 token 像不像一个**子命令**。
61
+ *
62
+ * 必须做这道校验,否则三件事都会发生(都是端到端实测抓到的):
63
+ * 1. `node d:\dsh\...\probe.mjs` → 把**脚本路径**当成 sub,路径里的用户名被带出去;
64
+ * 2. `git -C $(Join-Path ...) log --oneline` → 把 `$(Join-Path` 当成 sub,清单里出现
65
+ * `git (join-path -C --oneline` 这种读不懂的东西;
66
+ * 3. `node x.test.js` → **带点的文件名**被当成 sub(`x.test.js` 完全符合"小写字母起头
67
+ * 的短词")。这条是加完前两条修复后复验时才冒出来的。
68
+ *
69
+ * 所以除了字符集,还要**排除含点的 token**:子命令带点是极罕见的
70
+ * (`git log` / `gh pr` / `npm test` / `kubectl get` 都不带),而文件名几乎都带。
71
+ * 宁可漏认一个,不可把文件名当子命令——后者会出现在给人看的清单里。
72
+ */
73
+ function looksLikeSubcommand(token) {
74
+ if (typeof token !== "string") return false;
75
+ const t = token.replace(/^["']|["']$/gu, "");
76
+ // 一个正则同时挡住四类坏值:旗标(`-` 开头)、含点的文件名、`$(…)` 这类
77
+ // shell 片段、以及超长的东西。上一版写成五道 if,等价但更啰嗦。
78
+ return /^[a-z0-9][a-z0-9_-]{0,19}$/u.test(t);
79
+ }
80
+
81
+ /**
82
+ * 一条命令 → 一个业务动作。
83
+ *
84
+ * 返回 `{ verb, sub, flags }` 或 `null`(不是业务命令,或抽不出动词)。
85
+ * - `verb`:白名单里的动词(小写)
86
+ * - `sub`:**扫描出的第一个像子命令的 token**(不是"第一个非旗标 token"——那个
87
+ * 可能是路径或参数,见 `looksLikeSubcommand`)。找不到就是空串
88
+ * - `flags`:`-` 开头的旗标。**位置参数一律丢弃**(理由见文件头隐私一节)
89
+ */
90
+ export function businessCommandOf(command) {
91
+ if (typeof command !== "string") return null;
92
+ const cleaned = command.replace(REDIRECT_RE, " ").trim();
93
+ if (cleaned === "" || CD_RE.test(cleaned)) return null;
94
+
95
+ const tokens = cleaned.split(/\s+/u).filter((t) => t !== "");
96
+ if (tokens.length === 0) return null;
97
+
98
+ const verb = tokens[0].toLowerCase().replace(/^["']|["']$/gu, "");
99
+ if (!BUSINESS_VERBS.has(verb)) return null;
100
+
101
+ // **扫描**而不是取第一个非旗标:`git -C <path> log` 的子命令在 `-C <值>` 之后。
102
+ let sub = "";
103
+ for (let i = 1; i < tokens.length; i += 1) {
104
+ if (looksLikeSubcommand(tokens[i])) {
105
+ sub = tokens[i].replace(/^["']|["']$/gu, "").toLowerCase();
106
+ break;
107
+ }
108
+ }
109
+
110
+ // 旗标:只留 `-` 开头的、短的、不含路径分隔符的(`--base=<path>` 这类直接丢)。
111
+ const flags = [];
112
+ for (const raw of tokens.slice(1)) {
113
+ const t = raw.replace(/^["']|["']$/gu, "");
114
+ if (t.startsWith("-") && t.length > 1 && t.length <= 24 && !/[/\\=]/u.test(t)) flags.push(t);
115
+ }
116
+
117
+ return { verb, sub, flags: [...new Set(flags)].slice(0, 4) };
118
+ }
119
+
120
+ /**
121
+ * 分组的键。
122
+ *
123
+ * 优先用子命令(`git status`)。**没有子命令时退而用第一个旗标**——因为对
124
+ * `node` / `npm` 这类工具,真正区分语义的常常就是旗标:`node --test`(跑测试)与
125
+ * `node --check`(查语法)是两件事,若键里只有 `node`,它们会被合并成一行,
126
+ * 清单就读不出差别了。这条是端到端实测里发现的(16 次 node 调用被并成一条)。
127
+ *
128
+ * **旗标仍然只是"补充",不是主体**:`git status -C --porcelain` 的键是 `git status`,
129
+ * `-C`(它后面本来跟着一个被丢弃的路径)不该挤进键里。
130
+ */
131
+ export function activityKey(activity) {
132
+ if (!activity) return "";
133
+ const tail = activity.sub || (Array.isArray(activity.flags) ? activity.flags[0] : "") || "";
134
+ return tail ? `${activity.verb} ${tail}` : activity.verb;
135
+ }
136
+
137
+ /**
138
+ * 一批观察 → 业务动作清单(按出现次数降序)。
139
+ *
140
+ * 每条:`{ key, verb, sub, count, flags }`;`flags` 是出现过的旗标并集(截断)。
141
+ * **不含任何位置参数**,因此不含路径与用户名。
142
+ */
143
+ export function tallyActivities(observations) {
144
+ const groups = new Map();
145
+ let shellNoise = 0;
146
+ let totalSegments = 0;
147
+
148
+ for (const observation of observations || []) {
149
+ const command = observation?.command;
150
+ if (typeof command !== "string" || command.trim() === "") continue;
151
+ for (const raw of command.split(SEPARATOR_RE)) {
152
+ if (raw.trim() === "") continue;
153
+ totalSegments += 1;
154
+ const activity = businessCommandOf(raw);
155
+ if (!activity) {
156
+ shellNoise += 1;
157
+ continue;
158
+ }
159
+ const key = activityKey(activity);
160
+ const cur = groups.get(key) || { key, verb: activity.verb, sub: activity.sub, count: 0, flags: new Set() };
161
+ cur.count += 1;
162
+ for (const f of activity.flags) cur.flags.add(f);
163
+ groups.set(key, cur);
164
+ }
165
+ }
166
+
167
+ const activities = [...groups.values()]
168
+ .map((g) => ({ key: g.key, verb: g.verb, sub: g.sub, count: g.count, flags: [...g.flags].slice(0, 6) }))
169
+ .sort((a, b) => (b.count === a.count ? a.key.localeCompare(b.key) : b.count - a.count));
170
+
171
+ return {
172
+ activities,
173
+ // 两个数分开报:「业务动作很少」与「这条命令根本不是动作」是两件事。
174
+ businessSegments: activities.reduce((n, a) => n + a.count, 0),
175
+ shellNoise,
176
+ totalSegments
177
+ };
178
+ }