@modusensus/dsh-mneme 0.9.4 → 0.10.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 +57 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +25 -2
- package/docs/DAEMON.md +13 -2
- package/docs/DISTILL.md +150 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +21 -7
- package/docs/STORAGE.md +2 -1
- package/lib/api-standalone.js +294 -8
- package/lib/api.js +217 -3
- package/lib/client.js +919 -39
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +176 -30
- package/lib/dream/sleep.js +530 -94
- package/lib/dream.js +174 -21
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +38 -16
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pipeline.js +62 -1
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +251 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +97 -4
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +797 -28
- package/lib/summarize.js +209 -23
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +294 -8
- package/src/api.js +217 -3
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/document.js +15 -2
- package/src/dream/decisions.js +176 -30
- package/src/dream/sleep.js +530 -94
- package/src/dream.js +174 -21
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +38 -16
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pipeline.js +62 -1
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +251 -0
- package/src/serve.js +13 -0
- package/src/service.js +97 -4
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +797 -28
- package/src/summarize.js +209 -23
- package/src/tools.js +26 -6
- package/test/api.test.js +523 -6
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +354 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +207 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/inject-pools.test.js +2 -1
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +84 -1
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +367 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/release-docs.test.js +7 -1
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/security-doc.test.js +43 -4
- package/test/serve.test.js +62 -0
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +574 -5
- package/test/store.test.js +220 -0
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +187 -2
package/src/dream.js
CHANGED
|
@@ -10,6 +10,13 @@ import { scopeKeyOf } from "./scope.js";
|
|
|
10
10
|
import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
import { STR, langOf } from "./lang.js";
|
|
13
|
+
// 巩固读的是行池,而动行的写手不经汇流口:applyDecisions 归档败者直接走
|
|
14
|
+
// service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在
|
|
15
|
+
// 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。
|
|
16
|
+
import { isImportedRow } from "./memory-source.js";
|
|
17
|
+
// 断链是围栏的共同底子:三处围栏(巩固/睡眠清单、蒸馏转录、画像条目)共用这一份,
|
|
18
|
+
// 不许各写一条(#493 补充 §③)。
|
|
19
|
+
import { breakFenceMarkers } from "./fence.js";
|
|
13
20
|
export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute, streamText, runAuditedLlm };
|
|
14
21
|
|
|
15
22
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -57,7 +64,7 @@ function totalChars(memories) {
|
|
|
57
64
|
// 两处若各写一份过滤条件,重启播种读到的基线就会和运行期判定口径漂移(一个把
|
|
58
65
|
// document 算进去、一个不算),阈值随之失真,且这种漂移只会在重启后才显形。
|
|
59
66
|
function activeStoreSize(service) {
|
|
60
|
-
const memories = withoutPaused(service, service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
67
|
+
const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
61
68
|
return { count: memories.length, chars: totalChars(memories) };
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -243,7 +250,8 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
|
|
|
243
250
|
});
|
|
244
251
|
} else if (c.action === "differentiate") {
|
|
245
252
|
// Issue #126:互补型裁决——两条都保留,各记一条 receipt。数量没有变化,
|
|
246
|
-
// count_before/after 固定 1/1
|
|
253
|
+
// count_before/after 固定 1/1。Issue #533 起正文不再追加差异注记、一个字节
|
|
254
|
+
// 都不动,这里记的是「这一对被裁定为互补」,配对关系另见 differentiated_pairs。
|
|
247
255
|
for (const id of c.ids ?? []) {
|
|
248
256
|
receipts.push({
|
|
249
257
|
...base,
|
|
@@ -309,6 +317,94 @@ function describeStreamFailure(reason) {
|
|
|
309
317
|
* operation_type, model_id, related_memory_ids); `body(reportUsage)` performs
|
|
310
318
|
* the actual stream consumption and is handed a usage reporter for the chunks.
|
|
311
319
|
*/
|
|
320
|
+
/**
|
|
321
|
+
* 把喂给模型的清单包进围栏(#493 的另一半:dream / sleep 那 5 处 user 消息以前是裸放)。
|
|
322
|
+
* 三个细节各自有原因,别当装饰删:
|
|
323
|
+
* - 角色声明落在**开头**:判断「这段文本是什么」的是它所在的那条 user 消息,只写在 system
|
|
324
|
+
* 里实测救不回来(#493 的 A/C 臂:只加 system 约束仍 0/2 出 JSON);
|
|
325
|
+
* - 正文里的 `<<<` / `>>>` 用零宽空格断链——**只中和标记,不删内容**。不这么做时,一条写着
|
|
326
|
+
* 「<<<记忆清单结束>>>」的记忆就能提前闭合围栏,把后面的文字升格成指令;而记忆正文本来就
|
|
327
|
+
* 是模型的可影响输入,这是最省事的注入入口;
|
|
328
|
+
* - 纯函数、不带 nonce:同一批记忆在下一次巩固阈值到来时会被原样再喂一次,重试收到的 prompt
|
|
329
|
+
* 必须与首次逐字节相同,否则「同输入同错」这条诊断依据就没了(#493 的重试 20 次现场就靠它)。
|
|
330
|
+
* @param {"json"|"text"} [shape] 该次调用的**输出契约**:巩固/叙述/冲突/模式要 JSON 数组,
|
|
331
|
+
* 总览摘要要散文。角色声明里那句「只输出 JSON 数组」必须跟着契约走——对散文契约说它,
|
|
332
|
+
* 模型会回 `[]`,而 `summaryText.trim()` 判非空通过,`[]` 就被当成摘要存进库(评审实测)。
|
|
333
|
+
*/
|
|
334
|
+
export function fenceListBody(language, text, shape = "json") {
|
|
335
|
+
const open = STR.memoryFenceOpen[language] ?? STR.memoryFenceOpen.en;
|
|
336
|
+
const close = STR.memoryFenceClose[language] ?? STR.memoryFenceClose.en;
|
|
337
|
+
const table = shape === "text" ? STR.dreamTextRoleReminder : STR.dreamRoleReminder;
|
|
338
|
+
const reminder = table[language] ?? table.en;
|
|
339
|
+
const body = breakFenceMarkers(text);
|
|
340
|
+
return `${reminder}\n\n${open}\n${body}\n${close}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 解析失败行的审计读数(#493 同款,报告者提的第 2 条)。dream 的 warn 日志本来就带
|
|
345
|
+
* raw length + 前 300 字,缺的是**可查询**的那一面:日志要翻、按不了、也进不了面板的
|
|
346
|
+
* 「LLM 消耗」。`raw_has_bracket` 是区分「模型压根没按格式写」与「格式对但内容救不回来」
|
|
347
|
+
* 的唯一低成本判据。
|
|
348
|
+
*/
|
|
349
|
+
export function parseFailureMeta(text) {
|
|
350
|
+
const raw = typeof text === "string" ? text : "";
|
|
351
|
+
return {
|
|
352
|
+
parse_failure: true,
|
|
353
|
+
raw_len: raw.length,
|
|
354
|
+
raw_head: raw.slice(0, 300).replace(/\s+/g, " ").trim(),
|
|
355
|
+
raw_has_bracket: raw.includes("[")
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* #534 建议 3(分账读数):一轮巩固在「校验层」判了什么。
|
|
361
|
+
*
|
|
362
|
+
* 调用层的审计行在 LLM 返回时就写死了 status=success,而这一轮可能整单被校验
|
|
363
|
+
* 拒绝、也可能只跳过几条(degraded)。把这三个数字补进那一行的 metadata,
|
|
364
|
+
* 「调用成功率」与「整轮有效率」就有了同一份可对齐的分母,不必再拿两张表硬拼。
|
|
365
|
+
*
|
|
366
|
+
* 只记计数与动作名,不记原文——原文已经在 raw_output 列(失败轮)与 skipped
|
|
367
|
+
* (逐条 error)里了,这里再抄一遍等于同一份数据存三处。
|
|
368
|
+
*/
|
|
369
|
+
export function decisionValidationMeta({ verdict, errors, skipped, survivorCount } = {}) {
|
|
370
|
+
const skips = Array.isArray(skipped) ? skipped : [];
|
|
371
|
+
const actions = [...new Set(skips.map((s) => s?.action).filter((a) => typeof a === "string" && a.length > 0))];
|
|
372
|
+
// verdict 由调用方给:它知道这一轮是「没解析出数组」「整单拒绝」还是「合法子集」,
|
|
373
|
+
// 判据复用校验器的返回值而不是在这里再推一遍布尔组合。
|
|
374
|
+
return {
|
|
375
|
+
decision_validation: {
|
|
376
|
+
verdict: typeof verdict === "string" ? verdict : "unknown",
|
|
377
|
+
error_count: Array.isArray(errors) ? errors.length : 0,
|
|
378
|
+
skipped_count: skips.length,
|
|
379
|
+
survivor_count: Number.isFinite(survivorCount) ? survivorCount : 0,
|
|
380
|
+
invalid_actions: actions
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* #534 建议 2:失败轮把模型原文留在行上。此前失败只有一条 warn,日志一滚现场
|
|
387
|
+
* 就没了——作者实测 19 轮里 17 轮整轮无效,事后连模型是怎么坏的都复不了盘。
|
|
388
|
+
*
|
|
389
|
+
* 截断口径沿用 #502/#504(summarize.js 的 summarizeParseFailureAudit):头部
|
|
390
|
+
* 300 字,仅当原文 >500 字时补末 200 字。只留头尾是刻意的——模型跑飞时中段是
|
|
391
|
+
* 重复噪声,头尾才分别在「它怎么起手」「它怎么收尾」上给线索;整段原文既不必要
|
|
392
|
+
* 也不该进库(一轮可有数十 KB)。
|
|
393
|
+
*
|
|
394
|
+
* 空文本返回 undefined,调用方据此整键不写(与 saveDreamRun 的 undefined→NULL
|
|
395
|
+
* 约定一致,不落一个空壳对象冒充「有原文」)。
|
|
396
|
+
*/
|
|
397
|
+
export function rawOutputPayload(text) {
|
|
398
|
+
if (typeof text !== "string" || text.length === 0) return undefined;
|
|
399
|
+
const tail = text.length > 500 ? text.slice(-200) : "";
|
|
400
|
+
return {
|
|
401
|
+
raw_len: text.length,
|
|
402
|
+
raw_head: text.slice(0, 300),
|
|
403
|
+
...(tail ? { raw_tail: tail } : {}),
|
|
404
|
+
raw_has_bracket: text.includes("[")
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
312
408
|
async function runAuditedLlm(ctx, service, config, spec, body) {
|
|
313
409
|
const audit = config?.llmAudit;
|
|
314
410
|
if (audit?.enabled === false || typeof service?.saveLlmAudit !== "function") return body(() => {});
|
|
@@ -351,8 +447,19 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
|
|
|
351
447
|
errorMessage = String(error?.message ?? error);
|
|
352
448
|
throw error;
|
|
353
449
|
} finally {
|
|
450
|
+
// spec.metadataFrom(result):调用方按**这一轮的原始输出**决定要不要带读数(解析成功就
|
|
451
|
+
// 返回 null → 整键不写)。判据函数必须是纯的:它读的是 result,不读控制流里的缓存,
|
|
452
|
+
// 否则「审计说了什么」会随调用方走到哪一行而变(sleep.js 同一条硬约定)。
|
|
453
|
+
let metadata;
|
|
454
|
+
if (result !== undefined && typeof spec.metadataFrom === "function") {
|
|
455
|
+
try {
|
|
456
|
+
metadata = spec.metadataFrom(result) ?? undefined;
|
|
457
|
+
} catch {
|
|
458
|
+
metadata = undefined; // 读数失败不许把审计行整个弄没
|
|
459
|
+
}
|
|
460
|
+
}
|
|
354
461
|
try {
|
|
355
|
-
service.saveLlmAudit({
|
|
462
|
+
const row = service.saveLlmAudit({
|
|
356
463
|
timestamp,
|
|
357
464
|
trigger_source: spec.triggerSource,
|
|
358
465
|
operation_type: spec.operationType,
|
|
@@ -364,8 +471,13 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
|
|
|
364
471
|
duration_ms: Date.now() - startedAt,
|
|
365
472
|
status,
|
|
366
473
|
error_message: errorMessage,
|
|
367
|
-
related_memory_ids: spec.relatedMemoryIds ?? []
|
|
474
|
+
related_memory_ids: spec.relatedMemoryIds ?? [],
|
|
475
|
+
...(metadata ? { metadata } : {})
|
|
368
476
|
});
|
|
477
|
+
// #534 建议 3:把刚落库这一行的 id 交回调用方。校验判决发生在调用**之后**,
|
|
478
|
+
// 只有拿到 id 才补得回去(见 store 的 annotateLlmAudit)。这里只做交接,
|
|
479
|
+
// 不改写任何调用层事实——status/token/耗时仍然是调用那一刻的真相。
|
|
480
|
+
if (spec.auditRef && typeof spec.auditRef === "object") spec.auditRef.id = row?.id;
|
|
369
481
|
} catch (auditError) {
|
|
370
482
|
ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
|
|
371
483
|
}
|
|
@@ -486,7 +598,7 @@ async function reEmbedMemory(semantic, memory, logger) {
|
|
|
486
598
|
* 任何失败降级为 0 条,绝不反噬 dream 主流程。
|
|
487
599
|
*/
|
|
488
600
|
async function generateNarratives({ ctx, service, config, route, language, effort, semantic, logger }) {
|
|
489
|
-
const inputs = withoutPaused(service, service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
|
|
601
|
+
const inputs = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
|
|
490
602
|
const clusters = clusterByTag(inputs, { minCluster: config.dreamNarrativeMinCluster ?? 3 });
|
|
491
603
|
if (!clusters.length) return 0;
|
|
492
604
|
|
|
@@ -511,7 +623,7 @@ async function generateNarratives({ ctx, service, config, route, language, effor
|
|
|
511
623
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
512
624
|
messages: [
|
|
513
625
|
{ role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
514
|
-
{ role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
626
|
+
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listing) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
515
627
|
]
|
|
516
628
|
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
517
629
|
};
|
|
@@ -815,13 +927,9 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
|
|
|
815
927
|
// loser 同等处置)。winner 正文未变化,向量保持有效。
|
|
816
928
|
vectorIndex.deleteEmbedding(d.loser);
|
|
817
929
|
} else if (d.action === "differentiate") {
|
|
818
|
-
// Issue #
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
for (const id of d.ids ?? []) {
|
|
822
|
-
const mem = service.getById(id);
|
|
823
|
-
if (mem) rebuild.set(id, [mem.title, mem.content].filter(Boolean).join("\n"));
|
|
824
|
-
}
|
|
930
|
+
// Issue #533:differentiate 现在一个字节都不改正文(只落裁决账本),两侧向量
|
|
931
|
+
// 因此保持有效,不需要重嵌。旧版在这里为「注记进 embedding 防再判重复」兜底
|
|
932
|
+
// ——那个假设已被复现证伪,重嵌反而是替它续命。
|
|
825
933
|
} else if (d.action === "update") {
|
|
826
934
|
const id = d.ids[0];
|
|
827
935
|
const mem = service.getById(id);
|
|
@@ -1014,7 +1122,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1014
1122
|
async function runDream(ctx, service, config) {
|
|
1015
1123
|
const language = langOf(config);
|
|
1016
1124
|
const logger = ctx.logger;
|
|
1017
|
-
let memories = withoutPaused(service, service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
1125
|
+
let memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
1018
1126
|
if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
|
|
1019
1127
|
// Issue #89:开跑时刻既喂给审计行(created_at),也是调度器闸门的时间基准。
|
|
1020
1128
|
const dreamStartedAt = Date.now();
|
|
@@ -1132,6 +1240,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1132
1240
|
// 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
|
|
1133
1241
|
// 拒绝哨兵,语义与「部分应用」不同,也不复用)。
|
|
1134
1242
|
skipped: result.skipped,
|
|
1243
|
+
// #534 建议 2:拿到文本却解析/校验失败的轮,把模型原文的头尾留在行上。
|
|
1244
|
+
// 单独一列而不是内联进 decisions——decisions 的语义是「可重放决策数组」
|
|
1245
|
+
// (applyDecisions 直接吃它),塞非决策对象会污染重放者与所有按决策数组
|
|
1246
|
+
// 消费的下游;失败轮哨兵 _validationFailed 也只表达「整单拒绝」这一件事。
|
|
1247
|
+
// 流错误(result===undefined)没有文本可留,落 NULL。
|
|
1248
|
+
raw_output: result.raw_output,
|
|
1135
1249
|
// Issue #89(基线半边):成功的 run 才带这两个值(见上方 baselineSnapshot
|
|
1136
1250
|
// 的注释)。两列要么同时有值、要么同时为 NULL,读侧以「同时有值」认账。
|
|
1137
1251
|
store_count: baselineSnapshot?.count,
|
|
@@ -1195,18 +1309,25 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1195
1309
|
const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
|
|
1196
1310
|
let decisions = null;
|
|
1197
1311
|
let streamFailure = "";
|
|
1312
|
+
// #534 建议 3:这一轮巩固的审计行 id(runAuditedLlm 落库后回填)。校验判决
|
|
1313
|
+
// 出来时用它把 result 补回调用层那一行,两套账才对得上。每次重试重置,只认
|
|
1314
|
+
// 最终生效的那一次调用。
|
|
1315
|
+
let auditRef = {};
|
|
1198
1316
|
const runConsolidation = (withEffort, tokens) => {
|
|
1199
1317
|
streamFailure = "";
|
|
1318
|
+
auditRef = {};
|
|
1200
1319
|
return runAuditedLlm(ctx, service, config, {
|
|
1201
1320
|
triggerSource: "autoDream",
|
|
1202
1321
|
operationType: "dream_consolidate",
|
|
1203
1322
|
modelId: `${route.provider}:${route.model}`,
|
|
1204
1323
|
relatedMemoryIds: [...snapshot.keys()],
|
|
1205
1324
|
streamError: () => streamFailure,
|
|
1325
|
+
auditRef,
|
|
1206
1326
|
auditError: (text) => {
|
|
1207
1327
|
decisions = extractJsonArray(text);
|
|
1208
1328
|
return Array.isArray(decisions) ? null : "no json array in llm output";
|
|
1209
|
-
}
|
|
1329
|
+
},
|
|
1330
|
+
metadataFrom: (text) => (Array.isArray(decisions) ? null : parseFailureMeta(text))
|
|
1210
1331
|
}, (reportUsage) => streamText(ctx, {
|
|
1211
1332
|
provider: route.provider,
|
|
1212
1333
|
model: route.model,
|
|
@@ -1215,7 +1336,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1215
1336
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
1216
1337
|
messages: [
|
|
1217
1338
|
{ role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
1218
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1339
|
+
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1219
1340
|
]
|
|
1220
1341
|
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
1221
1342
|
};
|
|
@@ -1232,10 +1353,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1232
1353
|
logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
|
|
1233
1354
|
return finish({ ok: false, error: "llm failed", summary: false });
|
|
1234
1355
|
}
|
|
1356
|
+
// #534 建议 3 的接线点:审计行已由 runAuditedLlm 落库(status=error 也只说明
|
|
1357
|
+
// 「调用层这轮不算成功」),把校验层的判决补回去,两张表才说同一件事。
|
|
1358
|
+
const annotateAudit = (patch) => {
|
|
1359
|
+
if (!auditRef.id || typeof service?.annotateLlmAudit !== "function") return;
|
|
1360
|
+
try {
|
|
1361
|
+
service.annotateLlmAudit(auditRef.id, patch);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
logger?.warn?.(`dsh-mneme dream: failed to annotate llm audit row: ${String(error)}`);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1235
1366
|
if (!Array.isArray(decisions)) {
|
|
1236
1367
|
const head = (decisionText ?? "").slice(0, 300).replace(/\s+/g, " ").trim();
|
|
1237
1368
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
|
|
1238
|
-
|
|
1369
|
+
annotateAudit(decisionValidationMeta({ verdict: "unparsed", errors: [], skipped: [], survivorCount: 0 }));
|
|
1370
|
+
return finish({ ok: false, error: "no json array in llm output", summary: false, raw_output: rawOutputPayload(decisionText) });
|
|
1239
1371
|
}
|
|
1240
1372
|
const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
|
|
1241
1373
|
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
@@ -1271,11 +1403,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1271
1403
|
if (skipped.length > 0) {
|
|
1272
1404
|
logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped: ${skipped.map((s) => `decision[${s.index}]: ${s.error}`).join("; ")}`);
|
|
1273
1405
|
}
|
|
1406
|
+
annotateAudit(decisionValidationMeta({ verdict: "rejected", errors, skipped, survivorCount: 0 }));
|
|
1274
1407
|
return finish({
|
|
1275
1408
|
ok: false,
|
|
1276
1409
|
error: `invalid decisions: ${errors.length} errors`,
|
|
1277
1410
|
summary: false,
|
|
1278
|
-
decisions: [{ _validationFailed: true, errors, skipped }]
|
|
1411
|
+
decisions: [{ _validationFailed: true, errors, skipped }],
|
|
1412
|
+
// #534 建议 2:整轮被拒时最该留的就是模型原文——否则事后只能看到
|
|
1413
|
+
// 「N errors」这个结论,看不到它到底吐了什么。
|
|
1414
|
+
raw_output: rawOutputPayload(decisionText)
|
|
1279
1415
|
});
|
|
1280
1416
|
}
|
|
1281
1417
|
const skippedInvalid = skipped.length > 0;
|
|
@@ -1288,6 +1424,22 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1288
1424
|
if (coverageDegraded) {
|
|
1289
1425
|
logger?.warn?.(`dsh-mneme dream: ${coverageShortfall} (run degrades: valid subset applied, unclaimed ids kept implicitly)`);
|
|
1290
1426
|
}
|
|
1427
|
+
// 校验通过(可能带若干跳过、也可能只是覆盖率不足 → degraded)。判决补回审计行:
|
|
1428
|
+
// 这一步之前没有任何地方能回答「调用成功的那一轮,决策层到底认不认」。
|
|
1429
|
+
//
|
|
1430
|
+
// verdict 必须与下方状态判定同源(CodeRabbit on #535):coverageShortfall 同样是
|
|
1431
|
+
// 校验层的降级,若这里只认 skippedInvalid,覆盖率不足的轮会被 dream_runs 记成
|
|
1432
|
+
// degraded、审计 metadata 却写 ok——两套账在最该对齐的一格上又对不上,正是本
|
|
1433
|
+
// PR 要消灭的病。
|
|
1434
|
+
annotateAudit(decisionValidationMeta({
|
|
1435
|
+
verdict: skippedInvalid || coverageDegraded ? "degraded" : "ok",
|
|
1436
|
+
errors,
|
|
1437
|
+
skipped,
|
|
1438
|
+
// 校验器已就地 splice:这个长度就是下游 apply/audit 真正会消费的条目数
|
|
1439
|
+
// (含隐式补齐的 keep)。不能再减 skipped.length——被跳过的那些早被剪掉了,
|
|
1440
|
+
// 再减一次会把「跳 1 条、存活 1 条」算成 0(本分支自测当场抓到过)。
|
|
1441
|
+
survivorCount: decisions.length
|
|
1442
|
+
}));
|
|
1291
1443
|
|
|
1292
1444
|
// Capture pre-update snapshots so the audit records what each update changed.
|
|
1293
1445
|
const updateSnapshots = {};
|
|
@@ -1380,7 +1532,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1380
1532
|
// mint 来源:主链路的 create 落 source=dream。来源从管道给而非从决策对象取——
|
|
1381
1533
|
// 见 applyDecisions 的 @param config。
|
|
1382
1534
|
const { applied, conflicts, failures, committed } = applyDecisions(
|
|
1383
|
-
|
|
1535
|
+
// runId 让 differentiate 的账本行能追到是哪一轮 dream 判的(issue #533)。
|
|
1536
|
+
applyList, service, logger, snapshot, { ...config, mintSource: "dream", runId }
|
|
1384
1537
|
);
|
|
1385
1538
|
// Per-record receipt chain: one row per actually-committed merge/conflict/
|
|
1386
1539
|
// update verdict, stamped with the decision-basis digest + idempotency
|
|
@@ -1458,7 +1611,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1458
1611
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
1459
1612
|
messages: [
|
|
1460
1613
|
{ role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
1461
|
-
{ role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1614
|
+
{ role: "user", content: [{ type: "text", text: fenceListBody(language, summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n"), "text") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1462
1615
|
]
|
|
1463
1616
|
}, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
|
|
1464
1617
|
};
|
|
@@ -1476,7 +1629,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1476
1629
|
// (saveWithDedupe 不带 agent_scope/workspace_scope),所以被按下作用域的内容
|
|
1477
1630
|
// 一旦进了总览,注入侧按作用域过滤就再也拦不住它:内容已经洗成了全局行。
|
|
1478
1631
|
// 别的入口漏了只是「那一条漏过去」,这里漏了是「洗一遍之后一直漏」。
|
|
1479
|
-
const summaryAll = withoutPaused(service, service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
1632
|
+
const summaryAll = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && m.type !== "summary" && m.type !== "document"));
|
|
1480
1633
|
const summaryMaxInputs = Number.isInteger(config.dreamSummaryMaxInputs) ? config.dreamSummaryMaxInputs : 0;
|
|
1481
1634
|
const summaryInputs = summaryMaxInputs > 0 && summaryAll.length > summaryMaxInputs
|
|
1482
1635
|
? [...summaryAll]
|
package/src/fence.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// dsh-mneme/src/fence.js
|
|
2
|
+
// 围栏正文的「断链」——把内容里自带的 `<<<` / `>>>` 拆散,使它无法提前闭合我们围出来的围栏。
|
|
3
|
+
//
|
|
4
|
+
// 为什么单独成模块(#493 的补充,报告者 §③):断链原先只长在 dream.js 的 fenceListBody
|
|
5
|
+
// 里,于是同一件事在三处各有一条命:dream/sleep 的清单(有断链)、蒸馏转录(没有)、
|
|
6
|
+
// 画像提取的条目(也没有)。两处一旦分叉,下次出问题就得分别查一遍——#494 与 #501/PR
|
|
7
|
+
// #503 的重复劳动就是这么来的。判定逻辑只能有一份实现,这条纪律在本仓已经付过学费
|
|
8
|
+
// (scopeStateGate 注释里「过滤点漂移」那条)。
|
|
9
|
+
//
|
|
10
|
+
// 三个刻意的性质:
|
|
11
|
+
// - **只中和标记,不删内容**:被断链的文本仍逐字可读(零宽空格不占位),只是不再是
|
|
12
|
+
// 一个能匹配围栏串的连续字符序列。吞掉正文会让「这段记忆说了什么」变成不可复原的。
|
|
13
|
+
// - **纯函数、不带 nonce**:重试收到同输入时提示词必须逐字节相同,否则「同输入同错」
|
|
14
|
+
// 这条诊断依据就没了(#493 的重试 20 次现场全靠它)。同一份文本调两次结果相同。
|
|
15
|
+
// - **不认围栏名字**:三对围栏(转录 / 记忆清单 / 画像条目)都以 `<<<` 开头、`>>>` 收尾,
|
|
16
|
+
// 中和这两个前缀/后缀就够了;认名字反而会在新增围栏时漏一处。
|
|
17
|
+
/**
|
|
18
|
+
* 断链:`<<<` / `>>>` 中间插一个零宽空格。
|
|
19
|
+
* @param {string} text 任意正文
|
|
20
|
+
* @returns {string} 断链后的正文(未触及的文本逐字节不变)
|
|
21
|
+
*/
|
|
22
|
+
export function breakFenceMarkers(text) {
|
|
23
|
+
return String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`);
|
|
24
|
+
}
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createInjector } from "./inject.js";
|
|
|
14
14
|
import { createPreInjectGate } from "./pre-inject-gate.js";
|
|
15
15
|
import { createContinuityRescue } from "./continuity.js";
|
|
16
16
|
import { createSummarizer } from "./summarize.js";
|
|
17
|
+
import { createSessionGate } from "./session-gate.js";
|
|
17
18
|
import { createDreamScheduler, describeStreamFailure } from "./dream.js";
|
|
18
19
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
19
20
|
import { createApi } from "./api.js";
|
|
@@ -231,6 +232,7 @@ export const apply = (ctx, config) => {
|
|
|
231
232
|
for (const [objKey, sub] of Object.entries(nestedFlags)) {
|
|
232
233
|
cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
|
|
233
234
|
}
|
|
235
|
+
const sessionGate = createSessionGate(cfg);
|
|
234
236
|
|
|
235
237
|
// 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
|
|
236
238
|
// 90 天)。保留期现在是显式配置键 reflectionFailureRetentionDays;等它进面板
|
|
@@ -411,7 +413,7 @@ export const apply = (ctx, config) => {
|
|
|
411
413
|
//
|
|
412
414
|
// 门控:默认关(actionRunEnabled),并且它挂热路径,轻量档也关。
|
|
413
415
|
if (cfg.actionRunEnabled === true) {
|
|
414
|
-
recorder = createExecutionRecorder({ ctx, logger: ctx.logger });
|
|
416
|
+
recorder = createExecutionRecorder({ ctx, logger: ctx.logger, sessionGate });
|
|
415
417
|
const disposeMoRun = registerMoRunCommand({
|
|
416
418
|
ctx,
|
|
417
419
|
recorder,
|
|
@@ -535,6 +537,13 @@ export const apply = (ctx, config) => {
|
|
|
535
537
|
|
|
536
538
|
const disposers = [];
|
|
537
539
|
|
|
540
|
+
try {
|
|
541
|
+
const disposeSessionGate = ctx.on?.("session/event", (session) => sessionGate.observe(session));
|
|
542
|
+
if (typeof disposeSessionGate === "function") disposers.push(disposeSessionGate);
|
|
543
|
+
} catch {
|
|
544
|
+
// 宿主没有该事件钩子时,注入、工具与蒸馏消费点仍直接检查当前 Session。
|
|
545
|
+
}
|
|
546
|
+
|
|
538
547
|
// Issue #380:注入前判定闸门。判定 LLM 走 entity adapter 同款装配(ctx.llm +
|
|
539
548
|
// agentDefaultModel 回退),gate 自持 enabled/usable 判定(未启用/llm 缺席/
|
|
540
549
|
// llmAudit 关闭时内部恒 off,注入行为与现状逐字节一致)。
|
|
@@ -552,21 +561,21 @@ export const apply = (ctx, config) => {
|
|
|
552
561
|
disposers.push(() => semantic.dispose()); // #118 重试计时器 + boot 回填计时器(搬入 semantic.js 后由它自持)
|
|
553
562
|
|
|
554
563
|
ctx.inject(["systemPrompt"], (promptCtx) => {
|
|
555
|
-
if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate));
|
|
564
|
+
if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate));
|
|
556
565
|
});
|
|
557
566
|
|
|
558
567
|
// #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
|
|
559
568
|
// 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
|
|
560
569
|
// 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
|
|
561
570
|
if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
|
|
562
|
-
disposers.push(createContinuityRescue(ctx, store));
|
|
571
|
+
disposers.push(createContinuityRescue(ctx, store, sessionGate));
|
|
563
572
|
}
|
|
564
573
|
|
|
565
574
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
566
|
-
disposers.push(createTools(toolsCtx, service, cfg, embedder));
|
|
575
|
+
disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate));
|
|
567
576
|
});
|
|
568
577
|
|
|
569
|
-
const summarizer = createSummarizer(ctx, service, cfg);
|
|
578
|
+
const summarizer = createSummarizer(ctx, service, cfg, {}, sessionGate);
|
|
570
579
|
disposers.push(summarizer.dispose);
|
|
571
580
|
|
|
572
581
|
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
@@ -607,7 +616,7 @@ export const apply = (ctx, config) => {
|
|
|
607
616
|
// 为什么挂 session/event 却又要进轻量档黑名单:它读的是 zstd 会话日志(同步解压),
|
|
608
617
|
// 是本插件里最重的一条后台路径;且它只产「提议」,关掉不影响记忆本身的正确性。
|
|
609
618
|
if (cfg.actionNudgeEnabled === true) {
|
|
610
|
-
disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger }));
|
|
619
|
+
disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger, sessionGate }));
|
|
611
620
|
}
|
|
612
621
|
|
|
613
622
|
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
package/src/inject.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createScopeResolver } from "./scope.js";
|
|
1
|
+
import { createScopeResolver, sessionOf } from "./scope.js";
|
|
2
2
|
import { createHotMemory, roundsFromEvents } from "./hot-memory.js";
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
4
|
import { escapePromptVars as escapePromptVarsWithConfig, renderUserSettingsBlock } from "./user-settings.js";
|
|
@@ -93,7 +93,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
export function createInjector(ctx, service, settings, config, gate = null) {
|
|
96
|
+
export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null) {
|
|
97
97
|
const language = langOf(config);
|
|
98
98
|
const baseMaxItems = config.maxInjectedItems ?? 5;
|
|
99
99
|
const threshold = config.importanceThreshold ?? 3;
|
|
@@ -343,7 +343,11 @@ export function createInjector(ctx, service, settings, config, gate = null) {
|
|
|
343
343
|
// 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
|
|
344
344
|
// 不在各自的地方重写「父关则子不生效」。
|
|
345
345
|
const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
|
|
346
|
-
? ctx.systemPrompt.section({
|
|
346
|
+
? ctx.systemPrompt.section({
|
|
347
|
+
name: "memory-guide",
|
|
348
|
+
order: 150,
|
|
349
|
+
text: (promptCtx) => (sessionGate?.isOff(sessionOf(promptCtx)) ? "" : MEMORY_GUIDE_SECTION)
|
|
350
|
+
})
|
|
347
351
|
: null;
|
|
348
352
|
|
|
349
353
|
const disposers = [
|
|
@@ -352,6 +356,7 @@ export function createInjector(ctx, service, settings, config, gate = null) {
|
|
|
352
356
|
name: "memory",
|
|
353
357
|
order: 90,
|
|
354
358
|
text: (ctx) => {
|
|
359
|
+
if (sessionGate?.isOff(sessionOf(ctx))) return "";
|
|
355
360
|
// Bug4: pass the latest user query so injection prefers semantically
|
|
356
361
|
// relevant memories; lastUserQuery is best-effort (empty → legacy).
|
|
357
362
|
// The query vector is prefetched asynchronously (cached) because the
|
|
@@ -434,7 +439,7 @@ export function createInjector(ctx, service, settings, config, gate = null) {
|
|
|
434
439
|
ctx.systemPrompt.context({
|
|
435
440
|
name: "user-settings",
|
|
436
441
|
order: 85,
|
|
437
|
-
text: (ctx) => renderUserSettings(ctx)
|
|
442
|
+
text: (ctx) => (sessionGate?.isOff(sessionOf(ctx)) ? "" : renderUserSettings(ctx))
|
|
438
443
|
})
|
|
439
444
|
];
|
|
440
445
|
|