@modusensus/dsh-mneme 0.8.15 → 0.9.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/README.md +21 -3
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +16 -3
- package/docs/CONTINUITY.md +18 -3
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +294 -10
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2767 -337
- package/lib/config.js +99 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +37 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +7 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +72 -18
- package/lib/inject.js +33 -64
- package/lib/maintenance.js +25 -5
- package/lib/pipeline.js +534 -0
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +83 -17
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +19 -2
- package/lib/store.js +251 -10
- package/lib/summarize.js +239 -60
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api-standalone.js +22 -39
- package/src/api.js +294 -10
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +99 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +37 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +7 -1
- package/src/entities/extractor.js +4 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +72 -18
- package/src/inject.js +33 -64
- package/src/maintenance.js +25 -5
- package/src/pipeline.js +534 -0
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +83 -17
- package/src/session-usage.js +350 -0
- package/src/settings.js +19 -2
- package/src/store.js +251 -10
- package/src/summarize.js +239 -60
- package/src/tools.js +11 -2
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +171 -8
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +715 -4
- package/test/continuity.test.js +84 -0
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +68 -1
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +190 -1
- package/test/maintenance.test.js +112 -2
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/pre-inject-gate.test.js +10 -0
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/service.test.js +7 -2
- package/test/session-usage.test.js +308 -0
- package/test/settings.test.js +8 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +350 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
package/lib/pipeline.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M.O.(Modus Operandi · 手法)工作流的**读侧**。
|
|
3
|
+
*
|
|
4
|
+
* 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库:
|
|
5
|
+
* 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退;
|
|
6
|
+
* 记忆库只存「为什么有这条」(证据)与「跑得怎么样」(结算)。
|
|
7
|
+
* 三句话的分工:记忆是「为什么」,仓库是「怎么跑」,图是「看起来怎么样」。
|
|
8
|
+
*
|
|
9
|
+
* 这里只做三件事:读盘 → 校验(宁可读不出来,也不要读出一个半成品)→ 投影成
|
|
10
|
+
* `mneme.pipeline/v1`(前端与 agent 共用的那一份结构化数据)。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不用 YAML:本包运行时依赖只有 `morphicons` 一个,为一个配置文件引 YAML
|
|
13
|
+
* 解析器不划算;手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑。
|
|
14
|
+
* 人读的形态由面板渲染负责,不靠文件格式。
|
|
15
|
+
*
|
|
16
|
+
* **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。
|
|
17
|
+
* 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套
|
|
18
|
+
* 「全局手法」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
|
|
19
|
+
* `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一
|
|
20
|
+
* 理由是接那些**跨项目复用**的手法,而跨项目复用是一个要留痕的显式动作。
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
23
|
+
import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
// 两种路径语义各自指名一次,别让调用点再自己判断平台。
|
|
27
|
+
const basenamePosix = pathPosix.basename;
|
|
28
|
+
const basenameWin32 = pathWin32.basename;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* **全局层**的家:插件包自带的 `pipelines/`。算法是「本模块所在目录的上一级」——
|
|
32
|
+
* src/ 与 lib/ 都在包根下,所以这个式子对源码树和发布包同时成立。
|
|
33
|
+
*
|
|
34
|
+
* 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天
|
|
35
|
+
* 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**:
|
|
36
|
+
* 一条手法该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。
|
|
37
|
+
*/
|
|
38
|
+
export function defaultPipelineDir() {
|
|
39
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** **项目层**的家:`<项目>/.mneme/pipelines/*.json`。点开头不碍眼,但仍随仓库提交。 */
|
|
43
|
+
export const PROJECT_PIPELINE_SUBDIR = ".mneme";
|
|
44
|
+
|
|
45
|
+
export function projectPipelineDir(projectRoot) {
|
|
46
|
+
return join(projectRoot, PROJECT_PIPELINE_SUBDIR, "pipelines");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 项目根 → 面板上那个作用域标签。**只取目录名,不把整条绝对路径画出去**:
|
|
51
|
+
* 绝对路径里有本机用户名,而面板是会被截图、被贴进 issue 的那一面。
|
|
52
|
+
* 机器要用的完整路径另在 `sources[].dir`,那是数据,不是给人读的。
|
|
53
|
+
*/
|
|
54
|
+
export function projectLabel(projectRoot) {
|
|
55
|
+
const root = String(projectRoot ?? "");
|
|
56
|
+
// **语义显式指名,不借 path.basename()**:它跟着运行平台走——posix 上 `\` 不是
|
|
57
|
+
// 分隔符,一条 Windows 路径会被整条当成标签,而面板正是会被截图、被贴进 issue
|
|
58
|
+
// 的那一面,标签里于是带着本机用户名,恰好是本函数要挡住的东西。
|
|
59
|
+
// 含反斜杠按 Windows 语义解析,否则按 posix——两个平台的正确结果就此一致。
|
|
60
|
+
// Windows 腿对旧写法**结构性看不见**(win32.basename 两种分隔符都认,旧实现在
|
|
61
|
+
// 本机算出来就是对的),所以这条的杀伤力只在 ubuntu 矩阵腿上。
|
|
62
|
+
const base = root.includes("\\") ? basenameWin32(root) : basenamePosix(root);
|
|
63
|
+
return base === "" ? root : base;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读取来源,**项目层在前、全局层在后**——顺序就是优先级:同 id 时项目层赢。
|
|
68
|
+
*
|
|
69
|
+
* 没有项目根(会话工作目录那一刻不可得)时只给全局层,并如实标 `project: null`;
|
|
70
|
+
* **绝不回退到进程自己所在的目录**:那会把「读不到」变成「读到别的地方」,
|
|
71
|
+
* 而后者在界面上看不出来,是最难查的一类错。
|
|
72
|
+
*/
|
|
73
|
+
export function pipelineSources({ projectRoot = null, pluginDir = defaultPipelineDir() } = {}) {
|
|
74
|
+
const sources = [];
|
|
75
|
+
const root = typeof projectRoot === "string" ? projectRoot.trim() : "";
|
|
76
|
+
if (root !== "") sources.push({ scope: "project", label: projectLabel(root), dir: projectPipelineDir(root) });
|
|
77
|
+
sources.push({ scope: "global", label: null, dir: pluginDir });
|
|
78
|
+
return sources;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 风险四档(R0–R3)。跨档按最高档算;子 agent 只准接 R0 / R1。 */
|
|
82
|
+
export const RISK_LEVELS = ["R0", "R1", "R2", "R3"];
|
|
83
|
+
|
|
84
|
+
/** 这一步谁做。`human` 与 `ci` 是闸门与流水线自己的位置,不是 agent。 */
|
|
85
|
+
export const ACTORS = ["main", "subagent", "human", "ci"];
|
|
86
|
+
|
|
87
|
+
/** 工作流状态:草稿 / 生效 / 退役。退役不删文件——留着才知道它曾经存在过。 */
|
|
88
|
+
export const STATUSES = ["draft", "active", "retired"];
|
|
89
|
+
|
|
90
|
+
/** 节点状态四值;`refuted` 是全篇核心信号。 */
|
|
91
|
+
export const NODE_STATES = ["candidate", "trusted", "stale", "refuted"];
|
|
92
|
+
|
|
93
|
+
/** 单层最多读多少条。目录被人塞满时宁可报错,也不要把一次面板刷新变成磁盘扫描。 */
|
|
94
|
+
const MAX_PIPELINES = 200;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 提议的阈值:跳过/绕开多少次就该提一句。
|
|
98
|
+
* 3 这个数来自标本 E2/E3——「某步跳过 ≥3 次 → 提议删掉;某动作你手动补过 ≥2 次 → 提议升成正式步骤」。
|
|
99
|
+
*/
|
|
100
|
+
export const RETIRE_AFTER_SKIPS = 3;
|
|
101
|
+
export const RETIRE_AFTER_DETOURS = 3;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 从运行记录里算出「该提议什么」。这是**图的自我精简**——不是人定期巡检,
|
|
105
|
+
* 而是计数到了就冒出来:
|
|
106
|
+
* 跳过 ≥3 次 → 提议删掉**这一步**(这一步对这条流程没用了)
|
|
107
|
+
* 绕开 ≥3 次 → 提议这条**路径**过时了(从这里岔出去成了常态,图该重画)
|
|
108
|
+
*
|
|
109
|
+
* 两者的区别是这一版的核心:**跳过 = 这次不用;绕开 = 这条路整个不对。**
|
|
110
|
+
* 只有绕开才动「路径」这一层。
|
|
111
|
+
*/
|
|
112
|
+
export function suggestRetirements(pipeline, { runs = [], now = Date.now() } = {}) {
|
|
113
|
+
const credit = tallyRuns(runs);
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const step of pipeline.steps) {
|
|
116
|
+
const c = credit.get(step.id);
|
|
117
|
+
if (!c) continue;
|
|
118
|
+
if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
|
|
119
|
+
out.push({
|
|
120
|
+
kind: "drop-step",
|
|
121
|
+
stepId: step.id,
|
|
122
|
+
title: step.title,
|
|
123
|
+
because: `被跳过 ${c.skipped} 次`,
|
|
124
|
+
effect: "提议删掉这一步——对这条流程它可能已经没用了"
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if ((c.detoured || 0) >= RETIRE_AFTER_DETOURS) {
|
|
128
|
+
out.push({
|
|
129
|
+
kind: "rework-path",
|
|
130
|
+
stepId: step.id,
|
|
131
|
+
title: step.title,
|
|
132
|
+
because: `被绕开 ${c.detoured} 次`,
|
|
133
|
+
effect: "提议重看这条路径——从这一步岔出去已经成了常态,图该重画"
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
141
|
+
const STEP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
142
|
+
|
|
143
|
+
/** 校验失败的统一出口:一次把话说完,别让人一轮修一条。 */
|
|
144
|
+
class PipelineError extends Error {
|
|
145
|
+
constructor(file, problems) {
|
|
146
|
+
super(`${file}: ${problems.join("; ")}`);
|
|
147
|
+
this.name = "PipelineError";
|
|
148
|
+
this.problems = problems;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isPlainObject(value) {
|
|
153
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isNonEmptyString(value) {
|
|
157
|
+
return typeof value === "string" && value.trim() !== "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 校验一条工作流。返回归一化后的对象;有任何问题就抛,不返回「部分可用」的东西
|
|
162
|
+
* ——半个工作流比没有工作流更危险,因为它会被当成完整的跑。
|
|
163
|
+
*/
|
|
164
|
+
export function validatePipeline(raw, file = "<pipeline>") {
|
|
165
|
+
const problems = [];
|
|
166
|
+
const bad = (msg) => problems.push(msg);
|
|
167
|
+
|
|
168
|
+
if (!isPlainObject(raw)) {
|
|
169
|
+
throw new PipelineError(file, ["顶层必须是一个对象"]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!isNonEmptyString(raw.id) || !ID_RE.test(raw.id)) {
|
|
173
|
+
bad("id 必须是小写 kebab(如 dsh-mneme-maintenance)");
|
|
174
|
+
}
|
|
175
|
+
if (!isNonEmptyString(raw.name)) bad("name 必须是字符串");
|
|
176
|
+
if (!isNonEmptyString(raw.project)) bad("project 必须是字符串(如 slow-stack/mneme)");
|
|
177
|
+
if (!Number.isInteger(raw.version) || raw.version < 0) bad("version 必须是从 0 起的整数");
|
|
178
|
+
if (!STATUSES.includes(raw.status)) bad(`status 必须是 ${STATUSES.join(" / ")}`);
|
|
179
|
+
if (!isNonEmptyString(raw.done)) bad("done 必须写清「整条怎么算完成」");
|
|
180
|
+
|
|
181
|
+
if (!Array.isArray(raw.trigger) || raw.trigger.length === 0) {
|
|
182
|
+
bad("trigger 至少要有一条");
|
|
183
|
+
} else {
|
|
184
|
+
raw.trigger.forEach((t, i) => {
|
|
185
|
+
if (!isPlainObject(t) || !isNonEmptyString(t.value)) {
|
|
186
|
+
bad(`trigger[${i}] 必须写成 { kind, value }`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
|
|
192
|
+
bad("steps 至少要有一级");
|
|
193
|
+
throw new PipelineError(file, problems);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ids = new Set();
|
|
197
|
+
for (const [i, step] of raw.steps.entries()) {
|
|
198
|
+
const at = isPlainObject(step) && isNonEmptyString(step.id) ? step.id : `steps[${i}]`;
|
|
199
|
+
if (!isPlainObject(step)) {
|
|
200
|
+
bad(`${at} 必须是对象`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!isNonEmptyString(step.id) || !STEP_ID_RE.test(step.id)) {
|
|
204
|
+
bad(`${at}.id 非法(如 A1 / C2)`);
|
|
205
|
+
} else if (ids.has(step.id)) {
|
|
206
|
+
bad(`${at}.id 重复`);
|
|
207
|
+
} else {
|
|
208
|
+
ids.add(step.id);
|
|
209
|
+
}
|
|
210
|
+
for (const key of ["phase", "title", "when", "do", "done", "why"]) {
|
|
211
|
+
if (!isNonEmptyString(step[key])) bad(`${at}.${key} 必须是非空字符串`);
|
|
212
|
+
}
|
|
213
|
+
if (!RISK_LEVELS.includes(step.risk)) bad(`${at}.risk 必须是 ${RISK_LEVELS.join(" / ")}`);
|
|
214
|
+
if (!ACTORS.includes(step.who)) bad(`${at}.who 必须是 ${ACTORS.join(" / ")}`);
|
|
215
|
+
if (!Array.isArray(step.needs)) bad(`${at}.needs 必须是数组(可空,表示这是一段的入口)`);
|
|
216
|
+
if (step.gate !== undefined && typeof step.gate !== "boolean") bad(`${at}.gate 只能是布尔`);
|
|
217
|
+
if (step.evidence !== undefined && !Array.isArray(step.evidence)) {
|
|
218
|
+
bad(`${at}.evidence 必须是字符串数组`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 先把 id 收集齐再校验引用,否则会按声明顺序报一堆假错。
|
|
223
|
+
for (const step of raw.steps) {
|
|
224
|
+
if (!Array.isArray(step.needs)) continue;
|
|
225
|
+
for (const need of step.needs) {
|
|
226
|
+
if (!ids.has(need)) bad(`${step.id}.needs 指向了不存在的步骤 ${need}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const cycles = findCycles(raw.steps, ids);
|
|
231
|
+
if (cycles.length) bad(`图里有环:${cycles.join(" / ")}(回写是新版本,不是回到自己)`);
|
|
232
|
+
|
|
233
|
+
if (problems.length) throw new PipelineError(file, problems);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
id: raw.id,
|
|
237
|
+
name: raw.name,
|
|
238
|
+
project: raw.project,
|
|
239
|
+
version: raw.version,
|
|
240
|
+
status: raw.status,
|
|
241
|
+
done: raw.done,
|
|
242
|
+
trigger: raw.trigger.map((t) => ({ kind: t.kind || "phrase", value: t.value, note: t.note || "" })),
|
|
243
|
+
inputs: Array.isArray(raw.inputs) ? raw.inputs.slice() : [],
|
|
244
|
+
steps: raw.steps.map((s) => ({
|
|
245
|
+
id: s.id,
|
|
246
|
+
phase: s.phase,
|
|
247
|
+
title: s.title,
|
|
248
|
+
when: s.when,
|
|
249
|
+
do: s.do,
|
|
250
|
+
who: s.who,
|
|
251
|
+
tool: isNonEmptyString(s.tool) ? s.tool : "",
|
|
252
|
+
done: s.done,
|
|
253
|
+
risk: s.risk,
|
|
254
|
+
needs: s.needs.slice(),
|
|
255
|
+
why: s.why,
|
|
256
|
+
evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
|
|
257
|
+
gate: s.gate === true
|
|
258
|
+
}))
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 三色 DFS 找环,把环上的步骤 id 拼出来(报错要能直接指到人看的那一行)。 */
|
|
263
|
+
function findCycles(steps, ids) {
|
|
264
|
+
const needsOf = new Map();
|
|
265
|
+
for (const step of steps) {
|
|
266
|
+
if (isNonEmptyString(step.id) && Array.isArray(step.needs)) {
|
|
267
|
+
needsOf.set(step.id, step.needs.filter((n) => ids.has(n)));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const state = new Map(); // 0 未访问 / 1 在栈上 / 2 已定
|
|
271
|
+
const cycles = [];
|
|
272
|
+
const walk = (id, trail) => {
|
|
273
|
+
if (state.get(id) === 2) return;
|
|
274
|
+
if (state.get(id) === 1) {
|
|
275
|
+
cycles.push([...trail.slice(trail.indexOf(id)), id].join(" → "));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
state.set(id, 1);
|
|
279
|
+
for (const next of needsOf.get(id) || []) walk(next, [...trail, id]);
|
|
280
|
+
state.set(id, 2);
|
|
281
|
+
};
|
|
282
|
+
for (const id of needsOf.keys()) walk(id, []);
|
|
283
|
+
return cycles;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 读一个目录下的全部工作流;坏文件直接抛,不做「跳过继续」——静默少一条最难查。
|
|
288
|
+
* 每条挂上 `origin`(哪一层来的、家在哪个目录、哪个文件),供上层判优先级与显示。
|
|
289
|
+
*/
|
|
290
|
+
export function loadPipelines(dir, { scope = "global", label = null } = {}) {
|
|
291
|
+
if (!existsSync(dir)) return [];
|
|
292
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
293
|
+
if (files.length > MAX_PIPELINES) {
|
|
294
|
+
throw new PipelineError(dir, [`目录里有 ${files.length} 个 .json,超过上限 ${MAX_PIPELINES}——这里应该是工作流目录,不是数据堆`]);
|
|
295
|
+
}
|
|
296
|
+
const out = [];
|
|
297
|
+
const seen = new Map();
|
|
298
|
+
for (const file of files) {
|
|
299
|
+
const full = join(dir, file);
|
|
300
|
+
let raw;
|
|
301
|
+
try {
|
|
302
|
+
raw = JSON.parse(readFileSync(full, "utf8"));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw new PipelineError(file, [`JSON 解析失败:${error.message}`]);
|
|
305
|
+
}
|
|
306
|
+
const pipeline = validatePipeline(raw, file);
|
|
307
|
+
if (seen.has(pipeline.id)) {
|
|
308
|
+
throw new PipelineError(file, [`id "${pipeline.id}" 与 ${seen.get(pipeline.id)} 重复`]);
|
|
309
|
+
}
|
|
310
|
+
seen.set(pipeline.id, file);
|
|
311
|
+
out.push({ ...pipeline, origin: { scope, label, dir, file } });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 从一条运行记录里数出「这次走过哪几步」——结算用。
|
|
318
|
+
*
|
|
319
|
+
* 每一步有三种处置,**计数必须分开**,因为它们驱动的判断完全不同:
|
|
320
|
+
* uses 照图走了(或走了但被推翻)
|
|
321
|
+
* skipped 这一步这次不用(图上还在)→ 跳过 ≥3 次就提议删掉这一步
|
|
322
|
+
* detoured 从这里岔出去走了图上没有的路 → 被绕开 ≥3 次说明这条路径过时了
|
|
323
|
+
*
|
|
324
|
+
* 「跳过」与「绕开」不是一回事:**跳过 = 这次不用这一步;绕开 = 这条路整个不对**。
|
|
325
|
+
* 混在一起数,两种提议就都发不出来了。
|
|
326
|
+
*/
|
|
327
|
+
function tallyRuns(runs) {
|
|
328
|
+
const perStep = new Map();
|
|
329
|
+
for (const run of runs || []) {
|
|
330
|
+
for (const step of run.steps || []) {
|
|
331
|
+
const cur = perStep.get(step.id) || { uses: 0, refuted: 0, lastUsedAt: null };
|
|
332
|
+
perStep.set(step.id, cur);
|
|
333
|
+
// 三种处置**各自独立累加**,谁也不吃谁:
|
|
334
|
+
// 一次绕行很可能同时是「这一步这次没用」+「从这里岔出去了」——
|
|
335
|
+
// 这两件事驱动的提议不同(删这一步 / 重画这条路径),吞掉一个就少一条提议。
|
|
336
|
+
// 第一版写成 if/else if,于是绕行把跳过吃掉了。
|
|
337
|
+
const detoured = step.detoured === true || step.kind === "detour";
|
|
338
|
+
const skipped = step.skipped === true;
|
|
339
|
+
if (detoured) cur.detoured = (cur.detoured || 0) + 1;
|
|
340
|
+
if (skipped) cur.skipped = (cur.skipped || 0) + 1;
|
|
341
|
+
// 只有「既没跳过也没绕行」才算真走了这一步——两种偏离都不是使用。
|
|
342
|
+
if (!detoured && !skipped) cur.uses += 1;
|
|
343
|
+
if (step.refuted) cur.refuted += 1;
|
|
344
|
+
if (step.at && (!cur.lastUsedAt || step.at > cur.lastUsedAt)) cur.lastUsedAt = step.at;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return perStep;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 节点状态由**对照数据**推出来,不由作者写死:
|
|
352
|
+
* 被推翻过 → refuted;用过没用砸 → trusted;有记录但久未使用 → stale;没有记录 → candidate。
|
|
353
|
+
* 没有运行记录时全体是 candidate —— 这是实话,也正是面板上那句 beta 免责必须摆在脸上的原因。
|
|
354
|
+
*
|
|
355
|
+
* 绕行不改变状态、只累加计数:**被绕开不等于这一步坏了**(可能只是这次不需要),
|
|
356
|
+
* 它推动的是「这条路径该重看了」这种**提议**,不是把节点染红。
|
|
357
|
+
* 染色要留给真正被推翻过的(refuted)——那是唯一有硬判据的信号。
|
|
358
|
+
*/
|
|
359
|
+
function deriveState(credit, now, staleAfterDays = 90) {
|
|
360
|
+
if (!credit || credit.uses === 0) return credit && credit.skipped ? "stale" : "candidate";
|
|
361
|
+
if (credit.refuted > 0) return "refuted";
|
|
362
|
+
if (credit.lastUsedAt) {
|
|
363
|
+
const age = (now - Date.parse(credit.lastUsedAt)) / 86400000;
|
|
364
|
+
if (Number.isFinite(age) && age > staleAfterDays) return "stale";
|
|
365
|
+
}
|
|
366
|
+
return "trusted";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 清单页要的那点信息:够渲染「名字 + 一句话 + 状态 + 作用域」,不必把整张图读出来。
|
|
371
|
+
* 面板第一屏是清单、点进去才是图,所以这一份是**首屏的全部输入**。
|
|
372
|
+
*/
|
|
373
|
+
function summarizePipeline(pipeline, runs, now) {
|
|
374
|
+
const credit = tallyRuns(runs);
|
|
375
|
+
const states = { candidate: 0, trusted: 0, stale: 0, refuted: 0 };
|
|
376
|
+
let gates = 0;
|
|
377
|
+
let contrast = 0;
|
|
378
|
+
for (const step of pipeline.steps) {
|
|
379
|
+
states[deriveState(credit.get(step.id) || null, now)] += 1;
|
|
380
|
+
if (step.gate) gates += 1;
|
|
381
|
+
if (step.evidence.length) contrast += 1;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
id: pipeline.id,
|
|
385
|
+
title: pipeline.name,
|
|
386
|
+
scope: pipeline.origin.scope,
|
|
387
|
+
scopeLabel: pipeline.origin.label,
|
|
388
|
+
project: pipeline.project,
|
|
389
|
+
status: pipeline.status,
|
|
390
|
+
version: pipeline.version,
|
|
391
|
+
done: pipeline.done,
|
|
392
|
+
trigger: pipeline.trigger,
|
|
393
|
+
file: pipeline.origin.file,
|
|
394
|
+
steps: pipeline.steps.length,
|
|
395
|
+
gates,
|
|
396
|
+
// 反事实对照覆盖率:19/19 与 6/19 是两条完全不同的工作流,清单上就该看得出来。
|
|
397
|
+
contrast,
|
|
398
|
+
states
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 投影成 `mneme.pipeline/v1`:前端渲染与 agent 读取用的是同一份数据,
|
|
404
|
+
* 画面只是它的投影——只画不存,agent 就什么也读不到。
|
|
405
|
+
*/
|
|
406
|
+
export function toPipelineV1(pipeline, { runs = [], now = Date.now(), memoryLinks = {} } = {}) {
|
|
407
|
+
const credit = tallyRuns(runs);
|
|
408
|
+
// 没经过 loadPipelines 的对象(手工 validate 出来的、别处传来的)没有来源。
|
|
409
|
+
// 这时 scope 落 null 而不是 "project":默认成项目级会让一条其实来路不明的工作流
|
|
410
|
+
// 看起来「就是本项目的」,而面板上没人能分辨。
|
|
411
|
+
const origin = pipeline.origin || { scope: null, label: null, dir: "", file: "" };
|
|
412
|
+
|
|
413
|
+
const nodes = pipeline.steps.map((step) => {
|
|
414
|
+
const c = credit.get(step.id) || null;
|
|
415
|
+
const link = memoryLinks[step.id] || null;
|
|
416
|
+
return {
|
|
417
|
+
id: step.id,
|
|
418
|
+
// gate / branch 是画法,不是第二套数据:闸门要能一眼看出来「这里会停下」。
|
|
419
|
+
kind: step.gate ? "gate" : step.needs.length > 1 ? "branch" : "step",
|
|
420
|
+
title: step.title,
|
|
421
|
+
phase: step.phase,
|
|
422
|
+
situation: step.when,
|
|
423
|
+
action: step.do,
|
|
424
|
+
why: step.why,
|
|
425
|
+
who: step.who,
|
|
426
|
+
tool: step.tool,
|
|
427
|
+
done: step.done,
|
|
428
|
+
risk: step.risk,
|
|
429
|
+
state: deriveState(c, now),
|
|
430
|
+
credit: c ? { uses: c.uses, refuted: c.refuted, skipped: c.skipped || 0, detoured: c.detoured || 0, lastUsedAt: c.lastUsedAt } : null,
|
|
431
|
+
// 反事实对照是**准入判据**的载体(「没有它这次会不会失败」),不是备注:
|
|
432
|
+
// 答不出来的步骤在图上要显得比答得出来的虚。
|
|
433
|
+
counterfactual: step.evidence.length
|
|
434
|
+
? { contrast: step.evidence.join(" / "), evidence: step.evidence.length }
|
|
435
|
+
: null,
|
|
436
|
+
// 记忆 id 是本机私有数据,不进仓库文件;有本地对照表才填。
|
|
437
|
+
source: link ? { memoryIds: link.memoryIds || [], originSession: link.originSession || null } : null
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const edges = [];
|
|
442
|
+
for (const step of pipeline.steps) {
|
|
443
|
+
for (const need of step.needs) {
|
|
444
|
+
edges.push({ id: `${need}->${step.id}`, from: need, to: step.id, kind: "control", label: "" });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
contract: "mneme.pipeline/v1",
|
|
450
|
+
pipeline: {
|
|
451
|
+
id: pipeline.id,
|
|
452
|
+
title: pipeline.name,
|
|
453
|
+
// scope 是**从哪儿读出来的**,不是作者写的:同一条文件放进项目层就变项目级。
|
|
454
|
+
scope: origin.scope,
|
|
455
|
+
scopeLabel: origin.label,
|
|
456
|
+
project: pipeline.project,
|
|
457
|
+
file: origin.file,
|
|
458
|
+
status: pipeline.status,
|
|
459
|
+
version: pipeline.version,
|
|
460
|
+
done: pipeline.done,
|
|
461
|
+
trigger: pipeline.trigger,
|
|
462
|
+
inputs: pipeline.inputs,
|
|
463
|
+
updatedAt: null
|
|
464
|
+
},
|
|
465
|
+
nodes,
|
|
466
|
+
edges,
|
|
467
|
+
runs
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 面板与 agent 的公共入口:按来源顺序读全两层,投影出第一条(或指定 id 的那条),
|
|
473
|
+
* 并**总是**给出清单(`available`)——首屏是清单,图是点进去才要的东西。
|
|
474
|
+
*
|
|
475
|
+
* `projectRoot` 是会话工作目录。给不出来时只读全局层,并在 `sources` 里如实标
|
|
476
|
+
* `project: null`、`available` 为空:界面据此说「这个会话的工作目录拿不到」,
|
|
477
|
+
* 而不是悄悄显示一份不属于任何项目的清单。
|
|
478
|
+
*/
|
|
479
|
+
export function readPipelineDoc({
|
|
480
|
+
projectRoot = null,
|
|
481
|
+
pluginDir = defaultPipelineDir(),
|
|
482
|
+
id = null,
|
|
483
|
+
runsById = {},
|
|
484
|
+
now = Date.now(),
|
|
485
|
+
memoryLinks = {}
|
|
486
|
+
} = {}) {
|
|
487
|
+
const sources = pipelineSources({ projectRoot, pluginDir });
|
|
488
|
+
const ordered = [];
|
|
489
|
+
const byId = new Map();
|
|
490
|
+
const shadowed = [];
|
|
491
|
+
const unreadable = [];
|
|
492
|
+
|
|
493
|
+
for (const source of sources) {
|
|
494
|
+
let list;
|
|
495
|
+
try {
|
|
496
|
+
list = loadPipelines(source.dir, { scope: source.scope, label: source.label });
|
|
497
|
+
} catch (error) {
|
|
498
|
+
// 项目层里有个坏文件,不该把全局层一起拖死:如实记下来继续读下一层。
|
|
499
|
+
// 全局层是自家发出去的包,坏了就是坏了——照抛。
|
|
500
|
+
if (source.scope === "global") throw error;
|
|
501
|
+
unreadable.push({ scope: source.scope, dir: source.dir, detail: error.message });
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
for (const pipeline of list) {
|
|
505
|
+
const already = byId.get(pipeline.id);
|
|
506
|
+
if (already) {
|
|
507
|
+
// 项目层赢是**安排好的**,不是巧合:记下来,免得以后有人以为丢了文件。
|
|
508
|
+
shadowed.push({ id: pipeline.id, hidden: source.scope, by: already.origin.scope });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
byId.set(pipeline.id, pipeline);
|
|
512
|
+
ordered.push(pipeline);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const available = ordered.map((p) => summarizePipeline(p, runsById[p.id] || [], now));
|
|
517
|
+
const chosen = (id ? byId.get(id) : ordered[0]) || null;
|
|
518
|
+
|
|
519
|
+
const base = {
|
|
520
|
+
contract: "mneme.pipeline/v1",
|
|
521
|
+
sources: sources.map((s) => ({ scope: s.scope, label: s.label, dir: s.dir, present: existsSync(s.dir) })),
|
|
522
|
+
available,
|
|
523
|
+
shadowed,
|
|
524
|
+
unreadable
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
if (!chosen) return { ...base, pipeline: null, nodes: [], edges: [], runs: [], suggestions: [] };
|
|
528
|
+
return {
|
|
529
|
+
...toPipelineV1(chosen, { runs: runsById[chosen.id] || [], now, memoryLinks }),
|
|
530
|
+
...base,
|
|
531
|
+
// 图的自我精简:计数到了就把提议冒出来,不用人等巡检。
|
|
532
|
+
suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now })
|
|
533
|
+
};
|
|
534
|
+
}
|
package/lib/recall-stats.js
CHANGED
|
@@ -24,11 +24,18 @@ import { scopeKeyOf } from "./scope.js";
|
|
|
24
24
|
* 上限,超出标 truncated(degraded 口径),不静默少算。
|
|
25
25
|
* - archive(#275 拍板的第五指标,与上面几项同位):
|
|
26
26
|
* · total = 归档区现有行数;addedInWindow / perDay = 窗口内进入归档区的行数
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* setArchived
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* 与其日均(净增速率)。归档时刻取 archived_at(#275 第二批落的精确列):
|
|
28
|
+
* setArchived 只在「活跃 → 归档」那一次落值,取消归档清 NULL,重复归档不刷新。
|
|
29
|
+
* 本指标 v1 曾用 updated_at 代理(setArchived 翻标志位时刷它),偏差方向是
|
|
30
|
+
* **偏高**——对已归档行做一次 memory_update、或对同一行重复调 setArchived(…,
|
|
31
|
+
* true),都会把旧归档算成「本窗口新进」。archived_at 就是为消灭这两个假阳性
|
|
32
|
+
* 而加的,别再退回 updated_at。真正的「净」增本来也要跨快照比 total(物理
|
|
33
|
+
* 删除落地后,差值才会由负向变化体现)。
|
|
34
|
+
* · undated = archived_at 为 NULL 的归档行数(本列落地前就已归档的存量行,时刻
|
|
35
|
+
* 不可追溯)。**刻意不计入 addedInWindow**:它们的归档时刻早于本列落地,无法
|
|
36
|
+
* 证明落在窗口内还是窗口外(迁移当刻,窗口内的存量归档行同样会是 NULL)。用
|
|
37
|
+
* updated_at 顶替就是走回被推翻的代理口径;按 0 充数则是把「不知道」伪装成
|
|
38
|
+
* 「没有」。单列出来让读侧自判可信度——宁少不脏。
|
|
32
39
|
* · compressible = 可压掉行数:同 type、同 scope 三维且内容哈希完全相同的归档
|
|
33
40
|
* 行里,多出来的那些(每组留一行)。用内容哈希而不是向量近重复,是因为回收
|
|
34
41
|
* 动作本身会清掉归档行向量(clearArchivedEmbeddings)——指标不能建在它自己
|
|
@@ -85,12 +92,19 @@ export function recallStats(store, options = {}) {
|
|
|
85
92
|
// (type + 哈希 + scope 三维)——可压掉的必须是「本来就会被判成同一件事」的行。
|
|
86
93
|
let archivedTotal = 0;
|
|
87
94
|
let archivedInWindow = 0;
|
|
95
|
+
let archivedUndated = 0;
|
|
88
96
|
const hashGroups = new Map();
|
|
89
97
|
for (const m of memories) {
|
|
90
98
|
if (m.archived) {
|
|
91
99
|
archivedTotal += 1;
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
// 精确口径:只认 archived_at。NULL = 本列落地前的存量归档行,时刻不可追溯
|
|
101
|
+
// (见文件头 undated 段)——不拿 updated_at 顶替,那正是被本批推翻的代理口径。
|
|
102
|
+
const archivedMs = Date.parse(m.archived_at ?? "");
|
|
103
|
+
if (Number.isFinite(archivedMs)) {
|
|
104
|
+
if (archivedMs >= sinceMs) archivedInWindow += 1;
|
|
105
|
+
} else {
|
|
106
|
+
archivedUndated += 1;
|
|
107
|
+
}
|
|
94
108
|
if (m.content_hash) {
|
|
95
109
|
const key = [
|
|
96
110
|
m.type ?? "",
|
|
@@ -161,6 +175,7 @@ export function recallStats(store, options = {}) {
|
|
|
161
175
|
total: archivedTotal,
|
|
162
176
|
addedInWindow: archivedInWindow,
|
|
163
177
|
perDay: Math.round((archivedInWindow / windowDays) * 100) / 100,
|
|
178
|
+
undated: archivedUndated,
|
|
164
179
|
compressible: { rows: compressibleRows, groups: compressibleGroups }
|
|
165
180
|
},
|
|
166
181
|
zombie: {
|