@modusensus/dsh-mneme 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/readme-claims.test.js +68 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M.O.(Modus Operandi)的**归属规则**:把宿主的一次工具动作,映射成「它可能是工作流里的哪一步」。
|
|
3
|
+
*
|
|
4
|
+
* 这个模块存在的唯一理由,是执行链要记账时必须先回答一个问题:**这一次动作算哪一步?**
|
|
5
|
+
* 没有这条规则,观察到的动作与工作流里的步骤编号是两套东西,记下来的账谁也读不懂;
|
|
6
|
+
* 而「认真记了但没人能对账」比不记更糟——它会让面板显示一份看起来完整的假历史。
|
|
7
|
+
*
|
|
8
|
+
* ## 为什么只出候选,绝不下结论
|
|
9
|
+
*
|
|
10
|
+
* 本仓工作流里的 `tool` 字段是**人手写的自由文本**,实测形态:单命令(`npm test`)、
|
|
11
|
+
* 多命令并列(`git log / gh pr list`)、带占位符(`git push <tag>`),19 步里还有 4 步
|
|
12
|
+
* 是空的。拿这种文本去和宿主动作做精确匹配,命中率注定不高,而**误匹配的代价远大于漏匹配**:
|
|
13
|
+
* 把「推 tag」记成「推分支」,`deriveState` 就会据此把错误的一步染成可信。
|
|
14
|
+
*
|
|
15
|
+
* 所以本模块的产出是**候选 + 理由**,由人(或上游的显式动作)确认;认不出来就是空数组,
|
|
16
|
+
* **「认不出来」是合法答案,不许猜**。这与反事实判据那边同一条纪律:机器只写观察到的。
|
|
17
|
+
*
|
|
18
|
+
* ## 匹配分档
|
|
19
|
+
*
|
|
20
|
+
* - `exact`:归一化后整条命令相等(`npm test` ↔ `npm test`)
|
|
21
|
+
* - `weak`:命令头相同、参数不同或缺失(`git push` ↔ `git push <tag>`)
|
|
22
|
+
* - 其余不给候选
|
|
23
|
+
*
|
|
24
|
+
* 同一档内出现多条候选时,**全部返回**、不挑一个——挑一个就等于替人做了决定,而
|
|
25
|
+
* 「两条工作流步骤长得一样」这件事该由人看着消歧。
|
|
26
|
+
*
|
|
27
|
+
* ## 调用方要自己分的三桶
|
|
28
|
+
*
|
|
29
|
+
* 本模块只给 `attributionCandidates` 一个入口;把一批观察归类是调用方的事,但要照这条口径分:
|
|
30
|
+
* matched 有候选(可能多条,由人消歧)
|
|
31
|
+
* unmatched 命令拼得出来,但没有任何步骤对得上
|
|
32
|
+
* skipped 命令行都拼不出来,归属无从谈起
|
|
33
|
+
*
|
|
34
|
+
* **「认不出来」与「没有这一步」必须长得不一样**,否则一份全是 unmatched 的报告会被读成
|
|
35
|
+
* 「这次什么也没做」——与账本那边「缺账」和「坏账」要分开是同一条纪律。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** 命令里的占位符:`<tag>` / `<版本>`。它们不是字面量,不该参与比较。
|
|
39
|
+
* 实测真实工作流只用尖括号这一种形态(`{}` 与 `[]` 各 0 次),不为假想的写法留分支。 */
|
|
40
|
+
const PLACEHOLDER_RE = /<[^>]*>/gu;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 命令分隔符:一人写的 `tool` 里常见 `git log / gh pr list` 这种并列写法。
|
|
44
|
+
* 用 `/` 分隔有歧义(路径里也有 `/`),所以只在**两侧有空白**时才当分隔符,
|
|
45
|
+
* 否则 `/` 属于参数的一部分(`gh workflow run release-prep.yml` 不能被切碎)。
|
|
46
|
+
* 实测真实工作流只出现这一种(`|` / `;` / `&&` 各 0 次),同样不留假想分支。
|
|
47
|
+
*/
|
|
48
|
+
const SEPARATOR_RE = /\s+\/\s+/u;
|
|
49
|
+
|
|
50
|
+
/** 归一化:小写、去占位符、折叠空白。占位符去掉而不是替换成通配,否则 `git push <tag>` 会宽到能匹配一切。 */
|
|
51
|
+
export function normalizeCommand(text) {
|
|
52
|
+
if (typeof text !== "string") return "";
|
|
53
|
+
return text
|
|
54
|
+
.replace(PLACEHOLDER_RE, " ")
|
|
55
|
+
.replace(/\s+/gu, " ")
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 一条 `tool` 文本可能包含多条并列命令:拆开,逐条参与匹配。 */
|
|
61
|
+
export function commandParts(tool) {
|
|
62
|
+
if (typeof tool !== "string") return [];
|
|
63
|
+
return tool
|
|
64
|
+
.split(SEPARATOR_RE)
|
|
65
|
+
.map((part) => normalizeCommand(part))
|
|
66
|
+
.filter((part) => part !== "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 切成词。命令行的词就是空格分隔的片段;这里不做完整的 shell 解析(引号、转义),
|
|
70
|
+
* 因为 `tool` 字段是**人写的说明文本**而不是可执行脚本,解析得越精细越容易把说明当脚本。 */
|
|
71
|
+
function tokensOf(command) {
|
|
72
|
+
return command.split(" ").filter((t) => t !== "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 一个是否是另一个的**词前缀**:`["gh","pr","create"]` 是 `["gh","pr","create","--base","main"]`
|
|
77
|
+
* 的前缀。这是「弱匹配」的判据。
|
|
78
|
+
*
|
|
79
|
+
* 为什么不用「第一个词相同」:真实工作流里 `git` 出现 5 次、`gh pr` 出现 4 次,按首词匹配
|
|
80
|
+
* 会让一次 `git push` 报出六条候选——**噪声盖过信号**,人看一眼就放弃了。
|
|
81
|
+
* 词前缀表达的是「这次跑的就是那一步,只是多带了参数」,这才是真实形态:
|
|
82
|
+
* 人写 `gh pr create`,agent 跑的是 `gh pr create --base main --title …`。
|
|
83
|
+
*/
|
|
84
|
+
function isTokenPrefix(shorter, longer) {
|
|
85
|
+
if (shorter.length === 0 || shorter.length > longer.length) return false;
|
|
86
|
+
for (let i = 0; i < shorter.length; i += 1) {
|
|
87
|
+
if (shorter[i] !== longer[i]) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 观察记录的形状(上游负责拼)。
|
|
94
|
+
*
|
|
95
|
+
* `command` 是**尽力而为**的命令行:宿主给的是工具名 + 结构化参数,把参数拼成命令行这件事
|
|
96
|
+
* 由调用方按工具类型做。拼不出来就给空串——空串不会匹配上任何东西,是安全的默认。
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeObservation(raw) {
|
|
99
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
100
|
+
const command = normalizeCommand(raw.command);
|
|
101
|
+
if (command === "") return null; // 认不出命令行 = 这条观察对归属无用,直接丢给上游
|
|
102
|
+
return {
|
|
103
|
+
tool: typeof raw.tool === "string" ? raw.tool : "",
|
|
104
|
+
command,
|
|
105
|
+
ok: raw.ok === true ? true : raw.ok === false ? false : null // 三态:真 / 假 / 不知道
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 给一次观察找候选步骤。返回按档位排序的数组(exact 在前),可能为空。
|
|
111
|
+
*
|
|
112
|
+
* **不返回「最佳候选」**:那会诱使调用方直接采用它。调用方要决策,就得自己面对
|
|
113
|
+
* 「可能为空」「可能多条」这两个事实。
|
|
114
|
+
*/
|
|
115
|
+
export function attributionCandidates(observation, pipeline) {
|
|
116
|
+
const obs = normalizeObservation(observation);
|
|
117
|
+
if (!obs || !pipeline || !Array.isArray(pipeline.steps)) return [];
|
|
118
|
+
|
|
119
|
+
const obsTokens = tokensOf(obs.command);
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const step of pipeline.steps) {
|
|
122
|
+
const parts = commandParts(step.tool);
|
|
123
|
+
// 空 tool 的步骤**结构性地无法被归属**。这不是缺陷,是事实:作者没写它怎么跑。
|
|
124
|
+
// 把它报告出来(而不是跳过),好让「为什么这一步永远没账」有据可查。
|
|
125
|
+
if (parts.length === 0) continue;
|
|
126
|
+
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
if (part === obs.command) {
|
|
129
|
+
out.push({
|
|
130
|
+
stepId: step.id,
|
|
131
|
+
confidence: "exact",
|
|
132
|
+
matched: part,
|
|
133
|
+
why: `命令与步骤里写的完全一致(${part})`
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
const partTokens = tokensOf(part);
|
|
138
|
+
if (isTokenPrefix(partTokens, obsTokens) || isTokenPrefix(obsTokens, partTokens)) {
|
|
139
|
+
const longer = partTokens.length >= obsTokens.length ? part : obs.command;
|
|
140
|
+
out.push({
|
|
141
|
+
stepId: step.id,
|
|
142
|
+
confidence: "weak",
|
|
143
|
+
matched: part,
|
|
144
|
+
why: `像同一步但参数不同:步骤写的是「${part}」,这次是「${obs.command}」(${longer} 多带了参数)`
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
out.sort((a, b) => (a.confidence === b.confidence ? a.stepId.localeCompare(b.stepId) : a.confidence === "exact" ? -1 : 1));
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 结构性不可归属的步骤:作者没写 `tool`,所以它**永远不会**拿到账。
|
|
157
|
+
*
|
|
158
|
+
* 单独给一个入口,是为了让「为什么这一步永远没账」有据可查——否则它看起来只是
|
|
159
|
+
* 「还没跑过」,而真相是这条工作流根本没写明它怎么跑。
|
|
160
|
+
*/
|
|
161
|
+
export function unattributableSteps(pipeline) {
|
|
162
|
+
return (pipeline && Array.isArray(pipeline.steps) ? pipeline.steps : [])
|
|
163
|
+
.filter((s) => commandParts(s.tool).length === 0)
|
|
164
|
+
.map((s) => s.id);
|
|
165
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M.O. 的**开窗命令**:`/mo-run`。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要一条命令,而不是让插件自己判断
|
|
5
|
+
*
|
|
6
|
+
* 「哪条工作流正在被走」这件事**没法自动判**:工作流的 `trigger` 字段是人话
|
|
7
|
+
* (`{kind:"phrase", value:"发版"}`),拿它去匹配用户消息只能猜,而猜错会把一条
|
|
8
|
+
* **从没跑过的流程**记成跑过、还把节点染成可信。所以窗口的边界由**显式动作**划,
|
|
9
|
+
* 这条命令就是那个动作。
|
|
10
|
+
*
|
|
11
|
+
* 与仓库里既有的 `commands.js`(用户自定义指令)不同:那条路只把文本转发给模型,
|
|
12
|
+
* 不持有状态;这一条要真的开/关一个窗口,所以单独成模块。
|
|
13
|
+
*
|
|
14
|
+
* ## 三个子命令
|
|
15
|
+
*
|
|
16
|
+
* - `/mo-run start <pipeline-id>` —— 开窗。之后这个会话里的命令执行才可能被记账
|
|
17
|
+
* - `/mo-run stop` —— 结账并落盘
|
|
18
|
+
* - `/mo-run status` —— 只看「走到哪了」,不结账
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么把结果文本说这么细
|
|
21
|
+
*
|
|
22
|
+
* 命令的回执是这个功能**唯一的对外窗口**(面板那一侧还没做)。它必须能回答:
|
|
23
|
+
* 开的哪条、走了几步、有多少条认不出来、为什么没写账。含糊其辞的回执会让使用者
|
|
24
|
+
* 以为「跑成功了」,而实际可能一条观察都没认出来——那正是这个功能最该避免的事。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { STR } from "./lang.js";
|
|
28
|
+
|
|
29
|
+
/** 命令名与用法。名字走本仓的 `COMMAND_NAME` 约定:小写字母起头。 */
|
|
30
|
+
export const MO_RUN_COMMAND = "mo-run";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析 `/mo-run` 后面的原始输入。
|
|
34
|
+
*
|
|
35
|
+
* 五个子命令:
|
|
36
|
+
* - `start <id>`:开窗
|
|
37
|
+
* - `stop`:结账,**产出待确认清单**(P2 起不再自动落盘)
|
|
38
|
+
* - `confirm <答案>`:人确认哪几步走了 → 这才写账
|
|
39
|
+
* - `status`:看状态(不带参数时的默认,最安全)
|
|
40
|
+
*
|
|
41
|
+
* `confirm` 的答案格式刻意做成**最短的两种**,避免让人背语法:
|
|
42
|
+
* - `/mo-run confirm P6 V2` —— 这几步做了(空格分隔)
|
|
43
|
+
* - `/mo-run confirm P6:* V2:-` —— 带标记:`*` 做了、`-` 没做、`?` 不确定
|
|
44
|
+
*
|
|
45
|
+
* 没提到的步骤**什么都不写**(既不是"做了"也不是"没做")——见 pipeline-confirm 的注释。
|
|
46
|
+
*/
|
|
47
|
+
export function parseMoRunInput(rawInput) {
|
|
48
|
+
const text = typeof rawInput === "string" ? rawInput.trim() : "";
|
|
49
|
+
if (text === "") return { action: "status" }; // 不带参数 = 看状态,最安全
|
|
50
|
+
const [verb, ...rest] = text.split(/\s+/u);
|
|
51
|
+
const arg = rest.join(" ").trim();
|
|
52
|
+
switch ((verb ?? "").toLowerCase()) {
|
|
53
|
+
case "start":
|
|
54
|
+
return { action: "start", pipelineId: arg };
|
|
55
|
+
case "stop":
|
|
56
|
+
return { action: "stop" };
|
|
57
|
+
case "confirm":
|
|
58
|
+
return { action: "confirm", answers: parseConfirmArg(arg) };
|
|
59
|
+
case "status":
|
|
60
|
+
return { action: "status" };
|
|
61
|
+
default:
|
|
62
|
+
return { action: "help", unknown: verb };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `confirm` 的参数 → `{ stepId: "done"|"notDone"|"unsure" }`。
|
|
68
|
+
*
|
|
69
|
+
* 两种写法都收:
|
|
70
|
+
* - `P6` → done(最常用,直接列做了哪几步)
|
|
71
|
+
* - `P6:*` / `P6:-` / `P6:?` → done / notDone / unsure
|
|
72
|
+
*
|
|
73
|
+
* 解析不了的一律**不放进结果**(宁可少答,不可猜答案)——非法值会在
|
|
74
|
+
* `runFromAnswers` 那一层被拒,这里只负责拆分。
|
|
75
|
+
*/
|
|
76
|
+
export function parseConfirmArg(arg) {
|
|
77
|
+
const answers = {};
|
|
78
|
+
if (typeof arg !== "string" || arg.trim() === "") return answers;
|
|
79
|
+
for (const token of arg.split(/[\s,]+/u)) {
|
|
80
|
+
if (token === "") continue;
|
|
81
|
+
const [idPart, markPart] = token.split(":", 2);
|
|
82
|
+
const id = idPart.trim();
|
|
83
|
+
if (id === "") continue;
|
|
84
|
+
if (markPart === undefined || markPart.trim() === "") {
|
|
85
|
+
answers[id] = "done";
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const mark = markPart.trim();
|
|
89
|
+
if (mark === "*") answers[id] = "done";
|
|
90
|
+
else if (mark === "-") answers[id] = "notDone";
|
|
91
|
+
else if (mark === "?") answers[id] = "unsure";
|
|
92
|
+
// 其它标记:不放进结果(不是"没做",是"没答")
|
|
93
|
+
}
|
|
94
|
+
return answers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 把「本次观察到哪些业务动作」渲染成几行。
|
|
99
|
+
*
|
|
100
|
+
* 这是这一版回执的**主菜**:自动归属的命中率实测只有 14%,所以回执不再声称
|
|
101
|
+
* "我替你对上了哪几步",而是如实列出"这一遍你干了什么",由人决定它对不对得上。
|
|
102
|
+
*
|
|
103
|
+
* 只显示 `动词 子命令 ×次数`(可带旗标)——**位置参数在分桶层就被丢弃了**,
|
|
104
|
+
* 因为完整命令行含绝对路径(路径含本机用户名),而这条回执会显示在界面上。
|
|
105
|
+
* 那也是 pipeline-activities.js 存在的第二个理由。
|
|
106
|
+
*/
|
|
107
|
+
function renderActivities(activities, t, maxRows = 12) {
|
|
108
|
+
if (!Array.isArray(activities) || activities.length === 0) return "";
|
|
109
|
+
const rows = activities.slice(0, maxRows).map((a) => {
|
|
110
|
+
// 键在没有子命令时用的是第一个旗标(`node --test`)——那种情况下别再把同一个旗标
|
|
111
|
+
// 追加一遍,否则会渲染成 `node --test --test`(端到端复验时抓到的重复)。
|
|
112
|
+
const shown = (a.flags || []).filter((f) => !a.key.endsWith(f));
|
|
113
|
+
const flags = shown.length ? ` ${shown.join(" ")}` : "";
|
|
114
|
+
return ` ${a.key}${flags} ×${a.count}`;
|
|
115
|
+
});
|
|
116
|
+
const more = activities.length > maxRows ? `\n …另有 ${activities.length - maxRows} 种` : "";
|
|
117
|
+
return `${t.activities(activities.length)}\n${rows.join("\n")}${more}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 把一次 `stop()` 的结果翻成人话。
|
|
122
|
+
*
|
|
123
|
+
* P2 起 `stop` **不再写账**,而是产出一份待确认清单。所以这里的重点从
|
|
124
|
+
* "记了几步"变成"**请你确认哪几步走了**"——并给出步骤表与观察到的动作,供人对照。
|
|
125
|
+
*/
|
|
126
|
+
function describeStop(result, language) {
|
|
127
|
+
const t = STR.moRunStop[language];
|
|
128
|
+
const activityBlock = renderActivities(result.activities, t);
|
|
129
|
+
|
|
130
|
+
if (result.ok && result.state === "awaiting-confirmation") {
|
|
131
|
+
const prop = result.proposal;
|
|
132
|
+
const lines = prop.steps.map((s, i) => {
|
|
133
|
+
const hint = s.hintCount ? ` ← 本次看到 ${s.hintCount} 条相近命令` : "";
|
|
134
|
+
return ` ${i + 1}. ${s.id} ${s.title}${s.declared ? ` [${s.declared}]` : ""}${hint}`;
|
|
135
|
+
});
|
|
136
|
+
const parts = [t.awaiting(prop.pipelineId, prop.steps.length)];
|
|
137
|
+
parts.push(lines.join("\n"));
|
|
138
|
+
if (activityBlock) parts.push(activityBlock);
|
|
139
|
+
// 「这条命令不是动作」与「没归属上」是两件事,混在一起会让人以为工具坏了
|
|
140
|
+
if (result.shellNoise) parts.push(t.shellNoise(result.shellNoise));
|
|
141
|
+
if (result.notCommands) parts.push(t.notCommands(result.notCommands));
|
|
142
|
+
if (result.truncated) parts.push(t.truncated);
|
|
143
|
+
parts.push(t.confirmHow(prop.steps.map((s) => s.id).slice(0, 3).join(" ")));
|
|
144
|
+
return parts.filter(Boolean).join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
const parts = [t.ok(result.pipelineId, result.steps)];
|
|
149
|
+
if (activityBlock) parts.push(activityBlock);
|
|
150
|
+
if (result.truncated) parts.push(t.truncated);
|
|
151
|
+
return parts.filter(Boolean).join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
switch (result.reason) {
|
|
155
|
+
case "no-open-window":
|
|
156
|
+
return t.noWindow;
|
|
157
|
+
case "write-failed":
|
|
158
|
+
return t.writeFailed(result.detail);
|
|
159
|
+
default:
|
|
160
|
+
return t.failed(result.reason);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 把一次 `confirm()` 的结果翻成人话。 */
|
|
165
|
+
function describeConfirm(result, language) {
|
|
166
|
+
const t = STR.moRunConfirm[language];
|
|
167
|
+
if (result.ok) {
|
|
168
|
+
const parts = [t.ok(result.pipelineId, result.steps)];
|
|
169
|
+
if (result.notDone?.length) parts.push(t.notDone(result.notDone.join("、")));
|
|
170
|
+
if (result.unsure?.length) parts.push(t.unsure(result.unsure.join("、")));
|
|
171
|
+
if (result.unanswered?.length) parts.push(t.unanswered(result.unanswered.join("、")));
|
|
172
|
+
// P3 的核心产出:你说这步做了,但声明的命令这次一次都没出现。
|
|
173
|
+
// 三种可能(声明该改 / 本来不必敲 / 记错了)必须由人判——系统只负责把矛盾摆出来。
|
|
174
|
+
if (result.toolMismatches?.length) {
|
|
175
|
+
const rows = result.toolMismatches.map((m) => {
|
|
176
|
+
const seen = m.observedTop?.length ? `;本次实际看到 ${m.observedTop.join("、")}` : ";本次没有可对照的动作";
|
|
177
|
+
return ` ${m.stepId}(${m.title}):声明 ${m.declared},本次未出现${seen}`;
|
|
178
|
+
});
|
|
179
|
+
parts.push(t.mismatch(result.toolMismatches.length, rows.join("\n")));
|
|
180
|
+
}
|
|
181
|
+
// 旁挂回执:给不给只取决于 memoryDir 是否配置——静默关闭是设计,不是失败
|
|
182
|
+
if (result.observationsSaved?.ok) {
|
|
183
|
+
parts.push(t.obsSaved(result.observationsSaved.file));
|
|
184
|
+
}
|
|
185
|
+
return parts.filter(Boolean).join("\n");
|
|
186
|
+
}
|
|
187
|
+
switch (result.reason) {
|
|
188
|
+
case "nothing-to-confirm":
|
|
189
|
+
return t.nothingToConfirm;
|
|
190
|
+
case "no-answers":
|
|
191
|
+
// 说了"没做"是记录;什么都没说不是——这条区分很重要。
|
|
192
|
+
return t.noAnswers(result.notDone?.length ?? 0, result.unsure?.length ?? 0, result.unanswered?.length ?? 0);
|
|
193
|
+
case "bad-answer-value":
|
|
194
|
+
return t.badValue(result.stepId, result.value);
|
|
195
|
+
case "unknown-step":
|
|
196
|
+
return t.unknownStep(result.stepId);
|
|
197
|
+
case "write-failed":
|
|
198
|
+
return t.writeFailed(result.detail);
|
|
199
|
+
default:
|
|
200
|
+
return t.failed(result.reason);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeStart(result, language) {
|
|
205
|
+
const t = STR.moRunStart[language];
|
|
206
|
+
if (result.ok) return t.ok(result.pipelineId, result.steps);
|
|
207
|
+
switch (result.reason) {
|
|
208
|
+
case "already-open":
|
|
209
|
+
return t.alreadyOpen(result.pipelineId);
|
|
210
|
+
case "pipeline-not-found":
|
|
211
|
+
return t.notFound(result.available?.length ? result.available.join("、") : t.noneAvailable);
|
|
212
|
+
case "pipeline-unreadable":
|
|
213
|
+
return t.unreadable(result.detail);
|
|
214
|
+
case "no-project-root":
|
|
215
|
+
return t.noProject;
|
|
216
|
+
case "no-session":
|
|
217
|
+
return t.noSession;
|
|
218
|
+
default:
|
|
219
|
+
return t.failed(result.reason);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function describeStatus(status, language) {
|
|
224
|
+
const t = STR.moRunStatus[language];
|
|
225
|
+
if (status.awaitingConfirmation) return t.awaiting(status.pipelineId, status.steps ?? 0);
|
|
226
|
+
if (!status.open) return t.closed;
|
|
227
|
+
return t.open(status, status.recorded.join("、") || t.none);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 造命令的 handler。
|
|
232
|
+
*
|
|
233
|
+
* handler 的返回值走 DSH 的 CommandResult 契约:**只作 UI 回执,不会进模型上下文**。
|
|
234
|
+
* 这个功能的目的是记账,不是给模型派活,所以不需要 `agent.followup`。
|
|
235
|
+
*/
|
|
236
|
+
export function createMoRunHandler({ recorder, language = () => "zh" } = {}) {
|
|
237
|
+
return (invocation) => {
|
|
238
|
+
const lang = language();
|
|
239
|
+
try {
|
|
240
|
+
const parsed = parseMoRunInput(invocation?.rawInput);
|
|
241
|
+
const session = invocation?.agent?.session;
|
|
242
|
+
|
|
243
|
+
if (parsed.action === "help") {
|
|
244
|
+
return { kind: "success", text: STR.moRunUsage[lang] };
|
|
245
|
+
}
|
|
246
|
+
if (parsed.action === "start") {
|
|
247
|
+
if (!parsed.pipelineId) return { kind: "error", text: STR.moRunUsage[lang] };
|
|
248
|
+
return { kind: "success", text: describeStart(recorder.start({ session, pipelineId: parsed.pipelineId }), lang) };
|
|
249
|
+
}
|
|
250
|
+
if (parsed.action === "stop") {
|
|
251
|
+
return { kind: "success", text: describeStop(recorder.stop({ session }), lang) };
|
|
252
|
+
}
|
|
253
|
+
if (parsed.action === "confirm") {
|
|
254
|
+
const result = recorder.confirm({ session, answers: parsed.answers });
|
|
255
|
+
return { kind: result.ok ? "success" : "error", text: describeConfirm(result, lang) };
|
|
256
|
+
}
|
|
257
|
+
return { kind: "success", text: describeStatus(recorder.status({ session }), lang) };
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// 命令是用户主动触发的动作,不是热路径;出错就如实回报,但绝不把异常抛进宿主。
|
|
260
|
+
return { kind: "error", text: STR.moRunUsage[lang] };
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 注册命令。**默认关**:只有旗标打开才注册——beta 功能必须 opt-in,老用户行为不变。
|
|
267
|
+
*
|
|
268
|
+
* 返回 disposer(与仓库其它注册点一致);没注册时返回 null。
|
|
269
|
+
*/
|
|
270
|
+
export function registerMoRunCommand({ ctx, recorder, enabled, language = () => "zh" }) {
|
|
271
|
+
if (enabled !== true) return null;
|
|
272
|
+
if (typeof ctx?.commands?.register !== "function") return null;
|
|
273
|
+
return ctx.commands.register({
|
|
274
|
+
name: MO_RUN_COMMAND,
|
|
275
|
+
description: STR.moRunDescription[language()],
|
|
276
|
+
input: { hint: STR.moRunInputHint[language()] },
|
|
277
|
+
handler: createMoRunHandler({ recorder, language })
|
|
278
|
+
});
|
|
279
|
+
}
|