@modusensus/dsh-mneme 0.8.9 → 0.8.10
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 +4 -3
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +6 -1
- package/lib/client.js +18 -1
- package/lib/config.js +26 -0
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/tools.js +17 -8
- package/lib/write-admission.js +165 -22
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +57 -7
- package/src/api-standalone.js +11 -1
- package/src/api.js +6 -1
- package/src/config.js +26 -0
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +17 -2
- package/test/epistemic.test.js +27 -0
- package/test/helpers/write-admission-samples.js +308 -0
- package/test/inject.test.js +137 -0
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/slow-stack/mneme)
|
|
9
9
|
[](https://github.com/slow-stack/mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
@@ -432,6 +432,7 @@ dsh web
|
|
|
432
432
|
| `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(`autoDream` / `autoSummarize` / `sleep` / `entityExtract`,对应 `operation_type` 为 `dream_consolidate` / `dream_summarize` / `dream_narrative`、`summarize_compress`、`sleep_conflict` / `sleep_pattern`、`entity_extract`)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
|
|
433
433
|
| `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见)。也走 feature_flags 白名单(面板可启停) |
|
|
434
434
|
| `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 |
|
|
435
|
+
| `writeAdmission` | `{enabled:false, enforce:false}` | 写入准入(issue #254,默认关):把「这条该不该进库」前移到 LLM 之前。第 1 级是零 LLM 的确定性判据——空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入,本批只定义接口)。`enabled` 跑判据并落审计行(`llm_audit_logs` 的 `metadata.deny`),`enforce` 才真的拒(`store.save` 之前返回,`memory_save` 返回 `action:"denied"` + `reason`);只开 `enabled` = 仅告警、写入不拦。去重键命中不进第 1 级(归 write-update 放行),G1/G2 阈值只计量。也走 feature_flags 白名单 |
|
|
435
436
|
| `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) |
|
|
436
437
|
| `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) |
|
|
437
438
|
| `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) |
|
|
@@ -583,7 +584,7 @@ src/
|
|
|
583
584
|
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
|
|
584
585
|
└── index.js # 插件接线
|
|
585
586
|
lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
|
|
586
|
-
test/ #
|
|
587
|
+
test/ # 1431 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
|
|
587
588
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
|
|
588
589
|
```
|
|
589
590
|
|
|
@@ -592,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
592
593
|
```bash
|
|
593
594
|
cd dsh-mneme
|
|
594
595
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
595
|
-
npm test # 运行
|
|
596
|
+
npm test # 运行 1431 个测试
|
|
596
597
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
597
598
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
598
599
|
```
|
package/lib/api-standalone.js
CHANGED
|
@@ -386,7 +386,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
try {
|
|
389
|
-
const
|
|
389
|
+
const result = service.saveWithDedupe({
|
|
390
390
|
type: body.type,
|
|
391
391
|
title: body.title,
|
|
392
392
|
content: body.content,
|
|
@@ -398,6 +398,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
398
398
|
...(body.agent_scope !== undefined ? { agent_scope: body.agent_scope, agent_scope_source: "explicit" } : {}),
|
|
399
399
|
...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {})
|
|
400
400
|
});
|
|
401
|
+
const { action } = result;
|
|
402
|
+
// #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey
|
|
403
|
+
// 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到
|
|
404
|
+
// `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而
|
|
405
|
+
// 这两件事对调用方的处置完全不同(改内容 vs 重试)。
|
|
406
|
+
if (action === "denied") {
|
|
407
|
+
sendJson(res, 422, { error: "write-rejected", reason: result.reason });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const { memory } = result;
|
|
401
411
|
// action 随行透出(created/merged):memory_save 工具语义对齐所需,
|
|
402
412
|
// 附加键对既有消费方(CLI add 等)向后兼容。
|
|
403
413
|
sendJson(res, action === "created" ? 201 : 200, { ...service.toApiList([memory])[0], action });
|
package/lib/api.js
CHANGED
|
@@ -135,9 +135,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
135
135
|
];
|
|
136
136
|
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
137
137
|
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
138
|
+
// 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
|
|
139
|
+
// effective 会静默少两个键(configFlagValue 取不到 undefined 就不写进去),
|
|
140
|
+
// 面板上看不出来、PUT 也存不进去——test/api.test.js 的计数锁就是钉这件事的。
|
|
138
141
|
const NESTED_FLAG_PATHS = {
|
|
139
142
|
"memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
|
|
140
|
-
"llmAudit.enabled": ["llmAudit", "enabled"]
|
|
143
|
+
"llmAudit.enabled": ["llmAudit", "enabled"],
|
|
144
|
+
"writeAdmission.enabled": ["writeAdmission", "enabled"],
|
|
145
|
+
"writeAdmission.enforce": ["writeAdmission", "enforce"]
|
|
141
146
|
};
|
|
142
147
|
function configFlagValue(key) {
|
|
143
148
|
const path = NESTED_FLAG_PATHS[key];
|
package/lib/client.js
CHANGED
|
@@ -415,6 +415,8 @@ window.__ModuleLoader__.load({
|
|
|
415
415
|
"memory.features.autoSummarize.hint": "对话结束自动提炼记忆条目",
|
|
416
416
|
"memory.features.hotMemoryEnabled": "热记忆",
|
|
417
417
|
"memory.features.hotMemoryEnabled.hint": "最近几轮对话原文随注入携带,不写入长期记忆",
|
|
418
|
+
"memory.features.injectTimePrefix": "对话开始注入当前时间",
|
|
419
|
+
"memory.features.injectTimePrefix.hint": "每次新会话的首轮注入一次日期与星期(默认关)",
|
|
418
420
|
"memory.features.entityExtractionEnabled": "实体抽取",
|
|
419
421
|
"memory.features.entityExtractionEnabled.hint": "从记忆中提取人物/项目/概念,图谱随之生长",
|
|
420
422
|
"memory.features.entityExtractionProvider": "实体抽取 Provider",
|
|
@@ -466,6 +468,13 @@ window.__ModuleLoader__.load({
|
|
|
466
468
|
"memory.features.memoryQualityFilter.enabled.hint": "低价值记忆自动归档,注入按质量降权",
|
|
467
469
|
"memory.features.llmAudit.enabled": "后台调用审计",
|
|
468
470
|
"memory.features.llmAudit.enabled.hint": "记录巩固/总结的后台模型调用与 token 消耗",
|
|
471
|
+
// #254:写入准入两层。检测关时整条路径不碰;检测开、拦截关 = 仅告警 +
|
|
472
|
+
// 留审计;两个都开才真拦。文案要写清「只留审计」与「真拦下」的区别,
|
|
473
|
+
// 否则操作者会以为打开检测就已经在拦。
|
|
474
|
+
"memory.features.writeAdmission.enabled": "写入准入判定",
|
|
475
|
+
"memory.features.writeAdmission.enabled.hint": "写入前判空白/噪声,命中留审计(不拦)",
|
|
476
|
+
"memory.features.writeAdmission.enforce": "写入准入拦截",
|
|
477
|
+
"memory.features.writeAdmission.enforce.hint": "真拦下命中的写入;关闭时只留审计不拦",
|
|
469
478
|
"memory.features.bm25SearchEnabled": "BM25 关键词检索",
|
|
470
479
|
"memory.features.bm25SearchEnabled.hint": "传统关键词打分检索,与向量召回互补",
|
|
471
480
|
"memory.features.conflictFreezeEnabled": "冲突冻结",
|
|
@@ -839,6 +848,8 @@ window.__ModuleLoader__.load({
|
|
|
839
848
|
"memory.features.autoSummarize.hint": "Distill memory entries when a conversation ends",
|
|
840
849
|
"memory.features.hotMemoryEnabled": "Hot memory",
|
|
841
850
|
"memory.features.hotMemoryEnabled.hint": "Carry the last few turns verbatim; never written to long-term memory",
|
|
851
|
+
"memory.features.injectTimePrefix": "Inject current time at conversation start",
|
|
852
|
+
"memory.features.injectTimePrefix.hint": "Adds the date and weekday once at each new session's first turn (off by default)",
|
|
842
853
|
"memory.features.entityExtractionEnabled": "Entity extraction",
|
|
843
854
|
"memory.features.entityExtractionEnabled.hint": "Extract people / projects / concepts so the graph grows by itself",
|
|
844
855
|
"memory.features.entityExtractionProvider": "Entity extraction provider",
|
|
@@ -890,6 +901,12 @@ window.__ModuleLoader__.load({
|
|
|
890
901
|
"memory.features.memoryQualityFilter.enabled.hint": "Archive low-value memories automatically; injection downranks by quality",
|
|
891
902
|
"memory.features.llmAudit.enabled": "Background call audit",
|
|
892
903
|
"memory.features.llmAudit.enabled.hint": "Log background model calls and token usage from consolidation / summaries",
|
|
904
|
+
// #254 — two layers: enabled runs the check (audit only), enforce actually
|
|
905
|
+
// blocks. The hints must keep that difference explicit.
|
|
906
|
+
"memory.features.writeAdmission.enabled": "Write admission checks",
|
|
907
|
+
"memory.features.writeAdmission.enabled.hint": "Flag blank / noise writes before storing, with an audit row (never blocks)",
|
|
908
|
+
"memory.features.writeAdmission.enforce": "Write admission enforcement",
|
|
909
|
+
"memory.features.writeAdmission.enforce.hint": "Actually reject flagged writes; off keeps the audit row only",
|
|
893
910
|
"memory.features.bm25SearchEnabled": "BM25 keyword search",
|
|
894
911
|
"memory.features.bm25SearchEnabled.hint": "Classic keyword scoring, complementary to vector recall",
|
|
895
912
|
"memory.features.conflictFreezeEnabled": "Conflict freezing",
|
|
@@ -2072,7 +2089,7 @@ window.__ModuleLoader__.load({
|
|
|
2072
2089
|
// test/inject-parent-gate.test.js 钉住。
|
|
2073
2090
|
const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] };
|
|
2074
2091
|
const FEATURE_GROUPS = [
|
|
2075
|
-
{ key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "memoryQualityFilter.enabled", "llmAudit.enabled"] },
|
|
2092
|
+
{ key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce"] },
|
|
2076
2093
|
{ key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] },
|
|
2077
2094
|
{ key: "group.dream", items: ["autoDream", "sleepModeEnabled"] },
|
|
2078
2095
|
// v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。
|
package/lib/config.js
CHANGED
|
@@ -71,6 +71,9 @@ export const Config = z.object({
|
|
|
71
71
|
// 提示:上限/原长/全文 memory_get 指引(BUDGET_EXCEEDED 原则——绝不静默)。
|
|
72
72
|
// 默认 300 = 与既有行为一致。
|
|
73
73
|
injectContentMaxChars: z.natural().min(60).max(4000).default(300),
|
|
74
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入一次当前
|
|
75
|
+
// 时间。默认关 = 与既有行为一致;格式与键名不变,存量 feature_flags 恢复即生效。
|
|
76
|
+
injectTimePrefix: z.boolean().default(false),
|
|
74
77
|
importanceThreshold: z.natural().min(1).max(5).default(3),
|
|
75
78
|
// Issue #239(第 5 项):注入条数的查询自适应(默认关)。确定性强的话题收缩注入
|
|
76
79
|
// 条数(减半、下限 1),模糊话题(回指/时间线索,或极短查询)维持
|
|
@@ -588,6 +591,29 @@ export const Config = z.object({
|
|
|
588
591
|
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
589
592
|
}).default({}),
|
|
590
593
|
|
|
594
|
+
// --- write admission (#254) ------------------------------------------------
|
|
595
|
+
// 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
|
|
596
|
+
// 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
|
|
597
|
+
// 这个是**写入前**判定(硬拒不能错)。
|
|
598
|
+
//
|
|
599
|
+
// 两个开关都默认关(验收第 1 条「默认关零行为变化」):
|
|
600
|
+
// enabled — 跑第 1 级确定性判据。关时 evaluate 的返回与只计量那一阶段逐字段
|
|
601
|
+
// 一致,写入路径完全不碰。
|
|
602
|
+
// enforce — 命中时真的拒绝。关时判据照跑、审计照留,决策回落 allow(仅告警)。
|
|
603
|
+
// 读审计时「metadata.deny 非空且 metadata.decision=allow」就是这一档的指纹。
|
|
604
|
+
// 分层是为了能把「先观察」和「开始拦」分成两次上线:enforce 打开前先在真实流量
|
|
605
|
+
// 里看误杀面,而不是靠回滚开关当保险。
|
|
606
|
+
//
|
|
607
|
+
// 第 1 级的判据面刻意收窄到两类:密钥/PII(#164 A2 是判据来源,经
|
|
608
|
+
// createWriteAdmission 的 sensitiveScan 注入;本批只定义它怎么被消费)与空白/
|
|
609
|
+
// 纯噪声文本。去重键命中**不**进第 1 级——按维护者 09-22 的判定归 write-update
|
|
610
|
+
// 放行(2606.24535 的失效模式:同步近重复门跑在异步矛盾检测之前,把本该被裁决
|
|
611
|
+
// 的矛盾直接拒掉);G1/G2 的阈值继续只计量,等遥测有分布再单独提。
|
|
612
|
+
writeAdmission: z.object({
|
|
613
|
+
enabled: z.boolean().default(false),
|
|
614
|
+
enforce: z.boolean().default(false)
|
|
615
|
+
}).default({}),
|
|
616
|
+
|
|
591
617
|
// --- recall evaluation: test-result storage (v0.4.6, 方案 B) --------------
|
|
592
618
|
// Separate retrieval evaluation snapshots from the production recall audit.
|
|
593
619
|
// When false (default) evaluateRetrieval still computes precision/recall/mrr
|
package/lib/index.js
CHANGED
|
@@ -272,10 +272,16 @@ export const apply = (ctx, config) => {
|
|
|
272
272
|
documentIndex.remove();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
275
|
+
// 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
|
|
276
|
+
// 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
|
|
277
|
+
// llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
|
|
278
|
+
// 关掉,在无保留期的表里按写入频次增长是不能接受的)。
|
|
279
|
+
//
|
|
280
|
+
// sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
|
|
281
|
+
// 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里——
|
|
282
|
+
// 接上时只改这一行:
|
|
283
|
+
// createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) })
|
|
284
|
+
// 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。
|
|
279
285
|
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
280
286
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
281
287
|
|
package/lib/inject.js
CHANGED
|
@@ -16,6 +16,23 @@ export function getInjectionSnapshot() {
|
|
|
16
16
|
return injectionSnapshot;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Issue #334:UTF-16 截断切开 emoji 代理对会留下孤立代理项(如 🔴 = D83D DD34
|
|
20
|
+
// 在 800 处切,D83D 留下、DD34 被切走)。DSH 把它序列化成非法 UTF-8,DeepSeek
|
|
21
|
+
// API 对每个请求回 400 INVALID_REQUEST——而且畸形文本随注入消息被永久写进会话
|
|
22
|
+
// 历史,此后**每一轮**都带着它,表现为会话从某一轮起连续 400、换话题/重启都不
|
|
23
|
+
// 恢复(会话级不可逆损坏)。防法是「少一个字符都行,绝不留半个」:尾字符是
|
|
24
|
+
// 高位代理(0xD800–0xDBFF)说明它的低位搭档被切走了,把这个高位代理整个丢掉。
|
|
25
|
+
// (反过来尾字符是低位代理不可能孤立——它的搭档必然紧邻在前、同在切点之内。)
|
|
26
|
+
// ASCII-only 的文本零开销(length 相等直接短路)。
|
|
27
|
+
function safeSlice(text, limit) {
|
|
28
|
+
const s = String(text);
|
|
29
|
+
if (s.length <= limit) return s;
|
|
30
|
+
let cut = s.slice(0, limit);
|
|
31
|
+
const tail = cut.charCodeAt(cut.length - 1);
|
|
32
|
+
if (tail >= 0xd800 && tail <= 0xdbff) cut = cut.slice(0, -1);
|
|
33
|
+
return cut;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
// Best-effort extraction of the current user's latest message text from the
|
|
20
37
|
// live session, for semantic-first injection (Bug4). The system-prompt
|
|
21
38
|
// interpolator renders synchronously, so this walks the already-materialized
|
|
@@ -36,11 +53,13 @@ function lastUserQuery(ctx) {
|
|
|
36
53
|
if (kind !== undefined && kind !== "user") continue;
|
|
37
54
|
const parts = event.data?.content;
|
|
38
55
|
if (!Array.isArray(parts) || parts.length === 0) continue;
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
return safeSlice(
|
|
57
|
+
parts
|
|
58
|
+
.map((p) => (typeof p === "string" ? p : p?.text ?? ""))
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join("\n"),
|
|
61
|
+
500
|
|
62
|
+
);
|
|
44
63
|
}
|
|
45
64
|
} catch { /* session internals unavailable: degrade to no query */ }
|
|
46
65
|
return "";
|
|
@@ -85,7 +104,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
85
104
|
const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
|
|
86
105
|
if (isUser) {
|
|
87
106
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
88
|
-
pendingQuery = textOf(event)
|
|
107
|
+
pendingQuery = safeSlice(textOf(event), 500);
|
|
89
108
|
continue;
|
|
90
109
|
}
|
|
91
110
|
// Only assistant-originated events close a round; tool/system events
|
|
@@ -94,7 +113,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
94
113
|
|| kind === "assistant";
|
|
95
114
|
const body = isAssistant ? textOf(event) : "";
|
|
96
115
|
if (!body || !pendingQuery) continue;
|
|
97
|
-
rounds.push({ query: pendingQuery, response: body
|
|
116
|
+
rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
|
|
98
117
|
pendingQuery = null;
|
|
99
118
|
}
|
|
100
119
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
@@ -171,7 +190,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
171
190
|
function injectMemory(m, maxLength = maxContent) {
|
|
172
191
|
const text = String(m?.content ?? "");
|
|
173
192
|
if (text.length <= maxLength) return text;
|
|
174
|
-
return `${text
|
|
193
|
+
return `${safeSlice(text, maxLength)}…${STR.truncatedHint[language](maxLength, text.length, m.id)}`;
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
// Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
|
|
@@ -192,6 +211,41 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
192
211
|
return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
// Time-prefix injection (issue #34,恢复于 #333): opt-in, off by default.
|
|
215
|
+
// When enabled the current date/time is injected once per conversation — at
|
|
216
|
+
// the first prompt assembly of a new session — so the model can sense what
|
|
217
|
+
// time/day it is. A per-session latch means later assemblies in the same
|
|
218
|
+
// session never re-inject; a bare render ctx (no session id) falls back to
|
|
219
|
+
// once per injector lifetime.
|
|
220
|
+
// v0.7.11 面板大改版(ce4658e)时这段随一次批量删除被静默移除,CHANGELOG 无
|
|
221
|
+
// 登记(#162 的 escapePromptVars 同刀误删、后来恢复,本函数漏了)——用户按
|
|
222
|
+
// CHANGELOG 开着开关,实际什么都不会发生。本次按 v0.7.2 语义原样恢复:键名、
|
|
223
|
+
// 默认值(false)、格式 `[当前时间: YYYY-MM-DD 周X HH:MM]` 全部不变,存量
|
|
224
|
+
// feature_flags 里的配置恢复即生效。
|
|
225
|
+
// 已注入过时间前缀的会话集合(#335 review:单值闩锁在 A→B→A 交替渲染下会
|
|
226
|
+
// 重复注入)。上限 500 防长驻进程无界增长,超限逐出最早记录。
|
|
227
|
+
const timePrefixDone = new Set();
|
|
228
|
+
let timePrefixBare = false;
|
|
229
|
+
|
|
230
|
+
function renderTimePrefix(ctx) {
|
|
231
|
+
if (config.injectTimePrefix !== true) return "";
|
|
232
|
+
const sessionId = ctx?.agent?.session?.id;
|
|
233
|
+
if (sessionId === undefined) {
|
|
234
|
+
if (timePrefixBare) return "";
|
|
235
|
+
timePrefixBare = true;
|
|
236
|
+
} else {
|
|
237
|
+
if (timePrefixDone.has(sessionId)) return "";
|
|
238
|
+
if (timePrefixDone.size >= 500) timePrefixDone.delete(timePrefixDone.keys().next().value);
|
|
239
|
+
timePrefixDone.add(sessionId);
|
|
240
|
+
}
|
|
241
|
+
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
242
|
+
const now = new Date();
|
|
243
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
244
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
245
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
246
|
+
return escapePromptVars(`[当前时间: ${date} ${WEEKDAYS[now.getDay()]} ${time}]`);
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
// Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
|
|
196
250
|
// the materialized event log on every render — stateless, so it survives
|
|
197
251
|
// session switches and never persists anywhere.
|
|
@@ -335,7 +389,12 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
335
389
|
// separate context) keeps the prompt assembly stable at two blocks.
|
|
336
390
|
const hotText = renderHotContext(ctx);
|
|
337
391
|
const body = render(candidates, pinnedStats);
|
|
338
|
-
|
|
392
|
+
// #333(issue #34 恢复):时间前缀只在会话首轮出现(per-session 闩锁),
|
|
393
|
+
// 排在整个记忆块的最前——模型先知道「今天几号」,再看热上下文与长期记忆。
|
|
394
|
+
const timePrefix = renderTimePrefix(ctx);
|
|
395
|
+
// 拼接次序:时间 → 热上下文 → 长期记忆(三者都可能为空,逐级判空拼接)。
|
|
396
|
+
const withHot = !hotText ? body : body ? `${hotText}\n\n${body}` : hotText;
|
|
397
|
+
const finalBody = !timePrefix ? withHot : withHot ? `${timePrefix}\n\n${withHot}` : timePrefix;
|
|
339
398
|
// Issue #179:旁路缓存一帧——面板「注入预览」卡据此展示构成与体积。
|
|
340
399
|
// chars 为条目内容的截断后近似值(与 render 同一函数计长),totalChars
|
|
341
400
|
// 是本次实际返回块的精确长度。maxItems 反映自适应收缩后的生效值。
|
package/lib/service.js
CHANGED
|
@@ -1250,9 +1250,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1250
1250
|
scheduleEmbed(result);
|
|
1251
1251
|
return { action: "merged", memory: result };
|
|
1252
1252
|
}
|
|
1253
|
-
// #254
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1253
|
+
// #254 写入准入:决策形状由 write-admission.js 一次定死(阶段一定死、之后只加
|
|
1254
|
+
// 分支),这里只做两件事——先问决策,再在 enforce 命中时**在 store.save 之前**
|
|
1255
|
+
// 返回。放在 store.save 之前是这条闸门的意义所在:写进去再删等于没拦,而且中间
|
|
1256
|
+
// 那一瞬的注入/检索面已经暴露了。
|
|
1257
|
+
// 计量是旁路,拒绝不是:evaluate 抛异常按「不判定」处理(宁可漏拦也不能让判据
|
|
1258
|
+
// 故障把写入变成不可用),record 抛异常同样只 warn(审计失败不能反噬写入)。
|
|
1256
1259
|
let admission = null;
|
|
1257
1260
|
if (writeAdmission) {
|
|
1258
1261
|
try {
|
|
@@ -1261,6 +1264,23 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1261
1264
|
try { logger?.warn?.(`[dsh-mneme] write admission evaluate failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1262
1265
|
}
|
|
1263
1266
|
}
|
|
1267
|
+
// 第 1 级硬拒(enforce 打开且判据命中):这次写入不落库、不通知、不排嵌入、
|
|
1268
|
+
// 不抽实体。审计行仍然要落——拒绝面是这条闸门唯一可解释性的来源(验收第 2 条
|
|
1269
|
+
// 「被拒写入有审计与明确原因、可解释不静默丢弃」)。memoryId 传 null:这次写入
|
|
1270
|
+
// 没有产生任何行,related_memory_ids 必须空着,挂一个不存在的 id 就是造虚账。
|
|
1271
|
+
// reason 随返回值透出,工具层据此给模型一句可行动的拒绝理由。
|
|
1272
|
+
if (admission?.decision === "deny") {
|
|
1273
|
+
try {
|
|
1274
|
+
writeAdmission.record({
|
|
1275
|
+
sessionKey: memory._sessionKey,
|
|
1276
|
+
verdict: admission,
|
|
1277
|
+
memoryId: null
|
|
1278
|
+
});
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
try { logger?.warn?.(`[dsh-mneme] write admission record failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1281
|
+
}
|
|
1282
|
+
return { action: "denied", reason: admission.deny?.reason ?? "denied", deny: admission.deny ?? null, memory: null };
|
|
1283
|
+
}
|
|
1264
1284
|
const created = store.save({
|
|
1265
1285
|
type: memory.type,
|
|
1266
1286
|
title: memory.title,
|
|
@@ -1268,6 +1288,11 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1268
1288
|
tags: memory.tags ?? [],
|
|
1269
1289
|
importance: memory.importance ?? 3,
|
|
1270
1290
|
source: memory.source ?? "manual",
|
|
1291
|
+
// epistemic_status 必须显式透传:省略时 store 会回退到内容标记推断
|
|
1292
|
+
// (中文正则),英文/无标记内容一律落 subjective——E2 四臂实验当场抓到
|
|
1293
|
+
// (trustEpistemicWeighting 的重排对 saveWithDedupe 写入的记忆整体空转,
|
|
1294
|
+
// R+ 臂与 A 臂注入序逐条相同)。undefined 保持推断行为,显式值优先。
|
|
1295
|
+
epistemic_status: memory.epistemic_status,
|
|
1271
1296
|
...(memory.evidence !== undefined ? { evidence: memory.evidence } : {}),
|
|
1272
1297
|
// v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
|
|
1273
1298
|
// v0.8.1 底座:来源(auto/explicit)与决策时间随行透传。
|
package/lib/settings.js
CHANGED
|
@@ -43,6 +43,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
43
43
|
"autoInject",
|
|
44
44
|
"autoSummarize",
|
|
45
45
|
"hotMemoryEnabled",
|
|
46
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入当前时间。
|
|
47
|
+
// 顶层扁平键,随白名单回面板(FEATURE_GROUPS 的 group.core)。
|
|
48
|
+
"injectTimePrefix",
|
|
46
49
|
// Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
|
|
47
50
|
"injectUncertaintyAdaptive",
|
|
48
51
|
// Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
|
|
@@ -100,7 +103,12 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
100
103
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
101
104
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
102
105
|
"memoryQualityFilter.enabled",
|
|
103
|
-
"llmAudit.enabled"
|
|
106
|
+
"llmAudit.enabled",
|
|
107
|
+
// Issue #254:写入准入(第 1 级确定性拒绝)。两个键分层——enabled 跑判据、
|
|
108
|
+
// enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
|
|
109
|
+
// writeAdmission 对象。
|
|
110
|
+
"writeAdmission.enabled",
|
|
111
|
+
"writeAdmission.enforce"
|
|
104
112
|
];
|
|
105
113
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
106
114
|
const FEATURE_FLAG_INT_RANGES = {
|
package/lib/tools.js
CHANGED
|
@@ -149,11 +149,17 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
149
149
|
type: "object",
|
|
150
150
|
additionalProperties: false,
|
|
151
151
|
properties: {
|
|
152
|
-
action: { type: "string", required: true, enum: ["created", "merged"] },
|
|
153
|
-
|
|
152
|
+
action: { type: "string", required: true, enum: ["created", "merged", "denied"] },
|
|
153
|
+
// denied 时压根没有行可指,所以 id 缺省而不是空串:空串会让「写成功但
|
|
154
|
+
// id 为空」与「根本没写」在读回执时长得一样,与 #254 验收第 2 条
|
|
155
|
+
// (被拒写入可解释、不静默丢弃)相悖。
|
|
156
|
+
id: { type: "string", description: "Row id; absent when action=denied." },
|
|
157
|
+
reason: { type: "string", description: "Rejection reason when action=denied: blank | noise | sensitive." }
|
|
154
158
|
}
|
|
155
159
|
},
|
|
156
|
-
render: (_args, value) => TEXT_OUTPUT(
|
|
160
|
+
render: (_args, value) => TEXT_OUTPUT(value.action === "denied"
|
|
161
|
+
? `memory save rejected (${value.reason ?? "denied"}): nothing was written`
|
|
162
|
+
: `memory ${value.action}: ${value.id}`)
|
|
157
163
|
},
|
|
158
164
|
async execute(args, exec) {
|
|
159
165
|
// scope 标注(v0.8.1 底座,issue #170):默认由会话身份解析(载体自动
|
|
@@ -172,10 +178,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
172
178
|
const workspaceLabel = args.workspace_scope !== undefined
|
|
173
179
|
? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
|
|
174
180
|
: autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
|
|
175
|
-
const
|
|
176
|
-
// #254
|
|
177
|
-
//
|
|
178
|
-
//
|
|
181
|
+
const result = service.saveWithDedupe({
|
|
182
|
+
// #254 写入准入的计数单位:会话键作为瞬时字段随写入传递(与 _mergeInto /
|
|
183
|
+
// _overwrite 同款约定,不落库)。缺会话身份的宿主返回 null = 这次写入不进
|
|
184
|
+
// 预算;系统写入(dream / summarize / organize)从不带这个字段。
|
|
179
185
|
_sessionKey: sessionKeyOf(exec),
|
|
180
186
|
type: args.type,
|
|
181
187
|
title: args.title,
|
|
@@ -188,7 +194,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
188
194
|
...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
|
|
189
195
|
...(args.occurred_at !== undefined ? { occurred_at: args.occurred_at } : {})
|
|
190
196
|
});
|
|
191
|
-
|
|
197
|
+
// #254 第 1 级硬拒:工具层是模型唯一能看到这次失败的地方,必须显式回一句
|
|
198
|
+
// 可行动的拒绝理由。静默返回成功会让模型以为记住了,然后永远不再重写。
|
|
199
|
+
if (result.action === "denied") return { action: "denied", reason: result.reason };
|
|
200
|
+
return { action: result.action, id: result.memory.id };
|
|
192
201
|
}
|
|
193
202
|
}),
|
|
194
203
|
|