@modusensus/dsh-mneme 0.8.13 → 0.8.15
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/LICENSE +21 -21
- package/README.md +655 -626
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -0
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -572
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -836
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -671
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -0
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -0
- package/lib/service.js +2663 -2596
- package/lib/settings.js +517 -509
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +8 -4
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -0
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -572
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -836
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -671
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -0
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -0
- package/src/service.js +2663 -2596
- package/src/settings.js +517 -509
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1688
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -0
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -61
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -343
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -0
- package/test/serve.test.js +196 -0
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -544
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/write-admission.test.js +668 -668
package/src/write-admission.js
CHANGED
|
@@ -1,402 +1,402 @@
|
|
|
1
|
-
// #254 写入准入。两个阶段:
|
|
2
|
-
// 阶段一(已合入):只计量、不拦截。会话写入预算的 N 与同话题冷却的 X 在没有真实
|
|
3
|
-
// 分布时拍任何数字都是误杀面(维护者 2026-09-23 拍板「先记录不拦,由遥测定」)。
|
|
4
|
-
// 所以那两个闸门只把原始测量点写进 llm_audit_logs(status='skipped'、
|
|
5
|
-
// metadata.gate='g1' 且同话题重复时附 metadata.g2),不做判定、不改任何写入行为;
|
|
6
|
-
// 阈值与二次确认(memory_save({confirm:true}))等遥测有分布再单独提。
|
|
7
|
-
// 阶段一之后(本批):第 1 级确定性拒绝。按 2026-09-21/09-22 两轮收窄的口径,
|
|
8
|
-
// 第 1 级只剩**密钥/PII 与空白噪声**两类判据走硬拒;去重键命中判 write-update 放行
|
|
9
|
-
// (不把同步去重升级成拒绝门——2606.24535 记的那个失效模式:同步近重复门抢在异步
|
|
10
|
-
// 矛盾检测之前,本该被裁决的矛盾写入被门直接拒掉)。两类判据都零 LLM、纯确定性。
|
|
11
|
-
// 两个开关都默认关:enabled 打开判定,enforce 打开拦截(关 = 仅告警 + 留审计)。
|
|
12
|
-
// 默认关时 evaluate 的返回与阶段一逐字段一致(验收第 1 条「默认路径零行为变化」)。
|
|
13
|
-
// 密钥/PII 那一类的判据实现不在本模块:按验收第 4 条它是 #164 A2 的判据来源,由
|
|
14
|
-
// createWriteAdmission 的 sensitiveScan 注入,本模块只定义它怎么被消费。
|
|
15
|
-
//
|
|
16
|
-
// 为什么复用 llm_audit_logs:它是仓库既有的「后台动作回执」表(bookkeeping,不
|
|
17
|
-
// 触发写钩子,见 store.js 的表注释)。第二阶段落地时「第一次 skipped/gate →
|
|
18
|
-
// 第二次 confirm:true 放行」在审计里成对出现,因果可回放。
|
|
19
|
-
//
|
|
20
|
-
// 一行一个新建行(不是一行一个信号):这样「会话内新建条数」就是按 session_key
|
|
21
|
-
// 的直接计数,不需要任何 JSON 解析;g2 命中时作为该行的 metadata.g2 附带,不做第
|
|
22
|
-
// 二行。测量点只取在新行上——并入已有行是去重机制在正常工作,既不进预算也不记行。
|
|
23
|
-
// 会话身份来自工具 exec(scope.js › sessionKeyOf):没有会话身份的写入(dream /
|
|
24
|
-
// summarize / import / organize)不进预算,预算管的是「一次对话里写飞了」。
|
|
25
|
-
//
|
|
26
|
-
// 穿透口(与 #275 拍板 5 的 pinned 豁免同口径):constraint / preference 是 #249
|
|
27
|
-
// 第一批立的逐字保真池,永不进预算;仍记一行审计(metadata.exempt='pinned')以便
|
|
28
|
-
// 观察穿透频率。穿透行既不参与 g2 判定,也不推进同话题的时间基准——它整个不在闸门
|
|
29
|
-
// 里,成为下一次比较的基准会把 g2 的样本混进穿透流量。
|
|
30
|
-
//
|
|
31
|
-
// 内容哈希(exact duplicate,第三类测量点,维护者 2026-09-24 拍板):归一化口径定在
|
|
32
|
-
// content-hash.js,命中即「同一内容又被写了一次」。判据用哈希而不是相似度——相似度
|
|
33
|
-
// 阈值在同一件事换个说法 / 不同的事共享话题词之间来回挪,只会换一种错法。候选集先按
|
|
34
|
-
// type 与 scope 三维等值收窄再比哈希(不是全表扫),并把归档行一并纳入:「出口止体积、
|
|
35
|
-
// 不止重复」,被质量闸归档的同一个事实不在 saveWithDedupe 的候选集里(store.list 默认
|
|
36
|
-
// 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与
|
|
37
|
-
// pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。
|
|
38
|
-
//
|
|
39
|
-
// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话
|
|
40
|
-
// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。
|
|
41
|
-
//
|
|
42
|
-
// 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的
|
|
43
|
-
// deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做
|
|
44
|
-
// 按写入频次增长。
|
|
45
|
-
import { PINNED_MEMORY_TYPES } from "./service.js";
|
|
46
|
-
import { contentHashOf, normalizeForHash } from "./content-hash.js";
|
|
47
|
-
|
|
48
|
-
// 审计口径(llm_audit_logs 的既有列):trigger_source = 组件名,operation_type =
|
|
49
|
-
// 动作名,status='skipped' = 本行没有产生任何 LLM 花费(与 summarize 的间隔门、
|
|
50
|
-
// 尖峰门同款读法)。
|
|
51
|
-
export const ADMISSION_TRIGGER_SOURCE = "writeAdmission";
|
|
52
|
-
export const ADMISSION_OPERATION_TYPE = "write_admission";
|
|
53
|
-
export const ADMISSION_GATE_SESSION_BUDGET = "g1";
|
|
54
|
-
|
|
55
|
-
// 决策两值。阶段一恒 allow;第 1 级判定命中且 enforce 打开时才 deny。
|
|
56
|
-
export const ADMISSION_DECISION_ALLOW = "allow";
|
|
57
|
-
export const ADMISSION_DECISION_DENY = "deny";
|
|
58
|
-
|
|
59
|
-
// 第 1 级的拒绝原因(#254 拍板:硬拒绝只留密钥/PII 与空白噪声;去重键命中归
|
|
60
|
-
// write-update 放行,G1/G2 走二次确认)。reason 是审计行里的稳定键,别当展示文案用。
|
|
61
|
-
export const ADMISSION_DENY_BLANK = "blank";
|
|
62
|
-
export const ADMISSION_DENY_NOISE = "noise";
|
|
63
|
-
export const ADMISSION_DENY_SENSITIVE = "sensitive";
|
|
64
|
-
|
|
65
|
-
// 首次见到某会话时的回填深度:话题表由审计行重建,只回填最近这么多行。更早的
|
|
66
|
-
// 话题会漏(测量口径可接受),换来的是一次查询与有界的常驻内存。
|
|
67
|
-
export const TOPIC_LOOKBACK_ROWS = 200;
|
|
68
|
-
|
|
69
|
-
// 话题锚只收机械可判的两类:issue/PR 引用与文件路径。两条都带前缀或分隔符,误报
|
|
70
|
-
// 面小;自由文本里看不出确定性的「同话题」,硬判只会把误杀面提前引进来(#254 已
|
|
71
|
-
// 定:计量信号用确定性锚,不用相似度阈值——同一件事换个说法就掉下去,不同的事
|
|
72
|
-
// 共享话题词又顶上来,阈值怎么挪都是在两种误判之间来回)。
|
|
73
|
-
// 两处收紧都来自误报样本:`(?<![#\w])` 挡掉 Markdown 标题 `###1` 与紧跟标识符的
|
|
74
|
-
// 井号;扩展名要求首字符是字母,挡掉 `3.5/2.0` 这类比值(否则一个算式就成了话题)。
|
|
75
|
-
const ISSUE_REF = /(?<![#\w])#\d{1,7}\b/g;
|
|
76
|
-
const FILE_PATH = /(?:[\w.-]+[\\/])+[\w.-]+\.[A-Za-z][A-Za-z0-9]{0,5}\b/g;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 写入文本里的确定性话题锚:小写归一 + 路径分隔符统一为 `/` + 去重 + 排序。
|
|
80
|
-
* 同一话题在不同轮次必须逐字节相等,才能用等值比较而不是相似度比较——所以
|
|
81
|
-
* `src\service.js` 与 `src/service.js` 必须归到同一个锚。
|
|
82
|
-
* @param {{title?: string, content?: string, tags?: string[]}} memory
|
|
83
|
-
* @returns {string[]} 排序后的话题锚
|
|
84
|
-
*/
|
|
85
|
-
export function extractTopicKeys(memory) {
|
|
86
|
-
const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])]
|
|
87
|
-
.filter((s) => typeof s === "string")
|
|
88
|
-
.join("\n");
|
|
89
|
-
const keys = new Set();
|
|
90
|
-
const add = (raw) => keys.add(raw.toLowerCase().replace(/\\/g, "/"));
|
|
91
|
-
for (const m of text.matchAll(ISSUE_REF)) add(m[0]);
|
|
92
|
-
for (const m of text.matchAll(FILE_PATH)) add(m[0]);
|
|
93
|
-
return [...keys].sort();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 第 1 级「空白 / 纯噪声」判据(#254,零 LLM、纯函数、无状态)。
|
|
98
|
-
*
|
|
99
|
-
* 为什么这两类够格进硬拒:它们的命中面**没有解释空间**——一条归一化后什么都不剩的
|
|
100
|
-
* 写入,进库之后既检索不到也注入不了,写它只有成本没有收益,不存在「误杀有价值内容」
|
|
101
|
-
* 的可能。这正是硬拒与二次确认的分界:有解释空间的(去重命中、预算/冷却)走二次确认,
|
|
102
|
-
* 没有解释空间的才硬拒。
|
|
103
|
-
*
|
|
104
|
-
* 为什么复用 content-hash 的归一化而不另写一套:判据要的是「有没有信息」,而
|
|
105
|
-
* normalizeForHash 的口径(NFKC → 小写 → 去标点 → 空白折叠)恰好就是「把格式折掉之后
|
|
106
|
-
* 还剩什么」。另写一套只会多出一个会漂移的口径(同 content-hash.js 文件头的存量重算
|
|
107
|
-
* 理由)。两个 reason 分开报是为了可解释:`blank` 是真的什么都没写(提交一个空表单),
|
|
108
|
-
* `noise` 是写了但只有标点/空白(`...`、`---`)——两者的修法不同。
|
|
109
|
-
*
|
|
110
|
-
* 为什么不用 quality-filter 的 `repetitive`(dedupRatio < 0.3)一起判:那是**写入后**
|
|
111
|
-
* 的评分扣分项,扣分可以错(只影响排序与归档),硬拒不能错。一条字符多样性低的正常
|
|
112
|
-
* 记忆(同一种分隔符排出的长清单)会被它判成 repetitive,本判据不背这个误杀面。
|
|
113
|
-
*
|
|
114
|
-
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
115
|
-
* @returns {"blank"|"noise"|null} 命中即返回 reason,否则 null
|
|
116
|
-
*/
|
|
117
|
-
export function informationlessReason(memory) {
|
|
118
|
-
const title = String(memory?.title ?? "").trim();
|
|
119
|
-
const content = String(memory?.content ?? "").trim();
|
|
120
|
-
if (!title && !content) return ADMISSION_DENY_BLANK;
|
|
121
|
-
if (!normalizeForHash(title) && !normalizeForHash(content)) return ADMISSION_DENY_NOISE;
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* 写入准入(#254)。返回的两个方法就是三个消费方共用的那份接口:
|
|
127
|
-
* evaluate 给出决策形状,record 把测量点/拒绝面写成审计行。
|
|
128
|
-
* #249 的注入提示与 #275 的水位计数都读同一份 decision/审计行,而不是各写一套判定。
|
|
129
|
-
*
|
|
130
|
-
* 两个开关(config.writeAdmission,都默认关——验收第 1 条「默认路径零行为变化」):
|
|
131
|
-
* enabled — 打开第 1 级判定(空白/噪声 + 注入进来的密钥/PII 判据)。
|
|
132
|
-
* 关时 evaluate 的返回与阶段一逐字段一致,只算三个测量点。
|
|
133
|
-
* enforce — 命中时真的拒绝。关时判定照跑、照留审计,但决策回落 allow(仅告警)。
|
|
134
|
-
* 密钥/PII 那一类判据不在这里实现:按 #254 验收第 4 条,它是 #164 A2 的判据来源,
|
|
135
|
-
* 由 `sensitiveScan` 注入(见下),本模块只定义它怎么被消费。
|
|
136
|
-
*
|
|
137
|
-
* @param {object} deps
|
|
138
|
-
* @param {object} deps.store 存储层(listLlmAudits / saveLlmAudit)
|
|
139
|
-
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled、writeAdmission.*)
|
|
140
|
-
* @param {object} [deps.logger]
|
|
141
|
-
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
142
|
-
* @param {(memory: object) => ({reason: string, kind?: string, label?: string}|null)} [deps.sensitiveScan]
|
|
143
|
-
* 第 1 级的密钥/PII 判据(#164 A2 的落点)。约定:命中返回 `{kind, label}`,未命中
|
|
144
|
-
* 返回 null,**不抛**(抛出按未命中处理并 warn,判据故障不能让写入变成不可用)。
|
|
145
|
-
* 缺省时这一类判据整个不参与——本模块在 A2 落地前只跑空白/噪声。
|
|
146
|
-
*/
|
|
147
|
-
export function createWriteAdmission({ store, config, logger, now = Date.now, sensitiveScan = null } = {}) {
|
|
148
|
-
// sessionKey → Map<话题锚, 最近一次新建行时刻(ms)>。真相源是审计行,这里只是它的
|
|
149
|
-
// 增量视图:进程重启后按需回填。FIFO 上限防长驻进程内存无界(同 store.js 的
|
|
150
|
-
// embedding 解析缓存口径)。
|
|
151
|
-
const topicTables = new Map();
|
|
152
|
-
const SESSION_CACHE_MAX = 200;
|
|
153
|
-
|
|
154
|
-
const warn = (msg) => {
|
|
155
|
-
// 计量是旁路:任何一环失败都不能影响写入,日志自身故障也一样。
|
|
156
|
-
try {
|
|
157
|
-
logger?.warn?.(msg);
|
|
158
|
-
} catch { /* ignore */ }
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/** 某会话的话题表(首见时从审计行回填,之后走内存增量)。 */
|
|
162
|
-
function topicTable(sessionKey) {
|
|
163
|
-
const cached = topicTables.get(sessionKey);
|
|
164
|
-
if (cached) return cached;
|
|
165
|
-
const table = new Map();
|
|
166
|
-
try {
|
|
167
|
-
const rows = store?.listLlmAudits?.({ sessionKey, limit: TOPIC_LOOKBACK_ROWS }) ?? [];
|
|
168
|
-
for (const row of rows) {
|
|
169
|
-
const metadata = row?.metadata;
|
|
170
|
-
if (!metadata || typeof metadata !== "object" || !Array.isArray(metadata.topics)) continue;
|
|
171
|
-
// 穿透行(pinned)整个不在闸门里,也就不该进基准表——只挡内存路径不挡回填,
|
|
172
|
-
// 重启后口径就变了(同一个话题会因为一条穿透行而被判成重复)。
|
|
173
|
-
if (metadata.exempt) continue;
|
|
174
|
-
// 被第 1 级拦下的行同理:它根本没进库。内存路径已经跳过了(见 record 的
|
|
175
|
-
// enforced 判断),回填路径必须同口径——否则同一条时间线会随进程重启而变:
|
|
176
|
-
// 重启前不重复、重启后变重复。仅告警档(deny 非空但 decision=allow)要留,
|
|
177
|
-
// 那条行确实落库了。
|
|
178
|
-
if (metadata.deny?.enforced === true) continue;
|
|
179
|
-
const at = Date.parse(row.timestamp);
|
|
180
|
-
// 坏时间戳跳过:落到 0 会让 gap 变成几十年,一个离群值就能带偏按分位定的 X。
|
|
181
|
-
if (!Number.isFinite(at)) continue;
|
|
182
|
-
for (const topic of metadata.topics) {
|
|
183
|
-
// 审计行按时间倒序返回,先到的那条就是该话题最近一次写入——后来的不覆盖。
|
|
184
|
-
if (!table.has(topic)) table.set(topic, at);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
} catch (e) {
|
|
188
|
-
// 回填失败降级为「本会话此前的话题不可见」:漏一个 g2 测量点,不影响写入。
|
|
189
|
-
warn(`[dsh-mneme] write admission topic backfill failed: ${String(e)}`);
|
|
190
|
-
}
|
|
191
|
-
if (topicTables.size >= SESSION_CACHE_MAX && !topicTables.has(sessionKey)) {
|
|
192
|
-
topicTables.delete(topicTables.keys().next().value);
|
|
193
|
-
}
|
|
194
|
-
topicTables.set(sessionKey, table);
|
|
195
|
-
return table;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 内容哈希候选集查询(#254 第三类测量点)。失败降级为「没命中」:漏一个测量点,
|
|
200
|
-
* 不影响写入,也不让计量反噬。
|
|
201
|
-
* 多条命中时的取舍(活区优先):有活跃/未遗忘的命中就报它(判据是「去重候选集本该
|
|
202
|
-
* 拦住」),只有归档/遗忘行命中才报它们。store 侧已把活跃行排在窗口头部,这里的 find
|
|
203
|
-
* 是防御性重复:排序口径将来再变,本判据也不跟着变。
|
|
204
|
-
* archived 与 forgotten 分开回:两者都是「出口」,但穿透含义不同——只命中已遗忘行时
|
|
205
|
-
* 若只回 archived=false,读审计的人会以为库里有活跃重复;而 saveWithDedupe 的候选集
|
|
206
|
-
* 本来就排除遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。
|
|
207
|
-
* @returns {{memory_id: string, archived: boolean, forgotten: boolean}|null}
|
|
208
|
-
*/
|
|
209
|
-
function lookupContentDup(memory) {
|
|
210
|
-
try {
|
|
211
|
-
const hash = contentHashOf(memory);
|
|
212
|
-
if (!hash) return null;
|
|
213
|
-
const hits = store?.findContentHashMatches?.({
|
|
214
|
-
type: memory?.type,
|
|
215
|
-
hash,
|
|
216
|
-
agent_scope: memory?.agent_scope,
|
|
217
|
-
workspace_scope: memory?.workspace_scope,
|
|
218
|
-
sensitivity: memory?.sensitivity
|
|
219
|
-
}) ?? [];
|
|
220
|
-
const hit = hits.find((h) => !h.archived && !h.forgotten) ?? hits[0];
|
|
221
|
-
return hit ? { memory_id: hit.id, archived: hit.archived === true, forgotten: hit.forgotten === true } : null;
|
|
222
|
-
} catch (e) {
|
|
223
|
-
warn(`[dsh-mneme] write admission content hash lookup failed: ${String(e)}`);
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 第 1 级判据的汇总口(#254):空白 / 噪声 + 注入进来的密钥 / PII。
|
|
230
|
-
* 顺序按成本排:字符串判据在前,注入的扫描器在后;扫描器抛异常按「未命中」处理
|
|
231
|
-
* (warn)——判据故障不能让所有写入变成不可用,这是与计量同一条不反噬原则。
|
|
232
|
-
* @returns {{reason: string, kind?: string, label?: string}|null}
|
|
233
|
-
*/
|
|
234
|
-
function firstLevelHit(memory) {
|
|
235
|
-
const info = informationlessReason(memory);
|
|
236
|
-
if (info) return { reason: info };
|
|
237
|
-
if (typeof sensitiveScan !== "function") return null;
|
|
238
|
-
try {
|
|
239
|
-
const hit = sensitiveScan(memory);
|
|
240
|
-
if (!hit) return null;
|
|
241
|
-
const deny = { reason: ADMISSION_DENY_SENSITIVE };
|
|
242
|
-
// kind / label 只在判据真给了才落盘:给 undefined 会让审计行里出现一个
|
|
243
|
-
// 存在但无值的键,读的人分不清「没报」和「报了空串」。
|
|
244
|
-
if (hit.kind !== undefined) deny.kind = hit.kind;
|
|
245
|
-
if (hit.label !== undefined) deny.label = hit.label;
|
|
246
|
-
return deny;
|
|
247
|
-
} catch (e) {
|
|
248
|
-
warn(`[dsh-mneme] write admission sensitive scan failed: ${String(e)}`);
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* 决策形状一次定死,后续只加分支、不改字段:
|
|
255
|
-
* decision — "allow" | "deny"
|
|
256
|
-
* gate — "g1" | null(null = 这次写入不进预算,不记行)
|
|
257
|
-
* topics — 本次写入的确定性话题锚
|
|
258
|
-
* repeat — {topic, gapMs} | null(g2 的命中面)
|
|
259
|
-
* dup — {memory_id, archived, forgotten} | null(内容哈希的命中面)
|
|
260
|
-
* exempt — null | "pinned"
|
|
261
|
-
* deny — null | {reason, kind?, label?}(第 1 级命中面)
|
|
262
|
-
*
|
|
263
|
-
* deny 非空但 decision 仍是 allow = 仅告警档(config.writeAdmission.enforce 关):
|
|
264
|
-
* 判定照跑、审计照留,写入不拦。读审计时「deny 非空且 decision=allow」就是这一档的
|
|
265
|
-
* 指纹,不需要再读配置才能解释一行。
|
|
266
|
-
*
|
|
267
|
-
* 第 1 级判定放在 pinned 豁免之前:pinned 豁免的是**预算与冷却**(constraint /
|
|
268
|
-
* preference 是 #249 第一批立的逐字保真池,不该被会话预算拦),不是「这条能不能落库」。
|
|
269
|
-
* 一条写着私钥的 constraint 仍然是私钥。
|
|
270
|
-
* @param {{memory: object, sessionKey?: string|null}} input
|
|
271
|
-
*/
|
|
272
|
-
function evaluate({ memory, sessionKey } = {}) {
|
|
273
|
-
const verdict = {
|
|
274
|
-
decision: ADMISSION_DECISION_ALLOW,
|
|
275
|
-
gate: null,
|
|
276
|
-
topics: [],
|
|
277
|
-
repeat: null,
|
|
278
|
-
dup: null,
|
|
279
|
-
exempt: null,
|
|
280
|
-
deny: null
|
|
281
|
-
};
|
|
282
|
-
// 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。
|
|
283
|
-
// 第 1 级判定同样在这里之外:#164 A2 定的判据面含 autoSummarize / dream 输出,
|
|
284
|
-
// 那三处的接线是 A2 的落地范围(本模块跑的是会话内的 memory_save 路径)。
|
|
285
|
-
if (!sessionKey) return verdict;
|
|
286
|
-
// 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。
|
|
287
|
-
if (config?.llmAudit?.enabled === false) return verdict;
|
|
288
|
-
|
|
289
|
-
const hit = config?.writeAdmission?.enabled === true ? firstLevelHit(memory) : null;
|
|
290
|
-
const judged = hit ? { ...verdict, deny: hit } : verdict;
|
|
291
|
-
|
|
292
|
-
// 命中且 enforce:决策已定,不再发候选集查询、不再比较话题(纯白花成本)。
|
|
293
|
-
if (hit && config?.writeAdmission?.enforce === true) {
|
|
294
|
-
return {
|
|
295
|
-
...judged,
|
|
296
|
-
decision: ADMISSION_DECISION_DENY,
|
|
297
|
-
gate: ADMISSION_GATE_SESSION_BUDGET,
|
|
298
|
-
topics: extractTopicKeys(memory)
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const topics = extractTopicKeys(memory);
|
|
303
|
-
const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? ""));
|
|
304
|
-
if (pinned) return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" };
|
|
305
|
-
const dup = lookupContentDup(memory);
|
|
306
|
-
const table = topicTable(sessionKey);
|
|
307
|
-
const at = now();
|
|
308
|
-
let repeat = null;
|
|
309
|
-
for (const topic of topics) {
|
|
310
|
-
if (!table.has(topic)) continue;
|
|
311
|
-
const gapMs = Math.max(0, at - table.get(topic));
|
|
312
|
-
// 同一行可能命中多个锚:取最近的那次(间隔最短=最有价值的那次重复)。
|
|
313
|
-
if (!repeat || gapMs < repeat.gapMs) repeat = { topic, gapMs };
|
|
314
|
-
}
|
|
315
|
-
return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, repeat, dup };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* 把测量点与拒绝面落成审计行(一行一个新建行 / 一次被拒的写入)。四个信号的读法:
|
|
320
|
-
* g1 会话写入预算:`GROUP BY session_key` 计数即得「会话内新建条数」分布;要剔
|
|
321
|
-
* 掉穿透行(pinned 不进预算)就加 `json_extract(metadata,'$.exempt') IS NULL`。
|
|
322
|
-
* g2 同话题冷却:`json_extract(metadata,'$.g2.gap_ms')` 即得「同话题新建行间隔」
|
|
323
|
-
* 分布。间隔算的是同一话题两次**新建行**之间——标题命中走并入的那次不在这里
|
|
324
|
-
* (并入正是冷却要做的事,已经做到了)。
|
|
325
|
-
* dup 内容哈希:`json_extract(metadata,'$.dup.memory_id')` 非空即「这条新行的内容
|
|
326
|
-
* 与某条既有行归一化后逐字节相同」;`$.dup.archived` 区分命中那条在活区还是
|
|
327
|
-
* 归档区——归档区命中就是去重候选集漏掉的那一类穿透。
|
|
328
|
-
* deny 第 1 级命中:#254 验收第 2 条要的「被拒的写入可解释、不静默丢弃」就落在
|
|
329
|
-
* 这一项。三个键分工——`$.deny.reason` 是稳定判据键(blank / noise / sensitive),
|
|
330
|
-
* `$.deny.kind` 是密钥/PII 的子类,`$.deny.enforced` 区分「真拦下了」与「仅告警」。
|
|
331
|
-
* enforced=false 时这一行同时也是一个正常的 g1 行(写入确实发生了)。
|
|
332
|
-
* 计量绝不影响写入:任何异常只 warn。
|
|
333
|
-
* @returns {object[]} 落下的审计行(无测量点或失败时为空数组)
|
|
334
|
-
*/
|
|
335
|
-
function record({ sessionKey, verdict, memoryId } = {}) {
|
|
336
|
-
if (!sessionKey || !verdict || verdict.gate == null) return [];
|
|
337
|
-
const at = now();
|
|
338
|
-
const topics = Array.isArray(verdict.topics) ? verdict.topics : [];
|
|
339
|
-
const enforced = verdict.decision === ADMISSION_DECISION_DENY;
|
|
340
|
-
const rows = [];
|
|
341
|
-
try {
|
|
342
|
-
rows.push(store.saveLlmAudit({
|
|
343
|
-
timestamp: new Date(at).toISOString(),
|
|
344
|
-
trigger_source: ADMISSION_TRIGGER_SOURCE,
|
|
345
|
-
operation_type: ADMISSION_OPERATION_TYPE,
|
|
346
|
-
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得被当成某条路由的
|
|
347
|
-
// 花费来源统计进去。
|
|
348
|
-
model_id: "-",
|
|
349
|
-
status: "skipped",
|
|
350
|
-
related_memory_ids: memoryId ? [memoryId] : [],
|
|
351
|
-
session_key: sessionKey,
|
|
352
|
-
metadata: {
|
|
353
|
-
gate: verdict.gate,
|
|
354
|
-
topics,
|
|
355
|
-
...(verdict.exempt ? { exempt: verdict.exempt } : {}),
|
|
356
|
-
...(verdict.repeat ? { g2: { topic: verdict.repeat.topic, gap_ms: verdict.repeat.gapMs } } : {}),
|
|
357
|
-
// dup 两个出口分字段落盘:archived 与 forgotten 是两类不同的穿透,合成一个布尔
|
|
358
|
-
// 会让「只剩遗忘行命中」读起来像「库里有活跃重复」(见 lookupContentDup 注释)。
|
|
359
|
-
...(verdict.dup
|
|
360
|
-
? { dup: {
|
|
361
|
-
memory_id: verdict.dup.memory_id,
|
|
362
|
-
archived: verdict.dup.archived === true,
|
|
363
|
-
forgotten: verdict.dup.forgotten === true
|
|
364
|
-
} }
|
|
365
|
-
: {}),
|
|
366
|
-
// enforced 由 decision 推出而不是另存一个入参:两者若各存一份,将来就会出现
|
|
367
|
-
// 「decision=deny 但 enforced=false」这种自相矛盾的行。
|
|
368
|
-
//
|
|
369
|
-
// decision 只在真有拒绝面时落盘:两个开关都关时,这条 metadata 必须与只计量
|
|
370
|
-
// 那一阶段逐字节一致(#254 验收第 1 条)。没有 deny 的行本来就默认是放行,
|
|
371
|
-
// 写一个恒为 "allow" 的键只是把「默认路径零行为变化」变成需要解释的事。
|
|
372
|
-
// 读法:有 deny 才有 decision,deny 非空且 decision=allow 就是仅告警档。
|
|
373
|
-
...(verdict.deny
|
|
374
|
-
? {
|
|
375
|
-
decision: verdict.decision,
|
|
376
|
-
deny: {
|
|
377
|
-
reason: verdict.deny.reason,
|
|
378
|
-
...(verdict.deny.kind !== undefined ? { kind: verdict.deny.kind } : {}),
|
|
379
|
-
...(verdict.deny.label !== undefined ? { label: verdict.deny.label } : {}),
|
|
380
|
-
enforced
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
: {})
|
|
384
|
-
}
|
|
385
|
-
}));
|
|
386
|
-
} catch (e) {
|
|
387
|
-
// 审计失败只 warn(同 saveLlmAudit 的调用惯例),写入本身照常完成。
|
|
388
|
-
warn(`[dsh-mneme] write admission audit failed: ${String(e)}`);
|
|
389
|
-
}
|
|
390
|
-
// 只有真正落库、且不是穿透口的行才推进话题表:审计写失败时内存视图不能跑在审计
|
|
391
|
-
// 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。被拦下的写入
|
|
392
|
-
// 同样不推进——它没进库,成为下一次 g2 的基准就会造出一条不存在的时间线。
|
|
393
|
-
// 仅告警档(deny 非空但 decision=allow)**要**推进:那条行确实落库了。
|
|
394
|
-
if (rows.length > 0 && !verdict.exempt && !enforced) {
|
|
395
|
-
const table = topicTable(sessionKey);
|
|
396
|
-
for (const topic of topics) table.set(topic, at);
|
|
397
|
-
}
|
|
398
|
-
return rows;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
return { evaluate, record };
|
|
402
|
-
}
|
|
1
|
+
// #254 写入准入。两个阶段:
|
|
2
|
+
// 阶段一(已合入):只计量、不拦截。会话写入预算的 N 与同话题冷却的 X 在没有真实
|
|
3
|
+
// 分布时拍任何数字都是误杀面(维护者 2026-09-23 拍板「先记录不拦,由遥测定」)。
|
|
4
|
+
// 所以那两个闸门只把原始测量点写进 llm_audit_logs(status='skipped'、
|
|
5
|
+
// metadata.gate='g1' 且同话题重复时附 metadata.g2),不做判定、不改任何写入行为;
|
|
6
|
+
// 阈值与二次确认(memory_save({confirm:true}))等遥测有分布再单独提。
|
|
7
|
+
// 阶段一之后(本批):第 1 级确定性拒绝。按 2026-09-21/09-22 两轮收窄的口径,
|
|
8
|
+
// 第 1 级只剩**密钥/PII 与空白噪声**两类判据走硬拒;去重键命中判 write-update 放行
|
|
9
|
+
// (不把同步去重升级成拒绝门——2606.24535 记的那个失效模式:同步近重复门抢在异步
|
|
10
|
+
// 矛盾检测之前,本该被裁决的矛盾写入被门直接拒掉)。两类判据都零 LLM、纯确定性。
|
|
11
|
+
// 两个开关都默认关:enabled 打开判定,enforce 打开拦截(关 = 仅告警 + 留审计)。
|
|
12
|
+
// 默认关时 evaluate 的返回与阶段一逐字段一致(验收第 1 条「默认路径零行为变化」)。
|
|
13
|
+
// 密钥/PII 那一类的判据实现不在本模块:按验收第 4 条它是 #164 A2 的判据来源,由
|
|
14
|
+
// createWriteAdmission 的 sensitiveScan 注入,本模块只定义它怎么被消费。
|
|
15
|
+
//
|
|
16
|
+
// 为什么复用 llm_audit_logs:它是仓库既有的「后台动作回执」表(bookkeeping,不
|
|
17
|
+
// 触发写钩子,见 store.js 的表注释)。第二阶段落地时「第一次 skipped/gate →
|
|
18
|
+
// 第二次 confirm:true 放行」在审计里成对出现,因果可回放。
|
|
19
|
+
//
|
|
20
|
+
// 一行一个新建行(不是一行一个信号):这样「会话内新建条数」就是按 session_key
|
|
21
|
+
// 的直接计数,不需要任何 JSON 解析;g2 命中时作为该行的 metadata.g2 附带,不做第
|
|
22
|
+
// 二行。测量点只取在新行上——并入已有行是去重机制在正常工作,既不进预算也不记行。
|
|
23
|
+
// 会话身份来自工具 exec(scope.js › sessionKeyOf):没有会话身份的写入(dream /
|
|
24
|
+
// summarize / import / organize)不进预算,预算管的是「一次对话里写飞了」。
|
|
25
|
+
//
|
|
26
|
+
// 穿透口(与 #275 拍板 5 的 pinned 豁免同口径):constraint / preference 是 #249
|
|
27
|
+
// 第一批立的逐字保真池,永不进预算;仍记一行审计(metadata.exempt='pinned')以便
|
|
28
|
+
// 观察穿透频率。穿透行既不参与 g2 判定,也不推进同话题的时间基准——它整个不在闸门
|
|
29
|
+
// 里,成为下一次比较的基准会把 g2 的样本混进穿透流量。
|
|
30
|
+
//
|
|
31
|
+
// 内容哈希(exact duplicate,第三类测量点,维护者 2026-09-24 拍板):归一化口径定在
|
|
32
|
+
// content-hash.js,命中即「同一内容又被写了一次」。判据用哈希而不是相似度——相似度
|
|
33
|
+
// 阈值在同一件事换个说法 / 不同的事共享话题词之间来回挪,只会换一种错法。候选集先按
|
|
34
|
+
// type 与 scope 三维等值收窄再比哈希(不是全表扫),并把归档行一并纳入:「出口止体积、
|
|
35
|
+
// 不止重复」,被质量闸归档的同一个事实不在 saveWithDedupe 的候选集里(store.list 默认
|
|
36
|
+
// 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与
|
|
37
|
+
// pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。
|
|
38
|
+
//
|
|
39
|
+
// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话
|
|
40
|
+
// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。
|
|
41
|
+
//
|
|
42
|
+
// 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的
|
|
43
|
+
// deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做
|
|
44
|
+
// 按写入频次增长。
|
|
45
|
+
import { PINNED_MEMORY_TYPES } from "./service.js";
|
|
46
|
+
import { contentHashOf, normalizeForHash } from "./content-hash.js";
|
|
47
|
+
|
|
48
|
+
// 审计口径(llm_audit_logs 的既有列):trigger_source = 组件名,operation_type =
|
|
49
|
+
// 动作名,status='skipped' = 本行没有产生任何 LLM 花费(与 summarize 的间隔门、
|
|
50
|
+
// 尖峰门同款读法)。
|
|
51
|
+
export const ADMISSION_TRIGGER_SOURCE = "writeAdmission";
|
|
52
|
+
export const ADMISSION_OPERATION_TYPE = "write_admission";
|
|
53
|
+
export const ADMISSION_GATE_SESSION_BUDGET = "g1";
|
|
54
|
+
|
|
55
|
+
// 决策两值。阶段一恒 allow;第 1 级判定命中且 enforce 打开时才 deny。
|
|
56
|
+
export const ADMISSION_DECISION_ALLOW = "allow";
|
|
57
|
+
export const ADMISSION_DECISION_DENY = "deny";
|
|
58
|
+
|
|
59
|
+
// 第 1 级的拒绝原因(#254 拍板:硬拒绝只留密钥/PII 与空白噪声;去重键命中归
|
|
60
|
+
// write-update 放行,G1/G2 走二次确认)。reason 是审计行里的稳定键,别当展示文案用。
|
|
61
|
+
export const ADMISSION_DENY_BLANK = "blank";
|
|
62
|
+
export const ADMISSION_DENY_NOISE = "noise";
|
|
63
|
+
export const ADMISSION_DENY_SENSITIVE = "sensitive";
|
|
64
|
+
|
|
65
|
+
// 首次见到某会话时的回填深度:话题表由审计行重建,只回填最近这么多行。更早的
|
|
66
|
+
// 话题会漏(测量口径可接受),换来的是一次查询与有界的常驻内存。
|
|
67
|
+
export const TOPIC_LOOKBACK_ROWS = 200;
|
|
68
|
+
|
|
69
|
+
// 话题锚只收机械可判的两类:issue/PR 引用与文件路径。两条都带前缀或分隔符,误报
|
|
70
|
+
// 面小;自由文本里看不出确定性的「同话题」,硬判只会把误杀面提前引进来(#254 已
|
|
71
|
+
// 定:计量信号用确定性锚,不用相似度阈值——同一件事换个说法就掉下去,不同的事
|
|
72
|
+
// 共享话题词又顶上来,阈值怎么挪都是在两种误判之间来回)。
|
|
73
|
+
// 两处收紧都来自误报样本:`(?<![#\w])` 挡掉 Markdown 标题 `###1` 与紧跟标识符的
|
|
74
|
+
// 井号;扩展名要求首字符是字母,挡掉 `3.5/2.0` 这类比值(否则一个算式就成了话题)。
|
|
75
|
+
const ISSUE_REF = /(?<![#\w])#\d{1,7}\b/g;
|
|
76
|
+
const FILE_PATH = /(?:[\w.-]+[\\/])+[\w.-]+\.[A-Za-z][A-Za-z0-9]{0,5}\b/g;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 写入文本里的确定性话题锚:小写归一 + 路径分隔符统一为 `/` + 去重 + 排序。
|
|
80
|
+
* 同一话题在不同轮次必须逐字节相等,才能用等值比较而不是相似度比较——所以
|
|
81
|
+
* `src\service.js` 与 `src/service.js` 必须归到同一个锚。
|
|
82
|
+
* @param {{title?: string, content?: string, tags?: string[]}} memory
|
|
83
|
+
* @returns {string[]} 排序后的话题锚
|
|
84
|
+
*/
|
|
85
|
+
export function extractTopicKeys(memory) {
|
|
86
|
+
const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])]
|
|
87
|
+
.filter((s) => typeof s === "string")
|
|
88
|
+
.join("\n");
|
|
89
|
+
const keys = new Set();
|
|
90
|
+
const add = (raw) => keys.add(raw.toLowerCase().replace(/\\/g, "/"));
|
|
91
|
+
for (const m of text.matchAll(ISSUE_REF)) add(m[0]);
|
|
92
|
+
for (const m of text.matchAll(FILE_PATH)) add(m[0]);
|
|
93
|
+
return [...keys].sort();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 第 1 级「空白 / 纯噪声」判据(#254,零 LLM、纯函数、无状态)。
|
|
98
|
+
*
|
|
99
|
+
* 为什么这两类够格进硬拒:它们的命中面**没有解释空间**——一条归一化后什么都不剩的
|
|
100
|
+
* 写入,进库之后既检索不到也注入不了,写它只有成本没有收益,不存在「误杀有价值内容」
|
|
101
|
+
* 的可能。这正是硬拒与二次确认的分界:有解释空间的(去重命中、预算/冷却)走二次确认,
|
|
102
|
+
* 没有解释空间的才硬拒。
|
|
103
|
+
*
|
|
104
|
+
* 为什么复用 content-hash 的归一化而不另写一套:判据要的是「有没有信息」,而
|
|
105
|
+
* normalizeForHash 的口径(NFKC → 小写 → 去标点 → 空白折叠)恰好就是「把格式折掉之后
|
|
106
|
+
* 还剩什么」。另写一套只会多出一个会漂移的口径(同 content-hash.js 文件头的存量重算
|
|
107
|
+
* 理由)。两个 reason 分开报是为了可解释:`blank` 是真的什么都没写(提交一个空表单),
|
|
108
|
+
* `noise` 是写了但只有标点/空白(`...`、`---`)——两者的修法不同。
|
|
109
|
+
*
|
|
110
|
+
* 为什么不用 quality-filter 的 `repetitive`(dedupRatio < 0.3)一起判:那是**写入后**
|
|
111
|
+
* 的评分扣分项,扣分可以错(只影响排序与归档),硬拒不能错。一条字符多样性低的正常
|
|
112
|
+
* 记忆(同一种分隔符排出的长清单)会被它判成 repetitive,本判据不背这个误杀面。
|
|
113
|
+
*
|
|
114
|
+
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
115
|
+
* @returns {"blank"|"noise"|null} 命中即返回 reason,否则 null
|
|
116
|
+
*/
|
|
117
|
+
export function informationlessReason(memory) {
|
|
118
|
+
const title = String(memory?.title ?? "").trim();
|
|
119
|
+
const content = String(memory?.content ?? "").trim();
|
|
120
|
+
if (!title && !content) return ADMISSION_DENY_BLANK;
|
|
121
|
+
if (!normalizeForHash(title) && !normalizeForHash(content)) return ADMISSION_DENY_NOISE;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 写入准入(#254)。返回的两个方法就是三个消费方共用的那份接口:
|
|
127
|
+
* evaluate 给出决策形状,record 把测量点/拒绝面写成审计行。
|
|
128
|
+
* #249 的注入提示与 #275 的水位计数都读同一份 decision/审计行,而不是各写一套判定。
|
|
129
|
+
*
|
|
130
|
+
* 两个开关(config.writeAdmission,都默认关——验收第 1 条「默认路径零行为变化」):
|
|
131
|
+
* enabled — 打开第 1 级判定(空白/噪声 + 注入进来的密钥/PII 判据)。
|
|
132
|
+
* 关时 evaluate 的返回与阶段一逐字段一致,只算三个测量点。
|
|
133
|
+
* enforce — 命中时真的拒绝。关时判定照跑、照留审计,但决策回落 allow(仅告警)。
|
|
134
|
+
* 密钥/PII 那一类判据不在这里实现:按 #254 验收第 4 条,它是 #164 A2 的判据来源,
|
|
135
|
+
* 由 `sensitiveScan` 注入(见下),本模块只定义它怎么被消费。
|
|
136
|
+
*
|
|
137
|
+
* @param {object} deps
|
|
138
|
+
* @param {object} deps.store 存储层(listLlmAudits / saveLlmAudit)
|
|
139
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled、writeAdmission.*)
|
|
140
|
+
* @param {object} [deps.logger]
|
|
141
|
+
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
142
|
+
* @param {(memory: object) => ({reason: string, kind?: string, label?: string}|null)} [deps.sensitiveScan]
|
|
143
|
+
* 第 1 级的密钥/PII 判据(#164 A2 的落点)。约定:命中返回 `{kind, label}`,未命中
|
|
144
|
+
* 返回 null,**不抛**(抛出按未命中处理并 warn,判据故障不能让写入变成不可用)。
|
|
145
|
+
* 缺省时这一类判据整个不参与——本模块在 A2 落地前只跑空白/噪声。
|
|
146
|
+
*/
|
|
147
|
+
export function createWriteAdmission({ store, config, logger, now = Date.now, sensitiveScan = null } = {}) {
|
|
148
|
+
// sessionKey → Map<话题锚, 最近一次新建行时刻(ms)>。真相源是审计行,这里只是它的
|
|
149
|
+
// 增量视图:进程重启后按需回填。FIFO 上限防长驻进程内存无界(同 store.js 的
|
|
150
|
+
// embedding 解析缓存口径)。
|
|
151
|
+
const topicTables = new Map();
|
|
152
|
+
const SESSION_CACHE_MAX = 200;
|
|
153
|
+
|
|
154
|
+
const warn = (msg) => {
|
|
155
|
+
// 计量是旁路:任何一环失败都不能影响写入,日志自身故障也一样。
|
|
156
|
+
try {
|
|
157
|
+
logger?.warn?.(msg);
|
|
158
|
+
} catch { /* ignore */ }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** 某会话的话题表(首见时从审计行回填,之后走内存增量)。 */
|
|
162
|
+
function topicTable(sessionKey) {
|
|
163
|
+
const cached = topicTables.get(sessionKey);
|
|
164
|
+
if (cached) return cached;
|
|
165
|
+
const table = new Map();
|
|
166
|
+
try {
|
|
167
|
+
const rows = store?.listLlmAudits?.({ sessionKey, limit: TOPIC_LOOKBACK_ROWS }) ?? [];
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const metadata = row?.metadata;
|
|
170
|
+
if (!metadata || typeof metadata !== "object" || !Array.isArray(metadata.topics)) continue;
|
|
171
|
+
// 穿透行(pinned)整个不在闸门里,也就不该进基准表——只挡内存路径不挡回填,
|
|
172
|
+
// 重启后口径就变了(同一个话题会因为一条穿透行而被判成重复)。
|
|
173
|
+
if (metadata.exempt) continue;
|
|
174
|
+
// 被第 1 级拦下的行同理:它根本没进库。内存路径已经跳过了(见 record 的
|
|
175
|
+
// enforced 判断),回填路径必须同口径——否则同一条时间线会随进程重启而变:
|
|
176
|
+
// 重启前不重复、重启后变重复。仅告警档(deny 非空但 decision=allow)要留,
|
|
177
|
+
// 那条行确实落库了。
|
|
178
|
+
if (metadata.deny?.enforced === true) continue;
|
|
179
|
+
const at = Date.parse(row.timestamp);
|
|
180
|
+
// 坏时间戳跳过:落到 0 会让 gap 变成几十年,一个离群值就能带偏按分位定的 X。
|
|
181
|
+
if (!Number.isFinite(at)) continue;
|
|
182
|
+
for (const topic of metadata.topics) {
|
|
183
|
+
// 审计行按时间倒序返回,先到的那条就是该话题最近一次写入——后来的不覆盖。
|
|
184
|
+
if (!table.has(topic)) table.set(topic, at);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// 回填失败降级为「本会话此前的话题不可见」:漏一个 g2 测量点,不影响写入。
|
|
189
|
+
warn(`[dsh-mneme] write admission topic backfill failed: ${String(e)}`);
|
|
190
|
+
}
|
|
191
|
+
if (topicTables.size >= SESSION_CACHE_MAX && !topicTables.has(sessionKey)) {
|
|
192
|
+
topicTables.delete(topicTables.keys().next().value);
|
|
193
|
+
}
|
|
194
|
+
topicTables.set(sessionKey, table);
|
|
195
|
+
return table;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 内容哈希候选集查询(#254 第三类测量点)。失败降级为「没命中」:漏一个测量点,
|
|
200
|
+
* 不影响写入,也不让计量反噬。
|
|
201
|
+
* 多条命中时的取舍(活区优先):有活跃/未遗忘的命中就报它(判据是「去重候选集本该
|
|
202
|
+
* 拦住」),只有归档/遗忘行命中才报它们。store 侧已把活跃行排在窗口头部,这里的 find
|
|
203
|
+
* 是防御性重复:排序口径将来再变,本判据也不跟着变。
|
|
204
|
+
* archived 与 forgotten 分开回:两者都是「出口」,但穿透含义不同——只命中已遗忘行时
|
|
205
|
+
* 若只回 archived=false,读审计的人会以为库里有活跃重复;而 saveWithDedupe 的候选集
|
|
206
|
+
* 本来就排除遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。
|
|
207
|
+
* @returns {{memory_id: string, archived: boolean, forgotten: boolean}|null}
|
|
208
|
+
*/
|
|
209
|
+
function lookupContentDup(memory) {
|
|
210
|
+
try {
|
|
211
|
+
const hash = contentHashOf(memory);
|
|
212
|
+
if (!hash) return null;
|
|
213
|
+
const hits = store?.findContentHashMatches?.({
|
|
214
|
+
type: memory?.type,
|
|
215
|
+
hash,
|
|
216
|
+
agent_scope: memory?.agent_scope,
|
|
217
|
+
workspace_scope: memory?.workspace_scope,
|
|
218
|
+
sensitivity: memory?.sensitivity
|
|
219
|
+
}) ?? [];
|
|
220
|
+
const hit = hits.find((h) => !h.archived && !h.forgotten) ?? hits[0];
|
|
221
|
+
return hit ? { memory_id: hit.id, archived: hit.archived === true, forgotten: hit.forgotten === true } : null;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
warn(`[dsh-mneme] write admission content hash lookup failed: ${String(e)}`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 第 1 级判据的汇总口(#254):空白 / 噪声 + 注入进来的密钥 / PII。
|
|
230
|
+
* 顺序按成本排:字符串判据在前,注入的扫描器在后;扫描器抛异常按「未命中」处理
|
|
231
|
+
* (warn)——判据故障不能让所有写入变成不可用,这是与计量同一条不反噬原则。
|
|
232
|
+
* @returns {{reason: string, kind?: string, label?: string}|null}
|
|
233
|
+
*/
|
|
234
|
+
function firstLevelHit(memory) {
|
|
235
|
+
const info = informationlessReason(memory);
|
|
236
|
+
if (info) return { reason: info };
|
|
237
|
+
if (typeof sensitiveScan !== "function") return null;
|
|
238
|
+
try {
|
|
239
|
+
const hit = sensitiveScan(memory);
|
|
240
|
+
if (!hit) return null;
|
|
241
|
+
const deny = { reason: ADMISSION_DENY_SENSITIVE };
|
|
242
|
+
// kind / label 只在判据真给了才落盘:给 undefined 会让审计行里出现一个
|
|
243
|
+
// 存在但无值的键,读的人分不清「没报」和「报了空串」。
|
|
244
|
+
if (hit.kind !== undefined) deny.kind = hit.kind;
|
|
245
|
+
if (hit.label !== undefined) deny.label = hit.label;
|
|
246
|
+
return deny;
|
|
247
|
+
} catch (e) {
|
|
248
|
+
warn(`[dsh-mneme] write admission sensitive scan failed: ${String(e)}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 决策形状一次定死,后续只加分支、不改字段:
|
|
255
|
+
* decision — "allow" | "deny"
|
|
256
|
+
* gate — "g1" | null(null = 这次写入不进预算,不记行)
|
|
257
|
+
* topics — 本次写入的确定性话题锚
|
|
258
|
+
* repeat — {topic, gapMs} | null(g2 的命中面)
|
|
259
|
+
* dup — {memory_id, archived, forgotten} | null(内容哈希的命中面)
|
|
260
|
+
* exempt — null | "pinned"
|
|
261
|
+
* deny — null | {reason, kind?, label?}(第 1 级命中面)
|
|
262
|
+
*
|
|
263
|
+
* deny 非空但 decision 仍是 allow = 仅告警档(config.writeAdmission.enforce 关):
|
|
264
|
+
* 判定照跑、审计照留,写入不拦。读审计时「deny 非空且 decision=allow」就是这一档的
|
|
265
|
+
* 指纹,不需要再读配置才能解释一行。
|
|
266
|
+
*
|
|
267
|
+
* 第 1 级判定放在 pinned 豁免之前:pinned 豁免的是**预算与冷却**(constraint /
|
|
268
|
+
* preference 是 #249 第一批立的逐字保真池,不该被会话预算拦),不是「这条能不能落库」。
|
|
269
|
+
* 一条写着私钥的 constraint 仍然是私钥。
|
|
270
|
+
* @param {{memory: object, sessionKey?: string|null}} input
|
|
271
|
+
*/
|
|
272
|
+
function evaluate({ memory, sessionKey } = {}) {
|
|
273
|
+
const verdict = {
|
|
274
|
+
decision: ADMISSION_DECISION_ALLOW,
|
|
275
|
+
gate: null,
|
|
276
|
+
topics: [],
|
|
277
|
+
repeat: null,
|
|
278
|
+
dup: null,
|
|
279
|
+
exempt: null,
|
|
280
|
+
deny: null
|
|
281
|
+
};
|
|
282
|
+
// 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。
|
|
283
|
+
// 第 1 级判定同样在这里之外:#164 A2 定的判据面含 autoSummarize / dream 输出,
|
|
284
|
+
// 那三处的接线是 A2 的落地范围(本模块跑的是会话内的 memory_save 路径)。
|
|
285
|
+
if (!sessionKey) return verdict;
|
|
286
|
+
// 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。
|
|
287
|
+
if (config?.llmAudit?.enabled === false) return verdict;
|
|
288
|
+
|
|
289
|
+
const hit = config?.writeAdmission?.enabled === true ? firstLevelHit(memory) : null;
|
|
290
|
+
const judged = hit ? { ...verdict, deny: hit } : verdict;
|
|
291
|
+
|
|
292
|
+
// 命中且 enforce:决策已定,不再发候选集查询、不再比较话题(纯白花成本)。
|
|
293
|
+
if (hit && config?.writeAdmission?.enforce === true) {
|
|
294
|
+
return {
|
|
295
|
+
...judged,
|
|
296
|
+
decision: ADMISSION_DECISION_DENY,
|
|
297
|
+
gate: ADMISSION_GATE_SESSION_BUDGET,
|
|
298
|
+
topics: extractTopicKeys(memory)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const topics = extractTopicKeys(memory);
|
|
303
|
+
const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? ""));
|
|
304
|
+
if (pinned) return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" };
|
|
305
|
+
const dup = lookupContentDup(memory);
|
|
306
|
+
const table = topicTable(sessionKey);
|
|
307
|
+
const at = now();
|
|
308
|
+
let repeat = null;
|
|
309
|
+
for (const topic of topics) {
|
|
310
|
+
if (!table.has(topic)) continue;
|
|
311
|
+
const gapMs = Math.max(0, at - table.get(topic));
|
|
312
|
+
// 同一行可能命中多个锚:取最近的那次(间隔最短=最有价值的那次重复)。
|
|
313
|
+
if (!repeat || gapMs < repeat.gapMs) repeat = { topic, gapMs };
|
|
314
|
+
}
|
|
315
|
+
return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, repeat, dup };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 把测量点与拒绝面落成审计行(一行一个新建行 / 一次被拒的写入)。四个信号的读法:
|
|
320
|
+
* g1 会话写入预算:`GROUP BY session_key` 计数即得「会话内新建条数」分布;要剔
|
|
321
|
+
* 掉穿透行(pinned 不进预算)就加 `json_extract(metadata,'$.exempt') IS NULL`。
|
|
322
|
+
* g2 同话题冷却:`json_extract(metadata,'$.g2.gap_ms')` 即得「同话题新建行间隔」
|
|
323
|
+
* 分布。间隔算的是同一话题两次**新建行**之间——标题命中走并入的那次不在这里
|
|
324
|
+
* (并入正是冷却要做的事,已经做到了)。
|
|
325
|
+
* dup 内容哈希:`json_extract(metadata,'$.dup.memory_id')` 非空即「这条新行的内容
|
|
326
|
+
* 与某条既有行归一化后逐字节相同」;`$.dup.archived` 区分命中那条在活区还是
|
|
327
|
+
* 归档区——归档区命中就是去重候选集漏掉的那一类穿透。
|
|
328
|
+
* deny 第 1 级命中:#254 验收第 2 条要的「被拒的写入可解释、不静默丢弃」就落在
|
|
329
|
+
* 这一项。三个键分工——`$.deny.reason` 是稳定判据键(blank / noise / sensitive),
|
|
330
|
+
* `$.deny.kind` 是密钥/PII 的子类,`$.deny.enforced` 区分「真拦下了」与「仅告警」。
|
|
331
|
+
* enforced=false 时这一行同时也是一个正常的 g1 行(写入确实发生了)。
|
|
332
|
+
* 计量绝不影响写入:任何异常只 warn。
|
|
333
|
+
* @returns {object[]} 落下的审计行(无测量点或失败时为空数组)
|
|
334
|
+
*/
|
|
335
|
+
function record({ sessionKey, verdict, memoryId } = {}) {
|
|
336
|
+
if (!sessionKey || !verdict || verdict.gate == null) return [];
|
|
337
|
+
const at = now();
|
|
338
|
+
const topics = Array.isArray(verdict.topics) ? verdict.topics : [];
|
|
339
|
+
const enforced = verdict.decision === ADMISSION_DECISION_DENY;
|
|
340
|
+
const rows = [];
|
|
341
|
+
try {
|
|
342
|
+
rows.push(store.saveLlmAudit({
|
|
343
|
+
timestamp: new Date(at).toISOString(),
|
|
344
|
+
trigger_source: ADMISSION_TRIGGER_SOURCE,
|
|
345
|
+
operation_type: ADMISSION_OPERATION_TYPE,
|
|
346
|
+
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得被当成某条路由的
|
|
347
|
+
// 花费来源统计进去。
|
|
348
|
+
model_id: "-",
|
|
349
|
+
status: "skipped",
|
|
350
|
+
related_memory_ids: memoryId ? [memoryId] : [],
|
|
351
|
+
session_key: sessionKey,
|
|
352
|
+
metadata: {
|
|
353
|
+
gate: verdict.gate,
|
|
354
|
+
topics,
|
|
355
|
+
...(verdict.exempt ? { exempt: verdict.exempt } : {}),
|
|
356
|
+
...(verdict.repeat ? { g2: { topic: verdict.repeat.topic, gap_ms: verdict.repeat.gapMs } } : {}),
|
|
357
|
+
// dup 两个出口分字段落盘:archived 与 forgotten 是两类不同的穿透,合成一个布尔
|
|
358
|
+
// 会让「只剩遗忘行命中」读起来像「库里有活跃重复」(见 lookupContentDup 注释)。
|
|
359
|
+
...(verdict.dup
|
|
360
|
+
? { dup: {
|
|
361
|
+
memory_id: verdict.dup.memory_id,
|
|
362
|
+
archived: verdict.dup.archived === true,
|
|
363
|
+
forgotten: verdict.dup.forgotten === true
|
|
364
|
+
} }
|
|
365
|
+
: {}),
|
|
366
|
+
// enforced 由 decision 推出而不是另存一个入参:两者若各存一份,将来就会出现
|
|
367
|
+
// 「decision=deny 但 enforced=false」这种自相矛盾的行。
|
|
368
|
+
//
|
|
369
|
+
// decision 只在真有拒绝面时落盘:两个开关都关时,这条 metadata 必须与只计量
|
|
370
|
+
// 那一阶段逐字节一致(#254 验收第 1 条)。没有 deny 的行本来就默认是放行,
|
|
371
|
+
// 写一个恒为 "allow" 的键只是把「默认路径零行为变化」变成需要解释的事。
|
|
372
|
+
// 读法:有 deny 才有 decision,deny 非空且 decision=allow 就是仅告警档。
|
|
373
|
+
...(verdict.deny
|
|
374
|
+
? {
|
|
375
|
+
decision: verdict.decision,
|
|
376
|
+
deny: {
|
|
377
|
+
reason: verdict.deny.reason,
|
|
378
|
+
...(verdict.deny.kind !== undefined ? { kind: verdict.deny.kind } : {}),
|
|
379
|
+
...(verdict.deny.label !== undefined ? { label: verdict.deny.label } : {}),
|
|
380
|
+
enforced
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
: {})
|
|
384
|
+
}
|
|
385
|
+
}));
|
|
386
|
+
} catch (e) {
|
|
387
|
+
// 审计失败只 warn(同 saveLlmAudit 的调用惯例),写入本身照常完成。
|
|
388
|
+
warn(`[dsh-mneme] write admission audit failed: ${String(e)}`);
|
|
389
|
+
}
|
|
390
|
+
// 只有真正落库、且不是穿透口的行才推进话题表:审计写失败时内存视图不能跑在审计
|
|
391
|
+
// 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。被拦下的写入
|
|
392
|
+
// 同样不推进——它没进库,成为下一次 g2 的基准就会造出一条不存在的时间线。
|
|
393
|
+
// 仅告警档(deny 非空但 decision=allow)**要**推进:那条行确实落库了。
|
|
394
|
+
if (rows.length > 0 && !verdict.exempt && !enforced) {
|
|
395
|
+
const table = topicTable(sessionKey);
|
|
396
|
+
for (const topic of topics) table.set(topic, at);
|
|
397
|
+
}
|
|
398
|
+
return rows;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { evaluate, record };
|
|
402
|
+
}
|