@a9i5k4/dsh-auto-memory 3.0.1 → 3.1.1
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 +13 -8
- package/README.zh-CN.md +13 -8
- package/docs/HANDBOOK.md +92 -52
- package/docs/USER-GUIDE.en.md +9 -9
- package/docs/USER-GUIDE.zh-CN.md +9 -9
- package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
- package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
- package/lib/activation-host.js +6 -1
- package/lib/client.js +1376 -255
- package/lib/config-io.js +59 -6
- package/lib/context-host.js +17 -2
- package/lib/episodic-store.js +90 -16
- package/lib/evidence-store.js +27 -1
- package/lib/fact-store.js +463 -41
- package/lib/hub-io.js +217 -0
- package/lib/index.js +904 -87
- package/lib/intent-clean-safe.js +1 -1
- package/lib/jsonl-tail-cursor.js +75 -0
- package/lib/m7-index-sync-host.js +4 -6
- package/lib/memory-hub.js +62 -7
- package/lib/migrate-pack.js +351 -0
- package/lib/note-status.js +9 -1
- package/lib/procedure-store.js +252 -31
- package/lib/procedure-switch.js +38 -0
- package/lib/python-setup.js +109 -27
- package/lib/python-sidecar-client.js +285 -8
- package/lib/recall-stats.js +242 -0
- package/lib/rules-layer.js +106 -15
- package/lib/semantic-js.js +25 -7
- package/lib/shadow-host.js +41 -2
- package/package.json +6 -2
- package/docs/internal/ACCEPT-35-LIVE.md +0 -143
- package/docs/internal/ACCEPTANCE-20260914.md +0 -90
- package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
- package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
- package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
- package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
- package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
- package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
- package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
- package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
- package/docs/internal/FEATURE-INVENTORY.md +0 -531
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
- package/docs/internal/G3-DESIGN-20260918.md +0 -82
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
- package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
- package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
- package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
- package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
- package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
- package/docs/internal/KICKOFF-P0.md +0 -254
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
- package/docs/internal/MASTER-PLAN-3.0.md +0 -411
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
- package/docs/internal/NEXT-VERSION-TODO.md +0 -95
- package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
- package/docs/internal/PENDING-FIXES-20260916.md +0 -289
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
- package/docs/internal/PROGRESS-20260917.md +0 -93
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
- package/docs/internal/RELEASE-PROCESS.md +0 -99
- package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
- package/docs/internal/RESUME-20260918.md +0 -171
- package/docs/internal/RESUME-20260919.md +0 -104
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
- package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
- package/docs/internal/ROADMAP.md +0 -106
- package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
- package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
- package/docs/internal/T6-EXECUTION-20260920.md +0 -130
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
- package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
- package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
- package/docs/internal/TODO-BACKLOG.md +0 -263
- package/docs/internal/TODO-GRAPH.html +0 -715
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
- package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
* 错误 epoch/未知 requestId/重复或过期 response 全部计账丢弃,绝不注入上层。
|
|
10
10
|
* - 四种身份不混用:requestId(transport)/observationId(M5)/activationId(M6)/syncId(index)。
|
|
11
11
|
* - timeout/AbortSignal(cancel 通知)/latest-wins(由上层 M5 bridge 驱动)/crash recovery/circuit breaker。
|
|
12
|
-
*
|
|
12
|
+
* - ★ #107 看门狗:连续超时(心跳缺失)达阈值 ⇒ **kill 旧进程 + 下次请求重生新进程**。旧实现
|
|
13
|
+
* 超时只发 cancel 通知、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个请求各自等到超时。
|
|
14
|
+
* stats 把「看门狗主动击杀」与「崩溃退出」分开记;熔断只由**终态成功**清零,段内进度帧
|
|
15
|
+
* (index_sync_begin / index_sync_page)的 ack 成功不清零 —— 否则每个 Segment 一次 begin 就把
|
|
16
|
+
* 累计失败抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
|
|
17
|
+
* - ★ #108 stderr 可见:exit/fatal 分支把 stderr 尾部**原样**落一条 diag(同一次退出只记一条,
|
|
18
|
+
* 按行与字符双重截断防刷屏),并把截断后文本暴露为 stats.lastStderrTail /
|
|
19
|
+
* debugView().lastStderrTail;同时保留 stderrTailBytes 以区分「确实没输出」与「输出被截」。
|
|
20
|
+
* 无 shell;无 HTTP;stdout 只进协议解析器;stderr 有界诊断 + 有界 diag 环形缓冲(不再只留字节数)。
|
|
21
|
+
* ★ 影响面纪律:本模块是「JS 语义默认 / Python 发烧友可选」里**Python 那一侧**的传输层,不参与
|
|
22
|
+
* JS 语义路径;下述改动只影响 Python 档自身的行为,不改变 JS 端任何行为,也不让 python 侧的
|
|
23
|
+
* 存在或健康度成为 JS 语义生效的前提(未启动时零进程零 IO,调用方按需 lazy start)。
|
|
24
|
+
* UTF-8 无 BOM。
|
|
13
25
|
*/
|
|
14
26
|
import { spawn } from 'node:child_process'
|
|
15
27
|
import { randomBytes } from 'node:crypto'
|
|
@@ -22,6 +34,31 @@ import {
|
|
|
22
34
|
|
|
23
35
|
const B = M7_TRANSPORT_BUDGET_V1
|
|
24
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 看门狗/诊断策略(**客户端本地策略量**,刻意不放进 M7_TRANSPORT_BUDGET_V1)。
|
|
39
|
+
* 理由有二:①传输预算按契约冻结、变更须升协议版本,而「多久判挂死」是客户端自愈策略、不是 wire 契约;
|
|
40
|
+
* ②wire 文件同期另有 PR(#122/#124)在改,本任务不去碰它以避开写域冲突。
|
|
41
|
+
*/
|
|
42
|
+
const SIDECAR_WATCHDOG_V1 = Object.freeze({
|
|
43
|
+
stallThreshold: 3, // 连续超时(心跳缺失)达此数 ⇒ 判定挂死,看门狗击杀旧进程
|
|
44
|
+
stderrTailChars: 4096, // stderr 环形尾部保留长度(既有语义,原为字面量 4096)
|
|
45
|
+
diagRingCapacity: 16, // diag 环形缓冲条数(超出丢最旧)
|
|
46
|
+
diagTailLines: 8, // 单条 diag 最多保留的 stderr 行数(防刷屏)
|
|
47
|
+
diagTailChars: 1200, // 单条 diag 最多保留的 stderr 字符数
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** 退出归因优先级:高优先级不被低优先级覆盖。 */
|
|
51
|
+
const EXIT_KIND_RANK_V1 = Object.freeze({ crash: 0, restart: 1, dispose: 1, fatal: 2, watchdog: 3 })
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ★ #107 熔断清零口径:只有「终态成功」才把累计失败清零。
|
|
55
|
+
* index_sync_begin / index_sync_page 是**段内进度帧**(每个 Segment 都会 begin 一次),它们的 ack
|
|
56
|
+
* 成功只证明 worker 读到并回了一行,不能证明它健康。旧实现无差别清零 ⇒ 每个 Segment 的 begin 一经
|
|
57
|
+
* 成功就把累计失败抹平,峰值恒 1、阈值 3(breakerFailureThreshold)永不达,熔断形同虚设。
|
|
58
|
+
* health / context_push / index_sync_commit 才是各自工作单元的终态应答。
|
|
59
|
+
*/
|
|
60
|
+
const TERMINAL_SUCCESS_TYPES_V1 = new Set(['health', 'context_push', 'index_sync_commit'])
|
|
61
|
+
|
|
25
62
|
/** 捆绑 fake worker 的默认绝对路径(python/worker_v1.py)。 */
|
|
26
63
|
export function defaultWorkerScriptPathPre() {
|
|
27
64
|
try {
|
|
@@ -53,6 +90,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
53
90
|
const seenActivationIdSet = new Set()
|
|
54
91
|
let writeChain = Promise.resolve()
|
|
55
92
|
let lastSentFrame = null
|
|
93
|
+
// ---- #107 看门狗 / #108 stderr 可见:状态 ----
|
|
94
|
+
let genSeq = 0 // 世系序号发生器
|
|
95
|
+
let generation = 0 // 当前世系号(0 = 从未启动过)
|
|
96
|
+
let consecutiveTimeouts = 0 // 连续超时(心跳缺失)计数
|
|
97
|
+
let halfOpenPending = false // 熔断冷却到期后待用 health() 探活
|
|
98
|
+
let respawnPending = false // 看门狗击杀后待重生(下次 spawn 计一次 watchdog.respawns)
|
|
99
|
+
let lastStderr = { tail: '', bytes: 0, truncated: false, at: 0, generation: 0, totalLines: 0, keptLines: 0 }
|
|
100
|
+
let lastExitDiagKey = '' // 「同一次退出只记一条」去重键 = 世系号 + 退出种类
|
|
101
|
+
let lastExitDiagRef = null // 该条 diag 记录引用(供 exit 之后迟到的 stderr 就地刷新)
|
|
102
|
+
let lastExitGen = 0 // 最近一次退出的世系号
|
|
103
|
+
const genOfProc = new WeakMap() // ChildProcess -> 世系号
|
|
104
|
+
const exitKindByGen = new Map() // 世系号 -> 退出种类(crash/restart/dispose/fatal/watchdog)
|
|
105
|
+
const diagRing = [] // 有界 diag 环形缓冲(给人看的诊断,含中文归因)
|
|
56
106
|
const breaker = { consecutiveFailures: 0, openUntil: 0 }
|
|
57
107
|
const stats = {
|
|
58
108
|
starts: 0, exits: 0, framesIn: 0, requests: 0, succeeded: 0, activationsReceived: 0,
|
|
@@ -60,6 +110,17 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
60
110
|
dropped: { badJson: 0, badEnvelope: 0, staleEpoch: 0, unknownRequest: 0, typeMismatch: 0, duplicateActivation: 0 },
|
|
61
111
|
failed: { timeout: 0, crashed: 0, aborted: 0, circuitOpen: 0, unavailable: 0, backpressure: 0, protocol: 0, workerError: 0, disposed: 0 },
|
|
62
112
|
lastExit: null, lastFatal: null,
|
|
113
|
+
// ★ #107 看门狗:把「主动重启」与「崩溃退出」分开记,不再混在一个 exits 计数里。
|
|
114
|
+
watchdog: { kills: 0, respawns: 0, lastReason: null, lastAt: 0, lastDetail: null },
|
|
115
|
+
exitKinds: { crash: 0, restart: 0, dispose: 0, fatal: 0, watchdog: 0, spawnError: 0 },
|
|
116
|
+
breakerProbes: { attempted: 0, ok: 0, failed: 0 },
|
|
117
|
+
lastExitNote: null,
|
|
118
|
+
// ★ #108 stderr:lastStderrTail 是**截断后可读文本**(不再只有字节数);
|
|
119
|
+
// stderrTailBytes 仍在 debugView 上,用来区分「没输出」与「输出被截」。
|
|
120
|
+
lastStderrTail: '',
|
|
121
|
+
lastStderrTailBytes: 0,
|
|
122
|
+
lastStderrTailTruncated: false,
|
|
123
|
+
lastStderrTailAt: 0,
|
|
63
124
|
}
|
|
64
125
|
|
|
65
126
|
function resolveOpt(k) { return opt(k, null) }
|
|
@@ -75,6 +136,157 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
75
136
|
|
|
76
137
|
function breakerOpen() { return Date.now() < breaker.openUntil }
|
|
77
138
|
|
|
139
|
+
// ============ #107 看门狗 / #108 stderr 可见:诊断与自愈 ============
|
|
140
|
+
// 说明:以下全部只服务 Python 侧车自身的自愈与可观测性,不参与 JS 语义路径。
|
|
141
|
+
|
|
142
|
+
/** 有界 diag 环形缓冲:落一条**给人看**的中文诊断;opts.onDiag 在场时同步外发一份。 */
|
|
143
|
+
function pushDiag(level, text) {
|
|
144
|
+
const rec = { at: Date.now(), level, sidecar: 'python-sidecar-pre', generation, message: String(text) }
|
|
145
|
+
diagRing.push(rec)
|
|
146
|
+
while (diagRing.length > Math.max(1, Number(opt('diagRingCapacity', SIDECAR_WATCHDOG_V1.diagRingCapacity)))) diagRing.shift()
|
|
147
|
+
const sink = resolveOpt('onDiag')
|
|
148
|
+
if (typeof sink === 'function') { try { sink({ at: rec.at, level: rec.level, generation, message: rec.message }) } catch (_) {} }
|
|
149
|
+
return rec
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 把 stderr 尾部整理成可读文本:保留尾 N 行(默认 8)且不超过 M 字符(默认 1200)。
|
|
154
|
+
* 被截断时在正文前留一行说明 —— 避免读者误以为「这就是全部输出」。
|
|
155
|
+
*/
|
|
156
|
+
function tailForDiag(raw) {
|
|
157
|
+
const NL = String.fromCharCode(10)
|
|
158
|
+
const CR = String.fromCharCode(13)
|
|
159
|
+
const src = String(raw == null ? '' : raw)
|
|
160
|
+
const bytes = src.length
|
|
161
|
+
// 刻意不用正则字面量:本仓所有 .js 均为 CRLF,避免任何转义歧义。
|
|
162
|
+
const all = src.split(NL).map((l) => (l.endsWith(CR) ? l.slice(0, -1) : l))
|
|
163
|
+
while (all.length && all[all.length - 1] === '') all.pop()
|
|
164
|
+
const maxLines = Math.max(1, Number(opt('diagTailLines', SIDECAR_WATCHDOG_V1.diagTailLines)))
|
|
165
|
+
const maxChars = Math.max(1, Number(opt('diagTailChars', SIDECAR_WATCHDOG_V1.diagTailChars)))
|
|
166
|
+
const kept = all.slice(-maxLines)
|
|
167
|
+
let truncated = kept.length < all.length
|
|
168
|
+
let text = kept.join(NL)
|
|
169
|
+
if (text.length > maxChars) { text = text.slice(-maxChars); truncated = true }
|
|
170
|
+
const head = truncated ? '…[已按行/字符截断,下面不是全部输出]…' + NL : ''
|
|
171
|
+
return { text: head + text, truncated, keptLines: kept.length, totalLines: all.length, bytes }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 退出归因中文说明(回答「哪一侧车、哪一代、怎么退的、退出码多少」)。 */
|
|
175
|
+
function humanExitNote(gen, kind, code, signalName) {
|
|
176
|
+
const why = kind === 'watchdog' ? '被看门狗主动击杀(连续超时=心跳缺失达阈值,判定挂死)'
|
|
177
|
+
: kind === 'fatal' ? '因协议致命错误被就地击杀(帧纪律违规)'
|
|
178
|
+
: kind === 'restart' ? '被 restart() 主动重启(刻意换代,不是故障)'
|
|
179
|
+
: kind === 'dispose' ? '随 dispose() 收尾关闭(刻意关闭,不是故障)'
|
|
180
|
+
: '自行退出(非看门狗击杀)——即「崩溃退出」'
|
|
181
|
+
const c = (code === null || code === undefined) ? 'null' : String(code)
|
|
182
|
+
const s = signalName ? String(signalName) : 'null'
|
|
183
|
+
return '第 ' + gen + ' 代 Python 语义侧车' + why + ':退出码 code=' + c + ',signal=' + s
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 退出归因写入(优先级 watchdog > fatal > dispose/restart > crash;低优先级不覆盖高优先级)。 */
|
|
187
|
+
function markExitKind(gen, kind) {
|
|
188
|
+
if (!gen) return
|
|
189
|
+
const cur = exitKindByGen.get(gen) || 'crash'
|
|
190
|
+
if ((EXIT_KIND_RANK_V1[kind] || 0) < (EXIT_KIND_RANK_V1[cur] || 0)) return
|
|
191
|
+
exitKindByGen.set(gen, kind)
|
|
192
|
+
while (exitKindByGen.size > 64) exitKindByGen.delete(exitKindByGen.keys().next().value)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ★ #108 关键修复:把 stderr 尾部**原样**落一条 diag。
|
|
197
|
+
* 旧实现只把 4KB 尾部收进 `stderrTail` 变量、对外仅暴露 `stderrTailBytes` 一个整数,
|
|
198
|
+
* **内容没有任何读者** ⇒ Python traceback 永远看不到,用户只能看到「字节数非 0」。
|
|
199
|
+
* 同一次退出只记一条:以「世系号 + 退出种类」为键,命中则**就地刷新**(退出之后 stdio 往往
|
|
200
|
+
* 还在排空,close 晚于 exit、尾字节会迟到;就地刷新既补全内容又不新开第二条)。
|
|
201
|
+
*/
|
|
202
|
+
function logExitDiag(gen, kind, reasonText) {
|
|
203
|
+
const t = tailForDiag(stderrTail)
|
|
204
|
+
lastStderr = { tail: t.text, bytes: t.bytes, truncated: t.truncated, at: Date.now(), generation: gen, totalLines: t.totalLines, keptLines: t.keptLines }
|
|
205
|
+
stats.lastStderrTail = t.text
|
|
206
|
+
stats.lastStderrTailBytes = t.bytes
|
|
207
|
+
stats.lastStderrTailTruncated = t.truncated
|
|
208
|
+
stats.lastStderrTailAt = lastStderr.at
|
|
209
|
+
const note = t.bytes === 0
|
|
210
|
+
? 'stderr 零输出(确实没打印,不是被截断)'
|
|
211
|
+
: (t.truncated
|
|
212
|
+
? 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,此处只保留最后 ' + t.keptLines + ' 行共 ' + t.text.length + ' 字符(已按行与字符双重截断,防刷屏)'
|
|
213
|
+
: 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,已全部保留')
|
|
214
|
+
const NL = String.fromCharCode(10)
|
|
215
|
+
const body = String(reasonText) + ';' + note + (t.text ? NL + '—— stderr 原文 ——' + NL + t.text : '')
|
|
216
|
+
const key = gen + ':' + kind
|
|
217
|
+
const level = (kind === 'crash' || kind === 'watchdog' || kind === 'fatal') ? 'error' : 'info'
|
|
218
|
+
if (lastExitDiagKey === key && lastExitDiagRef) {
|
|
219
|
+
lastExitDiagRef.message = body
|
|
220
|
+
lastExitDiagRef.at = lastStderr.at
|
|
221
|
+
lastExitDiagRef.level = level
|
|
222
|
+
return lastExitDiagRef
|
|
223
|
+
}
|
|
224
|
+
lastExitDiagKey = key
|
|
225
|
+
lastExitGen = gen
|
|
226
|
+
lastExitDiagRef = pushDiag(level, body)
|
|
227
|
+
return lastExitDiagRef
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* ★ #107 看门狗:判定侧车挂死 ⇒ **kill 旧进程 + 下次请求重生新进程**。
|
|
232
|
+
* 旧实现在超时分支只 `notify('cancel')`、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个
|
|
233
|
+
* 请求各自等到自己的超时、再各自发一条无用的 cancel;对用户的表现是「Python 档语义静默失效」。
|
|
234
|
+
* 刻意不在此处立即 spawn:避免在熔断打开期制造进程(既有套件断言「打开期零 spawn」);
|
|
235
|
+
* 重建交给下一次 ensureStarted() —— 那里必然带新 epoch,index 重同步也随之触发。
|
|
236
|
+
*/
|
|
237
|
+
function disarmHungChild(trigger, detail) {
|
|
238
|
+
const proc = child
|
|
239
|
+
consecutiveTimeouts = 0
|
|
240
|
+
if (!proc) return
|
|
241
|
+
markExitKind(generation, 'watchdog')
|
|
242
|
+
child = null
|
|
243
|
+
epoch = null
|
|
244
|
+
buffer = Buffer.alloc(0)
|
|
245
|
+
respawnPending = true
|
|
246
|
+
stats.watchdog.kills++
|
|
247
|
+
stats.watchdog.lastReason = trigger
|
|
248
|
+
stats.watchdog.lastAt = Date.now()
|
|
249
|
+
stats.watchdog.lastDetail = detail || null
|
|
250
|
+
const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
|
|
251
|
+
pushDiag('error', '第 ' + generation + ' 代 Python 语义侧车连续 ' + thr + ' 次心跳缺失(' + trigger + ')⇒ 看门狗主动击杀旧进程;下次请求将重生新进程(新 epoch),未经确认的旧 in-flight 请求按 crashed 结算。')
|
|
252
|
+
logExitDiag(generation, 'watchdog', humanExitNote(generation, 'watchdog', null, null))
|
|
253
|
+
try { proc.stdin.destroy() } catch (_) {}
|
|
254
|
+
try { proc.kill() } catch (_) {}
|
|
255
|
+
rejectAll('crashed')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 心跳缺失证据:只把「超时」算挂死(abort 是调用方主动取消,不构成挂死证据)。 */
|
|
259
|
+
function noteStall(type, requestId, timeoutMs) {
|
|
260
|
+
consecutiveTimeouts++
|
|
261
|
+
const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
|
|
262
|
+
if (consecutiveTimeouts < thr) return
|
|
263
|
+
disarmHungChild('连续超时', { type: String(type), requestId: String(requestId), timeoutMs: Number(timeoutMs), consecutiveTimeouts })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
|
|
268
|
+
* 这是 health() 在仓内的**唯一真实调用点**。type==='health' 不入此分支 ⇒ 不会递归,
|
|
269
|
+
* 也不会把探针自身的失败再算作一次新的挂死证据。
|
|
270
|
+
*/
|
|
271
|
+
async function probeThenSend(type, payload, rOpts) {
|
|
272
|
+
halfOpenPending = false
|
|
273
|
+
stats.breakerProbes.attempted++
|
|
274
|
+
const probeTimeout = Math.min(Math.max(1, Number(opt('requestTimeoutMs', B.requestTimeoutMs))), 2000)
|
|
275
|
+
const probe = await request('health', {}, { timeoutMs: probeTimeout })
|
|
276
|
+
if (probe && probe.ok) {
|
|
277
|
+
stats.breakerProbes.ok++
|
|
278
|
+
breaker.consecutiveFailures = 0
|
|
279
|
+
consecutiveTimeouts = 0
|
|
280
|
+
return request(type, payload, rOpts)
|
|
281
|
+
}
|
|
282
|
+
stats.breakerProbes.failed++
|
|
283
|
+
breaker.openUntil = Date.now() + Number(opt('breakerCooldownMs', B.breakerCooldownMs))
|
|
284
|
+
halfOpenPending = true
|
|
285
|
+
stats.failed.circuitOpen++
|
|
286
|
+
pushDiag('error', '熔断半开探测未通过:Python 语义侧车 health 探针返回 ' + String((probe && probe.code) || 'unknown') + ' ⇒ 重新打开熔断 ' + Number(opt('breakerCooldownMs', B.breakerCooldownMs)) + 'ms;本次请求按 circuit-open 返回。')
|
|
287
|
+
return { ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() }
|
|
288
|
+
}
|
|
289
|
+
|
|
78
290
|
/** lazy start:no-shell spawn;仅由显式启用的调用路径触达。 */
|
|
79
291
|
function ensureStarted() {
|
|
80
292
|
if (disposed) return { ok: false, code: 'disposed' }
|
|
@@ -106,9 +318,16 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
106
318
|
}
|
|
107
319
|
child = proc
|
|
108
320
|
stats.starts++
|
|
321
|
+
generation = ++genSeq
|
|
322
|
+
genOfProc.set(proc, generation)
|
|
323
|
+
exitKindByGen.set(generation, 'crash')
|
|
324
|
+
stderrTail = '' // ★ #108:stderr 尾部按**世系**隔离,避免把上代 traceback 误挂到本代
|
|
325
|
+
consecutiveTimeouts = 0
|
|
326
|
+
if (respawnPending) { stats.watchdog.respawns++; respawnPending = false }
|
|
109
327
|
proc.stdout.on('data', (chunk) => { try { feed(chunk) } catch (_) { fatal('protocol') } })
|
|
110
328
|
proc.stderr.on('data', (chunk) => {
|
|
111
|
-
stderrTail = (stderrTail + chunk.toString('utf8'))
|
|
329
|
+
stderrTail = (stderrTail + chunk.toString('utf8'))
|
|
330
|
+
.slice(-Math.max(256, Number(opt('stderrTailChars', SIDECAR_WATCHDOG_V1.stderrTailChars))))
|
|
112
331
|
})
|
|
113
332
|
proc.on('error', () => {
|
|
114
333
|
// spawn 失败(ENOENT 等):结构化失败,不计崩溃重启
|
|
@@ -120,12 +339,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
120
339
|
noteFailure('unavailable')
|
|
121
340
|
})
|
|
122
341
|
proc.on('exit', (code, signalName) => {
|
|
342
|
+
const gen = genOfProc.get(proc) || 0
|
|
343
|
+
const kind = exitKindByGen.get(gen) || 'crash'
|
|
344
|
+
// ★ #107:无论是否当前世系都先记账 —— 「哪一侧车、第几代、怎么退的、退出码多少」
|
|
345
|
+
// stats.exits 仍是总退出数;active 的归因细分在 stats.exitKinds。
|
|
346
|
+
stats.exits++
|
|
347
|
+
stats.exitKinds[kind] = (stats.exitKinds[kind] || 0) + 1
|
|
348
|
+
stats.lastExit = { code, signal: signalName, generation: gen, kind }
|
|
349
|
+
stats.lastExitNote = humanExitNote(gen, kind, code, signalName)
|
|
350
|
+
logExitDiag(gen, kind, stats.lastExitNote)
|
|
123
351
|
if (child !== proc) return
|
|
124
352
|
child = null
|
|
125
353
|
epoch = null
|
|
126
354
|
buffer = Buffer.alloc(0)
|
|
127
|
-
stats.exits++
|
|
128
|
-
stats.lastExit = { code, signal: signalName }
|
|
129
355
|
rejectAll('crashed')
|
|
130
356
|
})
|
|
131
357
|
return { ok: true }
|
|
@@ -133,13 +359,21 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
133
359
|
|
|
134
360
|
function rejectAll(code) {
|
|
135
361
|
for (const [, entry] of pending) settle(entry, { ok: false, code })
|
|
136
|
-
noteFailure(code)
|
|
362
|
+
// ★ #107:不再在循环外额外 noteFailure(code) —— settle() 已按每个失败请求各计一次,
|
|
363
|
+
// 旧实现在此重复计数 ⇒ 崩溃时挂 K 个请求会被算成 K+1 次失败,熔断阈值提前触发(假熔断)。
|
|
137
364
|
}
|
|
138
365
|
|
|
139
366
|
function fatal(kind) {
|
|
140
367
|
stats.lastFatal = kind
|
|
141
368
|
buffer = Buffer.alloc(0) // 丢弃残留半帧,防止污染重生进程的解析流
|
|
142
|
-
|
|
369
|
+
// ★ #108:致命分支同样把 stderr 尾部原样落一条 diag;与随后 exit 同键(世系号+fatal)
|
|
370
|
+
// ⇒ 同一次退出只记一条。
|
|
371
|
+
logExitDiag(generation, 'fatal', humanExitNote(generation, 'fatal', null, null) + ' 触发原因=' + String(kind))
|
|
372
|
+
if (child) {
|
|
373
|
+
markExitKind(generation, 'fatal')
|
|
374
|
+
try { child.stdin.destroy() } catch (_) {}
|
|
375
|
+
try { child.kill() } catch (_) {}
|
|
376
|
+
}
|
|
143
377
|
rejectAll('protocol')
|
|
144
378
|
}
|
|
145
379
|
|
|
@@ -208,7 +442,17 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
208
442
|
if (entry.timer) clearTimeout(entry.timer)
|
|
209
443
|
if (entry.onAbort) { try { entry.signal.removeEventListener('abort', entry.onAbort) } catch (_) {} }
|
|
210
444
|
pending.delete(entry.requestId)
|
|
211
|
-
if (result.ok) {
|
|
445
|
+
if (result.ok) {
|
|
446
|
+
stats.succeeded++
|
|
447
|
+
// ★ #107 熔断清零口径:只有「终态成功」清零累计失败;段内进度帧(index_sync_begin /
|
|
448
|
+
// index_sync_page)的 ack 成功**不清零** —— 否则每个 Segment 一次 begin 就把累计失败
|
|
449
|
+
// 抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
|
|
450
|
+
if (TERMINAL_SUCCESS_TYPES_V1.has(entry.type)) {
|
|
451
|
+
breaker.consecutiveFailures = 0
|
|
452
|
+
consecutiveTimeouts = 0
|
|
453
|
+
halfOpenPending = false
|
|
454
|
+
}
|
|
455
|
+
}
|
|
212
456
|
else {
|
|
213
457
|
const bucket = stats.failed[result.code]
|
|
214
458
|
if (bucket === undefined) stats.failed.protocol++
|
|
@@ -250,6 +494,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
250
494
|
stats.failed.circuitOpen++
|
|
251
495
|
return Promise.resolve({ ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() })
|
|
252
496
|
}
|
|
497
|
+
// ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
|
|
498
|
+
// 这是 health() 在仓内的**唯一真实调用点** —— 旧实现 health() 全仓零调用,文件里那句
|
|
499
|
+
// 「health 探针(breaker half-open 用;有界响应)」是空头承诺。type==='health' 不入此分支
|
|
500
|
+
// ⇒ 不会递归,也不会把探针本身的失败再算作一次挂死证据。
|
|
501
|
+
if (halfOpenPending && type !== 'health') return probeThenSend(type, payload, rOpts)
|
|
502
|
+
return sendNow(type, payload, rOpts)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 真正的建帧 + 入队。与旧 request() 同体,刻意保持「调用后同步可见 _lastFrameForTest()」
|
|
507
|
+
* 的既有契约(既有套件靠它在 await 之前做帧级断言)。
|
|
508
|
+
*/
|
|
509
|
+
function sendNow(type, payload, rOpts = {}) {
|
|
253
510
|
const started = ensureStarted()
|
|
254
511
|
if (!started.ok) {
|
|
255
512
|
stats.failed[started.code] = (stats.failed[started.code] || 0) + 1
|
|
@@ -262,7 +519,7 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
262
519
|
const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
|
|
263
520
|
if (!mf.ok) { stats.failed.protocol++; return Promise.resolve({ ok: false, code: 'protocol', reason: mf.reason }) }
|
|
264
521
|
return new Promise((resolve) => {
|
|
265
|
-
const entry = { requestId, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
|
|
522
|
+
const entry = { requestId, type, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
|
|
266
523
|
pending.set(requestId, entry)
|
|
267
524
|
stats.requests++
|
|
268
525
|
const written = writeFrame(mf.frame)
|
|
@@ -271,6 +528,9 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
271
528
|
entry.timer = setTimeout(() => {
|
|
272
529
|
settle(entry, { ok: false, code: 'timeout', timeoutMs })
|
|
273
530
|
notify('cancel', { requestId })
|
|
531
|
+
// ★ #107:旧实现到此为止(只发 cancel,不 kill)⇒ 挂死的 worker 无人收尸。
|
|
532
|
+
// 这里补上心跳缺失证据的累计与看门狗收尸(达阈值才动,避免偶发慢帧被误杀)。
|
|
533
|
+
noteStall(type, requestId, timeoutMs)
|
|
274
534
|
}, timeoutMs)
|
|
275
535
|
if (entry.signal) {
|
|
276
536
|
if (entry.signal.aborted) {
|
|
@@ -309,6 +569,10 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
309
569
|
// 旧代残尾会污染新 worker 首帧 ⇒ 该行 badJson ⇒ 请求白等到超时)。
|
|
310
570
|
buffer = Buffer.alloc(0)
|
|
311
571
|
stats.restarts++
|
|
572
|
+
markExitKind(generation, 'restart')
|
|
573
|
+
breaker.consecutiveFailures = 0
|
|
574
|
+
consecutiveTimeouts = 0
|
|
575
|
+
pushDiag('info', 'restart() 主动重启 Python 语义侧车:旧进程已击杀、新 epoch 待下次请求重生(原因:' + String(reason === undefined || reason === null ? '未给' : reason) + ')。')
|
|
312
576
|
void reason
|
|
313
577
|
}
|
|
314
578
|
|
|
@@ -322,7 +586,18 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
322
586
|
epoch: epoch ? epoch.slice(0, 12) + '…' : null,
|
|
323
587
|
pending: pending.size,
|
|
324
588
|
breaker: { open: breakerOpen(), consecutiveFailures: breaker.consecutiveFailures, cooldownMs: Number(opt('breakerCooldownMs', B.breakerCooldownMs)) },
|
|
589
|
+
generation,
|
|
590
|
+
// ★ #108:stderrTailBytes 保留原语义(当前世系累计保留的尾部长度)——0 表示确实没输出,
|
|
591
|
+
// 非 0 且 lastStderrTailTruncated=true 表示有输出但被截断。文本本体见 lastStderrTail。
|
|
325
592
|
stderrTailBytes: stderrTail.length,
|
|
593
|
+
lastStderrTail: lastStderr.tail,
|
|
594
|
+
lastStderrTailBytes: lastStderr.bytes,
|
|
595
|
+
lastStderrTailTruncated: lastStderr.truncated,
|
|
596
|
+
lastStderrTailAt: lastStderr.at,
|
|
597
|
+
consecutiveTimeouts,
|
|
598
|
+
halfOpenProbePending: halfOpenPending,
|
|
599
|
+
watchdog: JSON.parse(JSON.stringify(stats.watchdog)),
|
|
600
|
+
recentDiag: diagRing.slice(-4).map((d) => ({ at: d.at, level: d.level, message: d.message })),
|
|
326
601
|
stats: JSON.parse(JSON.stringify(stats)),
|
|
327
602
|
}
|
|
328
603
|
}
|
|
@@ -330,6 +605,7 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
330
605
|
function dispose(reason) {
|
|
331
606
|
if (disposed) return
|
|
332
607
|
disposed = true
|
|
608
|
+
markExitKind(generation, 'dispose')
|
|
333
609
|
if (child) { try { child.kill() } catch (_) {} }
|
|
334
610
|
child = null
|
|
335
611
|
epoch = null
|
|
@@ -348,5 +624,6 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
348
624
|
onActivation(handler) { activationHandlers.add(handler); return () => activationHandlers.delete(handler) },
|
|
349
625
|
_statsForTest: stats,
|
|
350
626
|
_pendingForTest: pending,
|
|
627
|
+
_diagRingForTest: diagRing,
|
|
351
628
|
}
|
|
352
629
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回统计(recall statistics)—— 只**记录**、不参与排序。
|
|
3
|
+
*
|
|
4
|
+
* **为什么只记录**(用户 2026-09-22 拍板的分步走):
|
|
5
|
+
* 加权是**会自我强化**的机制(召回越多 → 权重越高 → 越容易被召回)。在埋点口径未被真实数据
|
|
6
|
+
* 检验之前就加权,会把口径错误放大成系统性偏差。所以本模块**只累积计数并落盘**,
|
|
7
|
+
* `recall()` 的排序与返回**逐字节不变**(守卫断言:本模块的调用不改任何排序输入)。
|
|
8
|
+
*
|
|
9
|
+
* ★★ 三条通路**分账**,不混为一谈(用户 2026-09-22 明确要求):
|
|
10
|
+
* 同一个「召回」在三条通路里的含义完全不同,合成一个数会误导判断:
|
|
11
|
+
* | channel | 含义 | 语义 | 用来判断什么 |
|
|
12
|
+
* |----------|------------------|----------------------|------------------------|
|
|
13
|
+
* | `model` | 模型主动检索 | 「我明确想找」= 高信号 | 什么内容真的有用、被需要 |
|
|
14
|
+
* | `inject` | 每轮自动注入 | 「系统一直塞给我」= 成本 | 注入预算花在哪、哪段最费 |
|
|
15
|
+
* | `shadow` | 主动唤起(影子) | 「系统觉得该唤起」= 系统判断质量 | 唤起准不准、命中率如何 |
|
|
16
|
+
* ⇒ 若把 inject 与 model 合并,会把「模型懒得检索」误读成「这条不重要」,
|
|
17
|
+
* 而这个误读会被写进加权公式,越滚越偏。故**必须分开存、分开看**。
|
|
18
|
+
*
|
|
19
|
+
* 存储:`~/.dsh/memory/recall-stats.json`(单文件、原子写 tmp→rename、有上限裁剪)。
|
|
20
|
+
* 设计约束:零依赖(只用 node: 内置)+ fail-soft(统计坏了绝不影响召回本身)。
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
export const RECALL_STATS_VERSION_PRE = 'recall_stats_v1'
|
|
26
|
+
|
|
27
|
+
/** 三条通路的稳定 id(写盘/前端都按它索引,禁止改拼写)。 */
|
|
28
|
+
export const RECALL_CHANNELS_PRE = ['model', 'inject', 'shadow']
|
|
29
|
+
|
|
30
|
+
/** 单条记忆的计数上限(超出按 count 升序淘汰最冷门的,防止文件无限增长)。 */
|
|
31
|
+
const MAX_ENTRIES = 2000
|
|
32
|
+
/** 保留天数(按天分布只留最近 N 天)。 */
|
|
33
|
+
const MAX_DAYS = 90
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{file: () => string, now?: () => number}} opts
|
|
37
|
+
* `file()` 每次调用都重新解析路径(DSH_HOME 可能变),必须是函数而非字符串。
|
|
38
|
+
*/
|
|
39
|
+
export function createRecallStatsPre(opts = {}) {
|
|
40
|
+
const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
|
|
41
|
+
const fileOf = typeof opts.file === 'function' ? opts.file : () => ''
|
|
42
|
+
let state = null
|
|
43
|
+
let dirty = false
|
|
44
|
+
|
|
45
|
+
const dayOf = (ms) => {
|
|
46
|
+
const d = new Date(ms)
|
|
47
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
48
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
49
|
+
}
|
|
50
|
+
/** 一条通路的空白账本。三条各自独立,互不汇总(汇总只发生在 snapshot 的展示层)。 */
|
|
51
|
+
const emptyChannel = () => ({
|
|
52
|
+
events: 0, // 这条通路发生了多少次(检索次数 / 注入轮数 / 唤起次数)
|
|
53
|
+
hits: 0, // 命中条目总数(inject 通道为「注入的条目数」)
|
|
54
|
+
zeroHit: 0, // 零命中次数(model 通道专有语义;其它通道记为 0)
|
|
55
|
+
ids: {}, // id → { count, first, last, layer, score, reason }
|
|
56
|
+
byLayer: {}, // layer → count
|
|
57
|
+
byDay: {}, // yyyy-mm-dd → count
|
|
58
|
+
lastAt: 0,
|
|
59
|
+
})
|
|
60
|
+
const empty = () => ({
|
|
61
|
+
version: RECALL_STATS_VERSION_PRE,
|
|
62
|
+
since: nowFn(),
|
|
63
|
+
channels: { model: emptyChannel(), inject: emptyChannel(), shadow: emptyChannel() },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** 兼容旧结构(v1 早期是扁平 queries/hits/byLayer…)→ 迁到 channels.model,不丢数据。 */
|
|
67
|
+
function migrate(d) {
|
|
68
|
+
if (d && d.channels && typeof d.channels === 'object') {
|
|
69
|
+
const base = empty()
|
|
70
|
+
for (const ch of RECALL_CHANNELS_PRE) {
|
|
71
|
+
base.channels[ch] = Object.assign(emptyChannel(), (d.channels && d.channels[ch]) || {})
|
|
72
|
+
}
|
|
73
|
+
base.since = d.since || base.since
|
|
74
|
+
return base
|
|
75
|
+
}
|
|
76
|
+
const base = empty()
|
|
77
|
+
const m = base.channels.model
|
|
78
|
+
m.events = Number(d.queries) || 0
|
|
79
|
+
m.zeroHit = Number(d.zeroHit) || 0
|
|
80
|
+
m.ids = (d.hits && typeof d.hits === 'object') ? d.hits : {}
|
|
81
|
+
m.byLayer = (d.byLayer && typeof d.byLayer === 'object') ? d.byLayer : {}
|
|
82
|
+
m.byDay = (d.byDay && typeof d.byDay === 'object') ? d.byDay : {}
|
|
83
|
+
m.hits = Object.keys(m.ids).reduce((n, k) => n + ((m.ids[k] && m.ids[k].count) || 0), 0)
|
|
84
|
+
m.lastAt = Number(d.lastQueryAt) || 0
|
|
85
|
+
base.since = d.since || base.since
|
|
86
|
+
return base
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function load() {
|
|
90
|
+
if (state) return state
|
|
91
|
+
state = empty()
|
|
92
|
+
try {
|
|
93
|
+
const f = fileOf()
|
|
94
|
+
if (f && existsSync(f)) {
|
|
95
|
+
const d = JSON.parse(readFileSync(f, 'utf8'))
|
|
96
|
+
if (d && typeof d === 'object' && d.version === RECALL_STATS_VERSION_PRE) {
|
|
97
|
+
state = migrate(d)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (_) { state = empty() } // 坏文件不阻塞:从零开始,下一轮覆盖写回
|
|
101
|
+
return state
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function save() {
|
|
105
|
+
if (!dirty) return false
|
|
106
|
+
try {
|
|
107
|
+
const f = fileOf()
|
|
108
|
+
if (!f) return false
|
|
109
|
+
mkdirSync(path.dirname(f), { recursive: true })
|
|
110
|
+
const tmp = f + '.tmp'
|
|
111
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf8')
|
|
112
|
+
renameSync(tmp, f) // 同目录原子替换
|
|
113
|
+
dirty = false
|
|
114
|
+
return true
|
|
115
|
+
} catch (_) {
|
|
116
|
+
try { if (fileOf()) rmSync(fileOf() + '.tmp', { force: true }) } catch (_) {}
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 裁剪:**每通道**条目数上限 + 天数上限。按 count 升序淘汰最冷门的。 */
|
|
122
|
+
function prune() {
|
|
123
|
+
for (const ch of RECALL_CHANNELS_PRE) {
|
|
124
|
+
const c = state.channels[ch]
|
|
125
|
+
if (!c) continue
|
|
126
|
+
const ids = Object.keys(c.ids)
|
|
127
|
+
if (ids.length > MAX_ENTRIES) {
|
|
128
|
+
ids.sort((a, b) => (c.ids[a].count || 0) - (c.ids[b].count || 0))
|
|
129
|
+
for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete c.ids[id]
|
|
130
|
+
}
|
|
131
|
+
const days = Object.keys(c.byDay)
|
|
132
|
+
if (days.length > MAX_DAYS) {
|
|
133
|
+
days.sort()
|
|
134
|
+
for (const d of days.slice(0, days.length - MAX_DAYS)) delete c.byDay[d]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 记一次事件。**只读入参、绝不修改** —— 调用方传的 hits/顺序不因本调用而变。
|
|
141
|
+
*
|
|
142
|
+
* ★★ channel 必须显式给:`model`(模型主动检索)/ `inject`(每轮自动注入)/ `shadow`(主动唤起)。
|
|
143
|
+
* 缺省回落到 `model` 只是为了兼容旧调用点;**新调用点一律显式传**。
|
|
144
|
+
*
|
|
145
|
+
* @param {Array<{id:string, layer?:string, score?:number, reason?:string}>} hits
|
|
146
|
+
* @param {{channel?:'model'|'inject'|'shadow', zero?:boolean, label?:string, chars?:number}} [meta]
|
|
147
|
+
*/
|
|
148
|
+
function observe(hits, meta = {}) {
|
|
149
|
+
try {
|
|
150
|
+
const s = load()
|
|
151
|
+
const t = nowFn()
|
|
152
|
+
const ch = RECALL_CHANNELS_PRE.indexOf(meta.channel) >= 0 ? meta.channel : 'model'
|
|
153
|
+
const c = s.channels[ch]
|
|
154
|
+
c.events += 1
|
|
155
|
+
c.lastAt = t
|
|
156
|
+
const list = Array.isArray(hits) ? hits : []
|
|
157
|
+
// 零命中:对 model 通道是「检索了但没东西」(重要信号);其它通道同样计数,语义由展示层解释
|
|
158
|
+
if (!list.length) c.zeroHit += 1
|
|
159
|
+
c.hits += list.length
|
|
160
|
+
const d = dayOf(t)
|
|
161
|
+
for (const h of list) {
|
|
162
|
+
const id = h && h.id ? String(h.id) : ''
|
|
163
|
+
if (!id) continue
|
|
164
|
+
const cur = c.ids[id] || { count: 0, first: t, last: t, layer: '', score: 0, reason: '' }
|
|
165
|
+
cur.count += 1
|
|
166
|
+
cur.last = t
|
|
167
|
+
if (h.layer) cur.layer = String(h.layer)
|
|
168
|
+
if (typeof h.score === 'number' && isFinite(h.score)) cur.score = h.score
|
|
169
|
+
// label / chars:注入侧用来回答「哪一段最占预算」(无 id 的段落用它记账)
|
|
170
|
+
if (h.__label) cur.reason = String(h.__label)
|
|
171
|
+
if (typeof h.__chars === 'number' && isFinite(h.__chars)) cur.score = h.__chars
|
|
172
|
+
if (h.reason) cur.reason = String(h.reason)
|
|
173
|
+
c.ids[id] = cur
|
|
174
|
+
const lyr = String(h.layer || 'unknown')
|
|
175
|
+
c.byLayer[lyr] = (c.byLayer[lyr] || 0) + 1
|
|
176
|
+
c.byDay[d] = (c.byDay[d] || 0) + 1
|
|
177
|
+
}
|
|
178
|
+
prune()
|
|
179
|
+
dirty = true
|
|
180
|
+
return true
|
|
181
|
+
} catch (_) { return false } // fail-soft:统计绝不影响召回
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 把一条通路的账本转成只读视图(已排序,直接可画图)。 */
|
|
185
|
+
function viewOf(c) {
|
|
186
|
+
const items = Object.keys(c.ids).map((id) => Object.assign({ id }, c.ids[id]))
|
|
187
|
+
const top = items.slice().sort((a, b) => b.count - a.count || (b.last || 0) - (a.last || 0))
|
|
188
|
+
const byLayer = Object.keys(c.byLayer).map((k) => ({ key: k, count: c.byLayer[k] }))
|
|
189
|
+
.sort((a, b) => b.count - a.count)
|
|
190
|
+
const byDay = Object.keys(c.byDay).sort().map((k) => ({ day: k, count: c.byDay[k] }))
|
|
191
|
+
return {
|
|
192
|
+
events: c.events,
|
|
193
|
+
hits: c.hits,
|
|
194
|
+
zeroHit: c.zeroHit,
|
|
195
|
+
lastAt: c.lastAt,
|
|
196
|
+
distinct: items.length,
|
|
197
|
+
top: top.slice(0, 200),
|
|
198
|
+
byLayer,
|
|
199
|
+
byDay,
|
|
200
|
+
warm: top.filter((it) => it.count <= 1).length, // 只出现过一次的("冷门"候选)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 面板/路由用的只读视图。**按通道分组返回**,不做跨通道汇总(汇总口径会误导)。 */
|
|
205
|
+
function snapshot() {
|
|
206
|
+
const s = load()
|
|
207
|
+
const channels = {}
|
|
208
|
+
for (const ch of RECALL_CHANNELS_PRE) channels[ch] = viewOf(s.channels[ch])
|
|
209
|
+
return {
|
|
210
|
+
version: RECALL_STATS_VERSION_PRE,
|
|
211
|
+
since: s.since,
|
|
212
|
+
channelIds: RECALL_CHANNELS_PRE.slice(),
|
|
213
|
+
channels,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 复位(面板按钮 / 测试)。 */
|
|
218
|
+
function reset() {
|
|
219
|
+
state = empty()
|
|
220
|
+
dirty = true
|
|
221
|
+
return save()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 注入侧的段级记账(没有条目 id 的段落也能记)。
|
|
226
|
+
* 用途:回答「哪一段最占注入预算」—— 段名当 id,字符数当 score。
|
|
227
|
+
* @param {Array<{name:string, chars:number, layer?:string}>} segs
|
|
228
|
+
*/
|
|
229
|
+
function observeInjection(segs) {
|
|
230
|
+
const list = (Array.isArray(segs) ? segs : [])
|
|
231
|
+
.filter((s) => s && s.name)
|
|
232
|
+
.map((s) => ({
|
|
233
|
+
id: 'seg:' + String(s.name),
|
|
234
|
+
layer: s.layer || 'section',
|
|
235
|
+
score: Number(s.chars) || 0,
|
|
236
|
+
__label: String(s.name) + ' · ' + (Number(s.chars) || 0) + ' 字符',
|
|
237
|
+
}))
|
|
238
|
+
return observe(list, { channel: 'inject' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { observe, observeInjection, snapshot, reset, save, _load: load, _state: () => state }
|
|
242
|
+
}
|