@modusensus/dsh-mneme 0.8.16 → 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 +16 -3
- package/assets/icon.svg +2 -2
- package/docs/CONFIGURATION.md +2 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api.js +124 -8
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +1703 -182
- package/lib/config.js +31 -5
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +49 -12
- package/lib/inject.js +6 -1
- package/lib/pipeline.js +534 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +7 -1
- package/lib/store.js +55 -3
- package/lib/summarize.js +31 -0
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api.js +124 -8
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +31 -5
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/entities/extractor.js +4 -0
- package/src/index.js +49 -12
- package/src/inject.js +6 -1
- package/src/pipeline.js +534 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +7 -1
- package/src/store.js +55 -3
- package/src/summarize.js +31 -0
- package/src/tools.js +11 -2
- package/test/api.test.js +147 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +361 -2
- package/test/continuity.test.js +84 -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/inject-recall.test.js +32 -4
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -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/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
package/lib/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { matchesPlatform } from "./runtime/closure.js";
|
|
|
7
7
|
import { verifyPayload } from "./runtime/verify.js";
|
|
8
8
|
import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
|
|
9
9
|
import { injectChildEnabled } from "./config.js";
|
|
10
|
+
import { lastUserQuery } from "./inject.js";
|
|
10
11
|
|
|
11
12
|
const TEXT_OUTPUT = (text) => [{ type: "text", text }];
|
|
12
13
|
// Per-registry tool-name registry: guards against duplicate registration on
|
|
@@ -381,6 +382,14 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
381
382
|
render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
|
|
382
383
|
},
|
|
383
384
|
async execute(args) {
|
|
385
|
+
// v0.9.0:失败表的 query 是「当时到底想干什么」唯一的落点,而 service
|
|
386
|
+
// 层看不到会话(它只认 ctx.query,见 src/service.js 的 saveFailure 注释)。
|
|
387
|
+
// 工具层是唯一同时握着改写动作与本轮会话的入口:没给 reason 就退回当轮用户
|
|
388
|
+
// 原话,取法与注入侧共用同一份实现(src/inject.js 的 lastUserQuery),不复制
|
|
389
|
+
// 第二份。两边都取不到时仍然写 NULL——空串会假装「有意图」,NULL 才说实话。
|
|
390
|
+
const exec = arguments[1];
|
|
391
|
+
const reason = typeof args.reason === "string" ? args.reason.trim() : "";
|
|
392
|
+
const query = reason || lastUserQuery(exec) || undefined;
|
|
384
393
|
const memory = service.update(args.id, {
|
|
385
394
|
title: args.title,
|
|
386
395
|
content: args.content,
|
|
@@ -389,10 +398,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
389
398
|
importance: args.importance,
|
|
390
399
|
// 复核项 4(#170):strictScope 下他 scope 的行按「不存在」处理(与 get 对齐),
|
|
391
400
|
// 展开恒为 undefined,仅承担写入前校验。
|
|
392
|
-
...(assertVisibleForTool(args.id,
|
|
401
|
+
...(assertVisibleForTool(args.id, exec)),
|
|
393
402
|
...(args.agent_scope !== undefined ? { agent_scope: args.agent_scope } : {}),
|
|
394
403
|
...(args.workspace_scope !== undefined ? { workspace_scope: args.workspace_scope } : {})
|
|
395
|
-
}, { query
|
|
404
|
+
}, { query });
|
|
396
405
|
return { memory: { id: memory.id, title: memory.title, content: memory.content } };
|
|
397
406
|
}
|
|
398
407
|
}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// 发布闸门:publish 步之后**必须**确认这个版本真的能被 registry 看见,看不见就整轮失败。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:原先这一步是
|
|
4
|
+
// echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"
|
|
5
|
+
// 命令替换里的失败**不会**让 `set -e` 退出。v0.8.16 那次日志白纸黑字写着
|
|
6
|
+
// `npm error code E404 / 404 No match found for version 0.8.16`,step 与 job 却都是绿的。
|
|
7
|
+
// 也就是说「真发不出去」(token 失效、权限不足、npm 侧拒绝)与「发出去了」在 CI 上是同一种
|
|
8
|
+
// 颜色,只有人翻日志才看得出来 —— 闸门的意义恰恰是没人翻日志的时候。
|
|
9
|
+
//
|
|
10
|
+
// 为什么是轮询而不是一次性查询:registry 有传播延迟,v0.8.16 实测 publish 到可见约 4.5 分钟
|
|
11
|
+
// (11:15:52Z → 11:20:22Z)。一次性查询只有两种结局:忽略失败(就是现在的假绿),或者拿传播
|
|
12
|
+
// 延迟当发布失败(假红)。幂等跳过那条路径(版本已存在)**首次探测即通过**,所以只有真发新版
|
|
13
|
+
// 才付等待成本。
|
|
14
|
+
//
|
|
15
|
+
// 为什么探测走 registry 公开 HTTP 面而不是 `npm view`:不依赖 .npmrc 里的 token、不依赖 npm CLI
|
|
16
|
+
// 的 E404 语义,而且能把「还没传播」与「网络异常」分开报出来 —— 两种都重试,但日志里要能区分,
|
|
17
|
+
// 否则排障时又得靠猜。
|
|
18
|
+
//
|
|
19
|
+
// 与 scripts/changelog-prep.mjs 同一个理由:逻辑收在可注入的模块里才测得到(probe/sleep/now 全
|
|
20
|
+
// 可注入,单测零网络零等待),入口只负责读参数与决定退出码。
|
|
21
|
+
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
|
|
24
|
+
export const PACKAGE_NAME = "@modusensus/dsh-mneme";
|
|
25
|
+
export const REGISTRY_ORIGIN = "https://registry.npmjs.org";
|
|
26
|
+
|
|
27
|
+
/** 默认 10 分钟:实测传播 ~4.5 分钟,留一倍余量;超时=发布失败,宁可红也不许假绿。 */
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
29
|
+
/** 默认 15 秒:一次发布最多 40 次探测,日志行数与 registry 负载都可接受。 */
|
|
30
|
+
export const DEFAULT_INTERVAL_MS = 15 * 1000;
|
|
31
|
+
/** 单次探测的 HTTP 超时:挂死的请求必须自己失败,不能吃掉整段预算。 */
|
|
32
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 15 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 单版本的 registry URL。scoped 包名必须整体 encodeURIComponent(`@scope/name` → `%40scope%2Fname`):
|
|
36
|
+
* 直接拼裸 `/` 会被当成路径分隔符,registry 回 404 —— 那正是「闸门自己造成的假红」。
|
|
37
|
+
*/
|
|
38
|
+
export function versionUrl(name, version, origin = REGISTRY_ORIGIN) {
|
|
39
|
+
return `${origin.replace(/\/+$/, "")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 探测一次「该版本是否已在 registry 上」。
|
|
44
|
+
*
|
|
45
|
+
* 永不抛错:任何异常都折成 `{ok:false, reason, detail}`,让上层的重试与超时逻辑保持单一形状。
|
|
46
|
+
* `reason` 取值:`missing`(404,多半是还没传播) / `network`(请求本身失败) / `http`(非 2xx/404) /
|
|
47
|
+
* `bad-json` / `mismatch`(200 但版本号对不上)。
|
|
48
|
+
*
|
|
49
|
+
* @param {string} version 如 "0.8.16"
|
|
50
|
+
* @param {{name?: string, origin?: string, fetchImpl?: typeof fetch, timeoutMs?: number}} [options]
|
|
51
|
+
* @returns {Promise<{ok: true, version: string, tarball: string} | {ok: false, reason: string, detail: string}>}
|
|
52
|
+
*/
|
|
53
|
+
export async function probeVersion(version, {
|
|
54
|
+
name = PACKAGE_NAME,
|
|
55
|
+
origin = REGISTRY_ORIGIN,
|
|
56
|
+
fetchImpl = globalThis.fetch,
|
|
57
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS
|
|
58
|
+
} = {}) {
|
|
59
|
+
const url = versionUrl(name, version, origin);
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetchImpl(url, {
|
|
63
|
+
headers: { accept: "application/json" },
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { ok: false, reason: "network", detail: `请求失败:${error?.message ?? error}(${url})` };
|
|
68
|
+
}
|
|
69
|
+
if (response.status === 404) {
|
|
70
|
+
return { ok: false, reason: "missing", detail: `HTTP 404,registry 上还看不到(${url})` };
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
return { ok: false, reason: "http", detail: `HTTP ${response.status}(${url})` };
|
|
74
|
+
}
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = await response.json();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { ok: false, reason: "bad-json", detail: `响应不是 JSON:${error?.message ?? error}` };
|
|
80
|
+
}
|
|
81
|
+
if (body?.version !== version) {
|
|
82
|
+
return { ok: false, reason: "mismatch", detail: `registry 返回的版本是 ${body?.version ?? "(缺失)"}` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, version, tarball: body?.dist?.tarball ?? "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 轮询到版本可见为止;超时即 `{ok:false, reason:"timeout"}`。
|
|
89
|
+
*
|
|
90
|
+
* 契约(单测锁的就是这条):**看不见就绝不返回 ok:true**。任何把超时折叠成成功、或者把
|
|
91
|
+
* 「探测失败」当成「跳过校验」的改法,都等于退回 v0.8.16 那种假绿。
|
|
92
|
+
*
|
|
93
|
+
* @param {string} version
|
|
94
|
+
* @param {{name?: string, origin?: string, timeoutMs?: number, intervalMs?: number,
|
|
95
|
+
* now?: () => number, sleep?: (ms: number) => Promise<void>,
|
|
96
|
+
* probe?: () => Promise<{ok: boolean, detail?: string}>, log?: (line: string) => void}} [options]
|
|
97
|
+
*/
|
|
98
|
+
export async function waitForPublished(version, {
|
|
99
|
+
name = PACKAGE_NAME,
|
|
100
|
+
origin = REGISTRY_ORIGIN,
|
|
101
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
102
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
103
|
+
now = () => Date.now(),
|
|
104
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
105
|
+
probe = () => probeVersion(version, { name, origin }),
|
|
106
|
+
log = () => {}
|
|
107
|
+
} = {}) {
|
|
108
|
+
const startedAt = now();
|
|
109
|
+
let attempts = 0;
|
|
110
|
+
let lastError = "";
|
|
111
|
+
for (;;) {
|
|
112
|
+
attempts += 1;
|
|
113
|
+
const result = await probe();
|
|
114
|
+
// 探测成功即算通过,**不再回头比期限**:预算是「等多久才放弃」,不是「多久之后就算没发布」。
|
|
115
|
+
// 单次探测自带 HTTP 超时(默认 15 秒),所以晚到的成功最多超出预算十几秒;而版本真的在
|
|
116
|
+
// registry 上就等于发布成功,因为晚到几秒把它判红才是假红——这个闸门存在的理由正是消灭
|
|
117
|
+
// 假绿,不该顺手制造另一种失真。反过来,探测失败仍严格按预算收口(见下)。
|
|
118
|
+
if (result?.ok) {
|
|
119
|
+
return { ok: true, version, attempts, waitedMs: now() - startedAt, tarball: result.tarball ?? "" };
|
|
120
|
+
}
|
|
121
|
+
lastError = result?.detail ?? "未知原因";
|
|
122
|
+
const waitedMs = now() - startedAt;
|
|
123
|
+
log(`第 ${attempts} 次探测(已等 ${Math.round(waitedMs / 1000)}s):${lastError}`);
|
|
124
|
+
if (waitedMs >= timeoutMs) {
|
|
125
|
+
return { ok: false, reason: "timeout", attempts, waitedMs, lastError };
|
|
126
|
+
}
|
|
127
|
+
// 只睡到预算耗尽为止:最后一次探测落在超时点上,不额外多睡一整轮。
|
|
128
|
+
await sleep(Math.min(intervalMs, timeoutMs - waitedMs));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 入口参数:位置参数是版本号,另可覆盖 `--timeout-ms` / `--interval-ms`(排障与手动跑用)。
|
|
134
|
+
* 缺版本号如实回报 `missing-version`,由入口打印用法并退出 1。
|
|
135
|
+
*/
|
|
136
|
+
export function parseArgs(argv = []) {
|
|
137
|
+
const out = { version: "", timeoutMs: DEFAULT_TIMEOUT_MS, intervalMs: DEFAULT_INTERVAL_MS };
|
|
138
|
+
const positional = [];
|
|
139
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
140
|
+
const arg = argv[i];
|
|
141
|
+
if (arg === "--timeout-ms" || arg === "--interval-ms") {
|
|
142
|
+
const key = arg === "--timeout-ms" ? "timeoutMs" : "intervalMs";
|
|
143
|
+
const value = Number(argv[i + 1]);
|
|
144
|
+
if (!Number.isFinite(value) || value <= 0) return { ok: false, reason: `${arg} 需要一个正数` };
|
|
145
|
+
out[key] = value;
|
|
146
|
+
i += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
positional.push(arg);
|
|
150
|
+
}
|
|
151
|
+
if (positional.length !== 1) return { ok: false, reason: "missing-version" };
|
|
152
|
+
out.version = positional[0];
|
|
153
|
+
return { ok: true, ...out };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 入口:CI 与手动都用它;失败一律 `process.exit(1)`,绝不吞掉。 */
|
|
157
|
+
export async function main(argv = process.argv.slice(2), {
|
|
158
|
+
log = console.log,
|
|
159
|
+
error = console.error,
|
|
160
|
+
wait = waitForPublished
|
|
161
|
+
} = {}) {
|
|
162
|
+
const parsed = parseArgs(argv);
|
|
163
|
+
if (!parsed.ok) {
|
|
164
|
+
error(`用法:node scripts/publish-gate.mjs <版本号> [--timeout-ms 600000] [--interval-ms 15000]`);
|
|
165
|
+
error(`参数有误:${parsed.reason}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
log(`等待 ${PACKAGE_NAME}@${parsed.version} 在 registry 上可见(最多 ${parsed.timeoutMs / 1000}s)…`);
|
|
169
|
+
const result = await wait(parsed.version, {
|
|
170
|
+
timeoutMs: parsed.timeoutMs,
|
|
171
|
+
intervalMs: parsed.intervalMs,
|
|
172
|
+
log
|
|
173
|
+
});
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
error(
|
|
176
|
+
`发布闸门未通过:${PACKAGE_NAME}@${parsed.version} 在 ${Math.round(result.waitedMs / 1000)}s 内` +
|
|
177
|
+
`(${result.attempts} 次探测)始终看不到 —— 最后一次:${result.lastError}`
|
|
178
|
+
);
|
|
179
|
+
error(`publish 步很可能失败了(token 失效 / 权限不足 / npm 侧拒绝),请翻上面 publish 步的日志。`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const waitedSec = Math.round(result.waitedMs / 1000);
|
|
183
|
+
log(`✓ ${PACKAGE_NAME}@${result.version} 已在 registry 上可见(第 ${result.attempts} 次探测,等了 ${waitedSec}s)`);
|
|
184
|
+
if (result.tarball) log(` tarball: ${result.tarball}`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// import 即执行会让单测没法只 import 纯函数(changelog-prep.mjs 的同一教训),故用主模块判别。
|
|
189
|
+
// 比较用 pathToFileURL:手拼 `file://${argv[1]}` 在本机(Windows 盘符 + 非 ASCII 路径)拼不出合法 URL,
|
|
190
|
+
// 判别会恒假 —— 那样的失败是静默的:脚本什么也不做,还退出 0。
|
|
191
|
+
//
|
|
192
|
+
// 用 `process.exitCode` 而不是 `process.exit()`:刚做完 HTTP 请求就硬退,会在 Windows 上撞到
|
|
193
|
+
// libuv 的 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`(实测 0.8.16 成功路径复现),
|
|
194
|
+
// 退出码变成 0xC0000409 而不是 0 —— 一个把成功报成崩溃的退出路径,正好与本次要修的假绿同类。
|
|
195
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
196
|
+
process.exitCode = await main();
|
|
197
|
+
}
|
package/src/api.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
2
3
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
4
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
4
5
|
import { TYPES } from "./store.js";
|
|
5
6
|
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
|
|
6
7
|
import { langOf } from "./lang.js";
|
|
7
8
|
import { computeHeat } from "./heat.js";
|
|
8
|
-
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
9
|
+
import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
|
|
9
10
|
import { getInjectionSnapshot } from "./inject.js";
|
|
10
11
|
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
11
12
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
12
13
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
13
14
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
15
|
+
import { readPipelineDoc } from "./pipeline.js";
|
|
16
|
+
import { readCandidateDoc } from "./candidates.js";
|
|
14
17
|
|
|
15
18
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
16
19
|
function sendJson(res, status, payload, headers = {}) {
|
|
@@ -376,13 +379,30 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
376
379
|
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
377
380
|
// mirrors what the consolidation would send — the panel can verify a model
|
|
378
381
|
// actually accepts the configured effort without tripping a full run.
|
|
382
|
+
//
|
|
383
|
+
// 档位被拒时探针必须和真实 run 一样降级:这里是全仓唯一「接受并转发调用方档位、
|
|
384
|
+
// 却此前没有兜底」的调用面——巩固/睡眠/蒸馏/总览/实体抽取都包在 withEffortFallback
|
|
385
|
+
// 里,档位被拒就丢掉字段重试一次(issue #9/#109/#135)。直调 llm.stream 的其余各面
|
|
386
|
+
// 要么本来就在兜底之内(dream.js/summarize.js/sleep.js 的共用辅助),要么是
|
|
387
|
+
// src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样根本不转发档位。
|
|
388
|
+
// 探针原先没有这层,于是同一份配置「后台真的跑得通、点一下测试就报红」,把用户
|
|
389
|
+
// 引向「换哪家 Provider 都失败」的错误诊断(issue #215 的后续)。降级跑通后响应带
|
|
390
|
+
// effortDropped:true,面板据此说明「档位不被接受、已按无档位连通」,而不是与「真的
|
|
391
|
+
// 连不上」同色。
|
|
379
392
|
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
380
393
|
const startedAt = Date.now();
|
|
381
394
|
const elapsed = () => Date.now() - startedAt;
|
|
382
395
|
const modelId = `${provider}:${model}`;
|
|
383
|
-
|
|
396
|
+
// 流级失败原因:withEffortFallback 只用它做拒收甄别,回给面板的仍是同一条
|
|
397
|
+
// describeStreamFailure 文案(与审计行同口径,不制造第二套措辞)。
|
|
398
|
+
let streamError = "";
|
|
399
|
+
// 降级标记刻意放在 attempt 之外:fallback 里置位、attempt 里读出,结果对象
|
|
400
|
+
// 据此带上 effortDropped——降级是「换个姿势跑通了」,不是失败。
|
|
401
|
+
let droppedEffort = false;
|
|
402
|
+
|
|
403
|
+
const attempt = async (effort) => {
|
|
404
|
+
streamError = "";
|
|
384
405
|
let reply = "";
|
|
385
|
-
let error = "";
|
|
386
406
|
for await (const chunk of llm.stream({
|
|
387
407
|
provider,
|
|
388
408
|
model,
|
|
@@ -391,7 +411,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
391
411
|
// reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
|
|
392
412
|
// 按钮无放大风险。
|
|
393
413
|
maxTokens: 1024,
|
|
394
|
-
...(
|
|
414
|
+
...(effort && effort !== "none" ? { reasoningEffort: effort } : {}),
|
|
395
415
|
// role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
|
|
396
416
|
// 抽成顶层参数,system-only 对话会被官方 API 以
|
|
397
417
|
// 「messages: at least one message is required」整单拒绝——探测按钮 502。
|
|
@@ -406,12 +426,39 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
406
426
|
})) {
|
|
407
427
|
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
408
428
|
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
409
|
-
|
|
429
|
+
streamError = describeStreamFailure(chunk.reason);
|
|
410
430
|
break;
|
|
411
431
|
}
|
|
412
432
|
}
|
|
413
|
-
|
|
414
|
-
|
|
433
|
+
const dropped = droppedEffort ? { effortDropped: true } : {};
|
|
434
|
+
if (streamError) {
|
|
435
|
+
// 只在「确实带了档位」时把流级拒收交回给 withEffortFallback 的 undefined
|
|
436
|
+
// 分支——rc.1 的适配器不抛错,把 provider 的拒收折叠成终止 finish chunk,
|
|
437
|
+
// 不看这里那次重试就是死代码。不带档位时原样上报:探针不该因为一句文案
|
|
438
|
+
// 没匹配上正则就退化成 500。
|
|
439
|
+
if (effort && effort !== "none" && EFFORT_REJECT_RE.test(streamError)) return undefined;
|
|
440
|
+
// 失败行不带 effortDropped:该字段只有一个含义——「连通了,但不是用你配的
|
|
441
|
+
// 档位连通的」(见下方成功分支的注释),失败时它只是噪音。
|
|
442
|
+
return { ok: false, durationMs: elapsed(), error: streamError, modelId };
|
|
443
|
+
}
|
|
444
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100), ...dropped };
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const result = await withEffortFallback(
|
|
449
|
+
ctx,
|
|
450
|
+
reasoningEffort,
|
|
451
|
+
() => attempt(reasoningEffort),
|
|
452
|
+
() => { droppedEffort = true; return attempt(""); },
|
|
453
|
+
() => streamError
|
|
454
|
+
);
|
|
455
|
+
// result === undefined 只可能来自上面那次「判据一致」的主动交还(两边用同
|
|
456
|
+
// 一份 EFFORT_REJECT_RE,走到这里等于假设被推翻):退回已捕获的流失败原
|
|
457
|
+
// 因,绝不让探针 500。
|
|
458
|
+
if (result === undefined) {
|
|
459
|
+
return { ok: false, durationMs: elapsed(), error: streamError || "no stream result", modelId };
|
|
460
|
+
}
|
|
461
|
+
return result;
|
|
415
462
|
} catch (error) {
|
|
416
463
|
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
417
464
|
}
|
|
@@ -1596,8 +1643,77 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1596
1643
|
}
|
|
1597
1644
|
});
|
|
1598
1645
|
|
|
1646
|
+
// --- M.O.(Modus Operandi · 手法)工作流 ---
|
|
1647
|
+
// 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
|
|
1648
|
+
// src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
|
|
1649
|
+
// mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
|
|
1650
|
+
// 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
|
|
1651
|
+
// 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
|
|
1652
|
+
register({
|
|
1653
|
+
kind: "exact",
|
|
1654
|
+
path: "/api/dsh-mneme/pipeline",
|
|
1655
|
+
handler(req, res) {
|
|
1656
|
+
try {
|
|
1657
|
+
const url = new URL(req.url, "http://localhost");
|
|
1658
|
+
const id = url.searchParams.get("id") || null;
|
|
1659
|
+
const project = url.searchParams.get("project");
|
|
1660
|
+
// project 是**会话工作目录**,由面板侧解析后带过来——工作目录只有客户端拿得到
|
|
1661
|
+
// (宿主会话头里的 header.cwd),服务端没有「当前项目」这个概念。
|
|
1662
|
+
// 不是绝对路径就**拒掉**,不能忽略:忽略了会静默退成「全局」,而全局看起来
|
|
1663
|
+
// 一切正常——那正是这版要修掉的那类错。
|
|
1664
|
+
if (project !== null && !isAbsolute(project)) {
|
|
1665
|
+
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
sendJson(res, 200, readPipelineDoc({ projectRoot: project, id }));
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
// 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
|
|
1671
|
+
// 而不是回一句 internal 让人去翻日志。
|
|
1672
|
+
sendJson(res, 500, {
|
|
1673
|
+
error: "pipeline-unreadable",
|
|
1674
|
+
detail: String((error && error.message) || error)
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
// --- M.O. 候选层(绕行走出来、还没进仓库的那些路) ---
|
|
1681
|
+
// 和 /pipeline 读的是两处不同的东西,别混:
|
|
1682
|
+
// /pipeline 定稿,住**仓库**(<项目>/.mneme/pipelines/),可提交、可 review
|
|
1683
|
+
// /candidates 候选,住**本地**(<memoryDir>/candidates/<项目键>/),随时长、随时扔
|
|
1684
|
+
// 分开是刻意的:候选是草稿,一天可能长十条死九条,进仓库就是垃圾;
|
|
1685
|
+
// 而定稿只放人亲手批过的东西——候选是定稿的唯一入口。
|
|
1686
|
+
//
|
|
1687
|
+
// 这一刀只给**读**:提议与批准的动作还没有落点(那要等执行侧)。
|
|
1688
|
+
// 契约里显式写着 boundaries.autoPromote/autoApprove/injectIntoMemory 三个 false,
|
|
1689
|
+
// 免得以后有人以为忘了做。
|
|
1690
|
+
register({
|
|
1691
|
+
kind: "exact",
|
|
1692
|
+
path: "/api/dsh-mneme/candidates",
|
|
1693
|
+
handler(req, res) {
|
|
1694
|
+
try {
|
|
1695
|
+
const url = new URL(req.url, "http://localhost");
|
|
1696
|
+
const project = url.searchParams.get("project");
|
|
1697
|
+
if (project !== null && !isAbsolute(project)) {
|
|
1698
|
+
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
// memoryDir 从 config 拿(与 store 同源);取不到就退回默认,与 config.js 的默认值一致。
|
|
1702
|
+
const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
|
|
1703
|
+
? config.memoryDir
|
|
1704
|
+
: "~/.dsh/memory";
|
|
1705
|
+
sendJson(res, 200, readCandidateDoc({ memoryDir, projectRoot: project }));
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
sendJson(res, 500, {
|
|
1708
|
+
error: "candidates-unreadable",
|
|
1709
|
+
detail: String((error && error.message) || error)
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1599
1715
|
return {
|
|
1600
|
-
routes:
|
|
1716
|
+
routes: 36,
|
|
1601
1717
|
dispose: () => {
|
|
1602
1718
|
for (const dispose of disposers) dispose();
|
|
1603
1719
|
}
|