@aiwayds/dsh-topics-memory 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +54 -1
- package/lib/config.js +38 -1
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +145 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +119 -0
- package/lib/retrieval.js +290 -0
- package/lib/service.d.ts +60 -3
- package/lib/service.js +320 -44
- package/lib/store.d.ts +15 -12
- package/lib/store.js +43 -3
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +47 -1
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +306 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +357 -0
- package/src/service.ts +326 -52
- package/src/store.ts +46 -10
- package/src/tools.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
44
44
|
"pretest": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs",
|
|
45
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/store-links.test.mjs",
|
|
46
46
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {},
|
package/src/commands.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
|
|
|
17
17
|
import { buildGraph, renderGraphHtml } from './viz.ts'
|
|
18
18
|
import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
|
|
19
19
|
import { reloadSecrets } from './jev/gate.ts'
|
|
20
|
-
import { aggregateStats } from './ilog.ts'
|
|
20
|
+
import { aggregateStats, aggregateOpenPanel, ZERO_OPEN_MIN_INJECTIONS } from './ilog.ts'
|
|
21
21
|
import {
|
|
22
22
|
askDistillSingle,
|
|
23
23
|
createOnboardHandler,
|
|
@@ -52,7 +52,7 @@ export const HELP = [
|
|
|
52
52
|
' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
|
|
53
53
|
' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
|
|
54
54
|
' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
|
|
55
|
-
' /topics stats 注入统计:hit rate、
|
|
55
|
+
' /topics stats 注入统计:hit rate、open rate、注入 vs 打开、near-miss 分布与调参建议',
|
|
56
56
|
' /topics list 列出全部 Topic',
|
|
57
57
|
' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
|
|
58
58
|
' /topics history <slug> 一个 Topic 的结论变更史(git log)',
|
|
@@ -285,7 +285,7 @@ async function doDistill(service: TopicsService, invocation: CommandInvocation,
|
|
|
285
285
|
|
|
286
286
|
async function renderStats(service: TopicsService): Promise<string> {
|
|
287
287
|
const records = await service.store.readInjectionRecords()
|
|
288
|
-
const stats = aggregateStats(records
|
|
288
|
+
const stats = aggregateStats(records)
|
|
289
289
|
if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
|
|
290
290
|
const lines = [
|
|
291
291
|
`注入统计(最近 ${records.length} 轮):`,
|
|
@@ -310,20 +310,14 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
310
310
|
}
|
|
311
311
|
lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`)
|
|
312
312
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
313
|
+
// Injection-vs-open metrics (v5): the denominator counts only pointers
|
|
314
|
+
// that truly rendered (deduped/echoed/dropped all excluded, slow pointers
|
|
315
|
+
// included) and the numerator only counts DISTINCT slugs from that
|
|
316
|
+
// rendered set opened inside the injection window — search opens and
|
|
317
|
+
// never-rendered slugs cannot inflate the rate.
|
|
315
318
|
const opens = await service.store.readOpenRecords()
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
(acc, r) =>
|
|
319
|
-
acc +
|
|
320
|
-
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
321
|
-
(r.slow?.length ?? 0),
|
|
322
|
-
0,
|
|
323
|
-
)
|
|
324
|
-
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
325
|
-
lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`)
|
|
326
|
-
}
|
|
319
|
+
const panel = aggregateOpenPanel(records, opens)
|
|
320
|
+
lines.push(`| open rate | ${(panel.openRate * 100).toFixed(1)}%(${panel.opensInWindow} 个被打开指针 / ${panel.pointerEntries} 条注入指针) |`)
|
|
327
321
|
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
|
|
328
322
|
if (echoedCount > 0) {
|
|
329
323
|
lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`)
|
|
@@ -332,6 +326,31 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
332
326
|
lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |')
|
|
333
327
|
for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
|
|
334
328
|
}
|
|
329
|
+
// ---- 注入 vs 打开 panel ----
|
|
330
|
+
// Per-topic cut: what the header's windowed rate cannot show — which
|
|
331
|
+
// topics the model actually bothers to open (wide-scope slug attribution).
|
|
332
|
+
if (panel.topics.length > 0) {
|
|
333
|
+
lines.push(
|
|
334
|
+
'',
|
|
335
|
+
'注入 vs 打开(Top-10,注入=真正渲染的指针;打开按 slug 归因,全窗口):',
|
|
336
|
+
'',
|
|
337
|
+
'| Topic | 注入 | 打开 | 打开率 |',
|
|
338
|
+
'| --- | --- | --- | --- |',
|
|
339
|
+
...panel.topics.map((t) => `| \`${cell(t.slug)}\` | ${t.injections} | ${t.opens} | ${(t.openRate * 100).toFixed(1)}% |`),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
if (panel.zeroOpenTotal > 0) {
|
|
343
|
+
const shown = panel.zeroOpen.map((z) => `\`${cell(z.slug)}\``).join('、')
|
|
344
|
+
const more = panel.zeroOpenTotal > panel.zeroOpen.length ? `(共 ${panel.zeroOpenTotal} 个)` : ''
|
|
345
|
+
lines.push('', `零打开高频(注入 ≥${ZERO_OPEN_MIN_INJECTIONS}、打开 0):${shown}${more}`)
|
|
346
|
+
}
|
|
347
|
+
lines.push('', panel.hasSourceData ? `打开来源:pointer ${panel.pointerOpens} / search ${panel.searchOpens}` : '打开来源:暂无来源数据(需新版本 open 记录)')
|
|
348
|
+
if (panel.pointerEntries > 0) {
|
|
349
|
+
lines.push('', '| 分桶 | 指针 | 打开率 |', '| --- | --- | --- |')
|
|
350
|
+
for (const b of panel.bands) {
|
|
351
|
+
lines.push(`| ${b.label} | ${b.pointers} | ${b.pointers === 0 ? '—' : `${(b.openRate * 100).toFixed(1)}%`} |`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
335
354
|
if (stats.nearMissHistogram.length > 0) {
|
|
336
355
|
lines.push(
|
|
337
356
|
'',
|
package/src/config.ts
CHANGED
|
@@ -72,6 +72,12 @@ export const TopicsConfig = z.object({
|
|
|
72
72
|
* granted to zero-lexical candidates; the structural gate still applies.
|
|
73
73
|
* 0 = off. */
|
|
74
74
|
usageBoost: z.number().default(0.15).volatile(),
|
|
75
|
+
/** Zero-open decay (v5, default off): halve the retrieval score of slugs
|
|
76
|
+
* injected ≥5 times in the rolling 30 days with zero opens ever — the
|
|
77
|
+
* 零打开高频 leaderboard stops out-ranking topics the model actually
|
|
78
|
+
* reads. false = zero behavior change; the reason tag `zero-open-decay`
|
|
79
|
+
* rides the hit/near-miss reasons when on. */
|
|
80
|
+
zeroOpenDecay: z.boolean().default(false).volatile(),
|
|
75
81
|
/** Debounced push delay in GitHub mode. */
|
|
76
82
|
pushDebounceSeconds: z.number().default(45).volatile(),
|
|
77
83
|
/** System One decision-model integration (design 2026-09-25), default-off
|
|
@@ -99,6 +105,21 @@ export const TopicsConfig = z.object({
|
|
|
99
105
|
/** laya-serve systemone endpoint for the pace-maker (default: the local
|
|
100
106
|
* laya-serve bind from the laya repo's serve mode). */
|
|
101
107
|
jevLayaUrl: z.string().default('http://127.0.0.1:8000/v1/systemone').volatile(),
|
|
108
|
+
/** Local embedding provider (A4 semantic reranker, spec §3.1): ''
|
|
109
|
+
* sentinel = the whole semantic layer is OFF (pure lexical); 'ollama' =
|
|
110
|
+
* the local ollama embed API (localhost is the ADR 0017 local exception).
|
|
111
|
+
* v1 ships no remote provider — a remote embedding may only ever enter a
|
|
112
|
+
* slow-lane/offline path, never the fast lane (red line ①). */
|
|
113
|
+
embeddingProvider: z.string().default('').volatile(),
|
|
114
|
+
/** Embedding model; '' = provider default (ollama → nomic-embed-text),
|
|
115
|
+
* same sentinel pattern as distillModel. */
|
|
116
|
+
embeddingModel: z.string().default('').volatile(),
|
|
117
|
+
/** Injection-package master gate (A-line, spec §2/§3): off | shadow | on.
|
|
118
|
+
* Carries the WHOLE new-behavior surface — query sanitization, the
|
|
119
|
+
* three-layer package, nearFloor semantic rerank, degradation. off = zero
|
|
120
|
+
* behavior change anywhere (byte-for-byte legacy); shadow = compute +
|
|
121
|
+
* bypass-ledger only, nothing lands in context; on = engage. */
|
|
122
|
+
packageMode: z.string().default('off').volatile(),
|
|
102
123
|
})
|
|
103
124
|
|
|
104
125
|
export type TopicsConfigValue = {
|
|
@@ -127,6 +148,10 @@ export type TopicsConfigValue = {
|
|
|
127
148
|
consolidateCadence: string
|
|
128
149
|
deprecatedTtlDays: number
|
|
129
150
|
usageBoost: number
|
|
151
|
+
/** Zero-open decay (v5). Optional in this TS view only (same sentinel
|
|
152
|
+
* pattern as the jev keys): bare-harness DEFAULTS predates it —
|
|
153
|
+
* consumers must treat undefined as the documented default (false). */
|
|
154
|
+
zeroOpenDecay?: boolean
|
|
130
155
|
pushDebounceSeconds: number
|
|
131
156
|
/** System One integration keys (design 2026-09-25 §4). Optional in this TS
|
|
132
157
|
* view only: index.ts's bare-harness DEFAULTS literal predates them and
|
|
@@ -139,6 +164,13 @@ export type TopicsConfigValue = {
|
|
|
139
164
|
jevSecretFile?: string
|
|
140
165
|
jevLayaFallback?: boolean
|
|
141
166
|
jevLayaUrl?: string
|
|
167
|
+
/** Injection-quality line keys (spec §7). Optional in this TS view only
|
|
168
|
+
* (same sentinel pattern as the jev keys): index.ts's bare-harness
|
|
169
|
+
* DEFAULTS literal predates them — consumers must treat undefined as the
|
|
170
|
+
* documented default ('' / '' / 'off' = every new behavior zeroed). */
|
|
171
|
+
embeddingProvider?: string
|
|
172
|
+
embeddingModel?: string
|
|
173
|
+
packageMode?: 'off' | 'shadow' | 'on'
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
export const CONFIG_KEYS = [
|
|
@@ -167,6 +199,7 @@ export const CONFIG_KEYS = [
|
|
|
167
199
|
'consolidateCadence',
|
|
168
200
|
'deprecatedTtlDays',
|
|
169
201
|
'usageBoost',
|
|
202
|
+
'zeroOpenDecay',
|
|
170
203
|
'pushDebounceSeconds',
|
|
171
204
|
'jevEnabled',
|
|
172
205
|
'jevBackend',
|
|
@@ -175,6 +208,9 @@ export const CONFIG_KEYS = [
|
|
|
175
208
|
'jevSecretFile',
|
|
176
209
|
'jevLayaFallback',
|
|
177
210
|
'jevLayaUrl',
|
|
211
|
+
'embeddingProvider',
|
|
212
|
+
'embeddingModel',
|
|
213
|
+
'packageMode',
|
|
178
214
|
] as const
|
|
179
215
|
|
|
180
216
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
@@ -199,7 +235,8 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
199
235
|
case 'autoObserve':
|
|
200
236
|
case 'distillOnSessionEnd':
|
|
201
237
|
case 'includeSubagents':
|
|
202
|
-
case 'jevEnabled':
|
|
238
|
+
case 'jevEnabled':
|
|
239
|
+
case 'zeroOpenDecay': {
|
|
203
240
|
if (raw === 'on' || raw === 'true') return true
|
|
204
241
|
if (raw === 'off' || raw === 'false') return false
|
|
205
242
|
return { error: `${key} 取值 on|off` }
|
|
@@ -245,10 +282,19 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
245
282
|
}
|
|
246
283
|
case 'distillProvider':
|
|
247
284
|
case 'distillModel':
|
|
285
|
+
case 'embeddingModel':
|
|
248
286
|
case 'jevModel':
|
|
249
287
|
case 'jevSecretFile':
|
|
250
288
|
case 'jevLayaUrl':
|
|
251
289
|
return raw
|
|
290
|
+
case 'embeddingProvider': {
|
|
291
|
+
if (raw === '' || raw === 'ollama') return raw
|
|
292
|
+
return { error: 'embedding-provider 仅支持 ollama(留空=语义层关;远程 provider 不进快道)' }
|
|
293
|
+
}
|
|
294
|
+
case 'packageMode': {
|
|
295
|
+
if (raw === 'off' || raw === 'shadow' || raw === 'on') return raw
|
|
296
|
+
return { error: 'package-mode 取值 off|shadow|on' }
|
|
297
|
+
}
|
|
252
298
|
case 'jevLayaFallback': {
|
|
253
299
|
if (raw === 'on' || raw === 'true') return true
|
|
254
300
|
if (raw === 'off' || raw === 'false') return false
|
package/src/digest.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { TopicDoc } from './okf.ts'
|
|
13
13
|
import { sectionOf, firstParagraph, CONCLUSION_HEADING, RECOMMENDATIONS_HEADING } from './okf.ts'
|
|
14
14
|
import type { SearchHit } from './retrieval.ts'
|
|
15
|
+
import { tokenize } from './retrieval.ts'
|
|
15
16
|
|
|
16
17
|
/** Rough CJK-aware token estimate: ~0.75 token per CJK char, ~4 chars per latin token. */
|
|
17
18
|
export function estimateTokens(text: string): number {
|
|
@@ -230,3 +231,418 @@ export function assembleInjection(
|
|
|
230
231
|
): AssembleResult {
|
|
231
232
|
return packInjection(entries, cfg, topicDigest, slow)
|
|
232
233
|
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// A3 相关性三层信息包 (spec 2026-10-self-improve-topics §2) — the gated new
|
|
237
|
+
// shape. Coexists with assemblePointer: the service-layer mode gate decides
|
|
238
|
+
// which renderer runs; both stay pure here. 分层预算取代单条 80(§2.6):
|
|
239
|
+
// 直接相关 ~120×≤2、邻域 ~40×≤3、弱信号合并一行;总预算钉 600 不浮动
|
|
240
|
+
// (A3 决议 3)。降档=琐碎 query 且与全部候选零词面实体 → null(整包跳过)。
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/** One retrieval-side candidate, roster metadata pre-extracted by the caller. */
|
|
244
|
+
export interface PackageCandidate {
|
|
245
|
+
slug: string
|
|
246
|
+
title: string
|
|
247
|
+
status: string
|
|
248
|
+
description?: string
|
|
249
|
+
/** Conclusion section first line (fallback for a missing description). */
|
|
250
|
+
conclusion?: string
|
|
251
|
+
score: number
|
|
252
|
+
/** Retrieval reason tags (`triggers:0.83` / `title:0.50` / `tags:…` / …). */
|
|
253
|
+
reasons: string[]
|
|
254
|
+
/** Edge/lane provenance: `depends:dN` | `slow-pick` | `semantic…` | raw. */
|
|
255
|
+
via?: string
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface PackageInput {
|
|
259
|
+
query: string
|
|
260
|
+
/** Query lexemes the caller retrieved with (tokenize + §3.3 cleaning). */
|
|
261
|
+
queryTokens: string[]
|
|
262
|
+
/** Lexical strong hits (gate-passed); lands in the direct layer. */
|
|
263
|
+
direct: PackageCandidate[]
|
|
264
|
+
/** Graph expansion + slow-lane picks; lands in the neighborhood layer. */
|
|
265
|
+
neighborhood: PackageCandidate[]
|
|
266
|
+
/** nearFloor band (0.20–0.30) head; lands in the merged weak-signal line. */
|
|
267
|
+
weak: PackageCandidate[]
|
|
268
|
+
/** ADR 0015 usage signal — within-layer ordering tiebreak only (v1). */
|
|
269
|
+
usageBySlug?: Map<string, number>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface PackageOpts {
|
|
273
|
+
/**
|
|
274
|
+
* `'on'` renders the live package; `'shadow'` means the caller double-runs
|
|
275
|
+
* (old assemblePointer + this) for the bypass ledger (spec §8.3). The pure
|
|
276
|
+
* output is identical in both modes — the mode is call-site bookkeeping.
|
|
277
|
+
*/
|
|
278
|
+
mode?: 'on' | 'shadow'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface PackageResult {
|
|
282
|
+
text: string
|
|
283
|
+
usedTokens: number
|
|
284
|
+
included: string[]
|
|
285
|
+
dropped: { slug: string; reason: string }[]
|
|
286
|
+
/** Final per-layer rosters (what the 包头 counts report). */
|
|
287
|
+
layers: { direct: string[]; neighborhood: string[]; weak: string[] }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Total package cap — pinned, does not float with hit strength (A3 决议 3). */
|
|
291
|
+
export const PACKAGE_TOTAL = 600
|
|
292
|
+
export const PACKAGE_DIRECT_MAX = 2
|
|
293
|
+
export const PACKAGE_DIRECT_BUDGET = 120
|
|
294
|
+
export const PACKAGE_NEIGHBOR_MAX = 3
|
|
295
|
+
export const PACKAGE_NEIGHBOR_BUDGET = 40
|
|
296
|
+
export const PACKAGE_WEAK_MAX = 2
|
|
297
|
+
/**
|
|
298
|
+
* Padded-trivial arm of the downgrade guard (spec §2.3 名词实体口径, an
|
|
299
|
+
* implementation-time item per §11): after stripping whitespace, a query this
|
|
300
|
+
* short counts as 琐碎 even when the tokenizer expands CJK bigrams past 3
|
|
301
|
+
* tokens. Calibrated on the two named scenarios: 场景① ack query strips to
|
|
302
|
+
* 15 chars (downgrade), a real review query strips to 18 (kept).
|
|
303
|
+
*/
|
|
304
|
+
export const PACKAGE_TRIVIAL_QUERY_CHARS = 16
|
|
305
|
+
|
|
306
|
+
const PACKAGE_WRAPPER_OPEN = '<topic-memory>'
|
|
307
|
+
const PACKAGE_WRAPPER_CLOSE = '</topic-memory>'
|
|
308
|
+
|
|
309
|
+
/** Edge/lane note for the why line / neighborhood annotation (A3 决议 2). */
|
|
310
|
+
function viaNote(via: string | undefined): string | null {
|
|
311
|
+
if (via === undefined || via === '') return null
|
|
312
|
+
if (via.startsWith('depends')) return '经 depends 关联'
|
|
313
|
+
if (via.startsWith('slow')) return '慢道 pick'
|
|
314
|
+
if (via.startsWith('semantic')) return '语义近邻'
|
|
315
|
+
return via
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Trigger reason → the trigger word when the caller carries it, `''` channel-only. */
|
|
319
|
+
function triggerWordOfReason(reason: string): string | undefined {
|
|
320
|
+
const m = reason.match(/^triggers?[::](.*)$/)
|
|
321
|
+
if (m === null) return undefined
|
|
322
|
+
const v = m[1].trim()
|
|
323
|
+
return /^[\d.+-]+$/.test(v) ? '' : v
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* slug/title 词面,含分隔符子词(`cs-geely-voice-agent` → `voice`/`agent`):
|
|
328
|
+
* query 里的连字符词(voice-agent)与 slug 复合词永远不相等,实体判定必须
|
|
329
|
+
* 下探到子词才有交集;CJK 已由 tokenize 的二元切分覆盖。
|
|
330
|
+
*/
|
|
331
|
+
function subtokenSurface(text: string): Set<string> {
|
|
332
|
+
const out = new Set<string>()
|
|
333
|
+
for (const t of tokenize(text)) {
|
|
334
|
+
out.add(t)
|
|
335
|
+
if (/[+#.-]/.test(t)) {
|
|
336
|
+
for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2) out.add(sub)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function tokenMatchesSurface(token: string, surface: ReadonlySet<string>): boolean {
|
|
343
|
+
const t = token.toLowerCase()
|
|
344
|
+
if (surface.has(t)) return true
|
|
345
|
+
if (/[+#.-]/.test(t)) {
|
|
346
|
+
for (const sub of t.split(/[+#.-]+/)) if (sub.length >= 2 && surface.has(sub)) return true
|
|
347
|
+
}
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Strict display matcher for why-line attribution: a compound query token
|
|
353
|
+
* counts as a title/weak word only when EVERY separator part ties to the
|
|
354
|
+
* surface — `voice-agent` matches `cs-geely-voice-agent`, but
|
|
355
|
+
* `cs-geely-alert-manager-api` must not match it via the shared `cs`.
|
|
356
|
+
*/
|
|
357
|
+
function tokenMatchesSurfaceStrict(token: string, surface: ReadonlySet<string>): boolean {
|
|
358
|
+
const t = token.toLowerCase()
|
|
359
|
+
if (surface.has(t)) return true
|
|
360
|
+
if (!/[+#.-]/.test(t)) return false
|
|
361
|
+
const subs = t.split(/[+#.-]+/).filter((s) => s.length >= 2)
|
|
362
|
+
return subs.length > 0 && subs.every((s) => surface.has(s))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Query tokens (original spelling, query order) with a词面 tie to the surface. */
|
|
366
|
+
function matchedQueryTokens(queryTokens: readonly string[], surfaceText: string): string[] {
|
|
367
|
+
const surface = subtokenSurface(surfaceText)
|
|
368
|
+
const out: string[] = []
|
|
369
|
+
for (const tok of queryTokens) {
|
|
370
|
+
if (!out.includes(tok) && tokenMatchesSurfaceStrict(tok, surface)) out.push(tok)
|
|
371
|
+
}
|
|
372
|
+
return out
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Entity-anchored candidate: its slug/title surface shares a query token
|
|
376
|
+
* (验证波次修正——A③ 场景实证:词法第 3 名恰是 query 点名实体时,≤2 席
|
|
377
|
+
* 会把任务对象整体剪掉;实体锚定者不受席帽约束,仍受总预算约束)。 */
|
|
378
|
+
function isEntityAnchored(queryTokens: readonly string[], c: PackageCandidate): boolean {
|
|
379
|
+
if (queryTokens.length === 0) return false
|
|
380
|
+
const surface = subtokenSurface(`${c.slug} ${c.title}`)
|
|
381
|
+
for (const tok of queryTokens) {
|
|
382
|
+
if (tokenMatchesSurface(tok, surface)) return true
|
|
383
|
+
}
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function hasEntityOverlap(queryTokens: readonly string[], candidates: readonly PackageCandidate[]): boolean {
|
|
388
|
+
if (queryTokens.length === 0) return false
|
|
389
|
+
for (const c of candidates) {
|
|
390
|
+
const surface = subtokenSurface(`${c.slug} ${c.title}`)
|
|
391
|
+
for (const tok of queryTokens) {
|
|
392
|
+
if (tokenMatchesSurface(tok, surface)) return true
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isTrivialQuery(query: string, queryTokens: readonly string[]): boolean {
|
|
399
|
+
if (queryTokens.length <= 3) return true
|
|
400
|
+
const stripped = query.replace(/\s+/g, '')
|
|
401
|
+
return stripped.length > 0 && stripped.length <= PACKAGE_TRIVIAL_QUERY_CHARS
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Stable within-layer order: score desc, then usage, then input order. */
|
|
405
|
+
function rankStable(list: readonly PackageCandidate[], usage: Map<string, number> | undefined): PackageCandidate[] {
|
|
406
|
+
return list
|
|
407
|
+
.map((c, i) => ({ c, i, u: usage?.get(c.slug) ?? 0 }))
|
|
408
|
+
.sort((a, b) => b.c.score - a.c.score || b.u - a.u || a.i - b.i)
|
|
409
|
+
.map((x) => x.c)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Machine-assembled why line (A3 决议 2): hit channel + edge source + score. */
|
|
413
|
+
function packageWhyLine(c: PackageCandidate, queryTokens: readonly string[]): string {
|
|
414
|
+
const parts: string[] = []
|
|
415
|
+
let triggerWord: string | null = null
|
|
416
|
+
let triggerChannel = false
|
|
417
|
+
let titleHit = false
|
|
418
|
+
const fields: string[] = []
|
|
419
|
+
for (const r of c.reasons) {
|
|
420
|
+
const tw = triggerWordOfReason(r)
|
|
421
|
+
if (tw !== undefined) {
|
|
422
|
+
if (tw === '') triggerChannel = true
|
|
423
|
+
else if (triggerWord === null) triggerWord = tw
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
if (r.startsWith('title:')) titleHit = true
|
|
427
|
+
else if (r.startsWith('tags:')) fields.push('标签')
|
|
428
|
+
else if (r.startsWith('description:')) fields.push('描述')
|
|
429
|
+
else if (r.startsWith('conclusion:')) fields.push('正文')
|
|
430
|
+
else if (r.startsWith('slug:')) fields.push('slug')
|
|
431
|
+
}
|
|
432
|
+
if (triggerWord !== null) parts.push(`trigger「${triggerWord}」`)
|
|
433
|
+
else if (triggerChannel) parts.push('trigger 命中')
|
|
434
|
+
if (titleHit) {
|
|
435
|
+
const words = matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)
|
|
436
|
+
parts.push(words.length > 0 ? `标题词「${words.slice(0, 2).join('」「')}」` : '标题词命中')
|
|
437
|
+
}
|
|
438
|
+
if (fields.length > 0) parts.push(`${[...new Set(fields)].join('/')}词面命中`)
|
|
439
|
+
const via = viaNote(c.via)
|
|
440
|
+
if (via !== null) parts.push(via)
|
|
441
|
+
if (parts.length === 0) parts.push('词面命中')
|
|
442
|
+
return `命中: ${truncateToTokens(parts.join('、'), 32).text}(score:${c.score.toFixed(2)})`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One direct-layer full entry (~120 tok): 标题+[status] + 一行实质 +
|
|
447
|
+
* 机器 why 行 + 指令式打开引导(预告源 description 优先,缺失回退结论行时
|
|
448
|
+
* 不再重复预告)。实质行是弹性段,超预算先截它(spec §2.4)。
|
|
449
|
+
*/
|
|
450
|
+
function directEntryText(
|
|
451
|
+
c: PackageCandidate,
|
|
452
|
+
queryTokens: readonly string[],
|
|
453
|
+
budget: number,
|
|
454
|
+
): { text: string; usedTokens: number } {
|
|
455
|
+
const head = `### ${c.title} [${c.status}]`
|
|
456
|
+
const why = packageWhyLine(c, queryTokens)
|
|
457
|
+
const warn = c.reasons.includes('conflicted-demoted') ? '⚠ 此条存在未合并冲突,结论可能过期。' : null
|
|
458
|
+
const openBase = `细节: topic_open "${c.slug}"`
|
|
459
|
+
const conclusionLine = (c.conclusion ?? '').trim()
|
|
460
|
+
const substanceRaw = (conclusionLine !== '' ? conclusionLine : (c.description ?? '')).trim()
|
|
461
|
+
const previewRaw = (c.description ?? '').replace(/\s+/g, ' ').trim()
|
|
462
|
+
|
|
463
|
+
const lines: string[] = [head, why]
|
|
464
|
+
let substanceText = ''
|
|
465
|
+
if (substanceRaw !== '') {
|
|
466
|
+
const others = estimateTokens([head, why, openBase].join('\n')) + (warn !== null ? estimateTokens(warn) + 1 : 0)
|
|
467
|
+
const room = budget - others - 1
|
|
468
|
+
if (room >= 8) substanceText = truncateToTokens(substanceRaw, room).text
|
|
469
|
+
}
|
|
470
|
+
if (substanceText !== '') lines.push(substanceText)
|
|
471
|
+
if (warn !== null) lines.push(warn)
|
|
472
|
+
let open = openBase
|
|
473
|
+
if (previewRaw !== '' && previewRaw !== substanceText) {
|
|
474
|
+
const room = budget - estimateTokens([...lines, open].join('\n')) - 3
|
|
475
|
+
if (room >= 6) open = `${openBase},${truncateToTokens(previewRaw, room).text}`
|
|
476
|
+
}
|
|
477
|
+
lines.push(open)
|
|
478
|
+
// Last resort for pathological heads (giant titles): shed elastic lines.
|
|
479
|
+
while (lines.length > 2 && estimateTokens(lines.join('\n')) > budget) {
|
|
480
|
+
const dropWarn = lines.indexOf(warn ?? '')
|
|
481
|
+
if (warn !== null && dropWarn !== -1) lines.splice(dropWarn, 1)
|
|
482
|
+
else if (substanceText !== '' && lines.includes(substanceText)) lines.splice(lines.indexOf(substanceText), 1)
|
|
483
|
+
else break
|
|
484
|
+
}
|
|
485
|
+
const text = lines.join('\n')
|
|
486
|
+
return { text, usedTokens: estimateTokens(text) }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** One neighborhood half-line (~40 tok): 标题 + slug + 边来源/通道标注. */
|
|
490
|
+
function neighborhoodEntryText(c: PackageCandidate): { text: string; usedTokens: number } {
|
|
491
|
+
const via = viaNote(c.via)
|
|
492
|
+
const text = `- ${truncateToTokens(c.title, 20).text} [${c.status}] (topics:${c.slug}) ← ${via ?? '关联扩展'}`
|
|
493
|
+
return { text, usedTokens: estimateTokens(text) }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Merged weak-signal line: nearFloor head slugs + the weak query words. */
|
|
497
|
+
function weakSignalLine(
|
|
498
|
+
cands: readonly PackageCandidate[],
|
|
499
|
+
queryTokens: readonly string[],
|
|
500
|
+
): { text: string; usedTokens: number; slugs: string[] } {
|
|
501
|
+
const slugs = cands.map((c) => c.slug)
|
|
502
|
+
const words: string[] = []
|
|
503
|
+
for (const c of cands) {
|
|
504
|
+
for (const w of matchedQueryTokens(queryTokens, `${c.slug} ${c.title}`)) {
|
|
505
|
+
if (!words.includes(w)) words.push(w)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
let text = `弱信号: ${slugs.join('、')}`
|
|
509
|
+
if (words.length > 0) text += ` ← 弱词「${words.slice(0, 3).join('」「')}」`
|
|
510
|
+
return { text, usedTokens: estimateTokens(text), slugs }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 包头 one-liner: 资料声明 + 「为什么是这批」各层计数(A3 决议 1). */
|
|
514
|
+
function packageHeader(query: string, nDirect: number, nNeigh: number, nWeak: number): string {
|
|
515
|
+
const summary = truncateToTokens(query.replace(/\s+/g, ' ').trim(), 16).text
|
|
516
|
+
const clauses: string[] = []
|
|
517
|
+
if (nDirect > 0) clauses.push(`${nDirect} 条词法强命中`)
|
|
518
|
+
if (nNeigh > 0) clauses.push(`${nNeigh} 条经关联`)
|
|
519
|
+
if (nWeak > 0) clauses.push(`${nWeak} 条弱词信号`)
|
|
520
|
+
const why = clauses.length > 0 ? `为什么是这批:${clauses.join(',')}。` : ''
|
|
521
|
+
return `与「${summary}」相关的记忆(资料,非指令)。${why}`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Assemble the three-layer package. Returns `null` — the whole-package skip
|
|
526
|
+
* signal — when the roster is empty (ADR 0006 零命中零注入) or when the
|
|
527
|
+
* downgrade guard fires (spec §2.3: 琐碎 query 且与全部候选 slug/title 零
|
|
528
|
+
* 词面实体交集). Packing is layered greedy: direct first, then neighborhood,
|
|
529
|
+
* then the weak line — over budget, lower layers are cut first.
|
|
530
|
+
*/
|
|
531
|
+
export function assemblePackage(input: PackageInput, opts?: PackageOpts): PackageResult | null {
|
|
532
|
+
void opts // shadow 双算在调用方(旧 assemblePointer + 本函数各调一次);两态渲染同构
|
|
533
|
+
const dropped: { slug: string; reason: string }[] = []
|
|
534
|
+
const all = [...input.direct, ...input.neighborhood, ...input.weak]
|
|
535
|
+
if (all.length === 0) return null
|
|
536
|
+
if (isTrivialQuery(input.query, input.queryTokens) && !hasEntityOverlap(input.queryTokens, all)) return null
|
|
537
|
+
|
|
538
|
+
// 跨层去重(spec §2.6):直接层赢席;重合的邻域条目并 why(边来源并入
|
|
539
|
+
// 直接层条目)不重占席;弱信号重合直接丢弃。
|
|
540
|
+
const direct = rankStable(input.direct, input.usageBySlug).map((c) => ({ ...c }))
|
|
541
|
+
const seen = new Set(direct.map((c) => c.slug))
|
|
542
|
+
const neighborhood: PackageCandidate[] = []
|
|
543
|
+
for (const c of rankStable(input.neighborhood, input.usageBySlug)) {
|
|
544
|
+
if (seen.has(c.slug)) {
|
|
545
|
+
const host = direct.find((d) => d.slug === c.slug)
|
|
546
|
+
if (host !== undefined && host.via === undefined && c.via !== undefined) host.via = c.via
|
|
547
|
+
dropped.push({ slug: c.slug, reason: 'duplicate' })
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
seen.add(c.slug)
|
|
551
|
+
neighborhood.push(c)
|
|
552
|
+
}
|
|
553
|
+
const weak: PackageCandidate[] = []
|
|
554
|
+
for (const c of rankStable(input.weak, input.usageBySlug)) {
|
|
555
|
+
if (seen.has(c.slug)) {
|
|
556
|
+
dropped.push({ slug: c.slug, reason: 'duplicate' })
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
seen.add(c.slug)
|
|
560
|
+
weak.push(c)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Reserve with an upper-bound header (pre-cap counts); the final header is
|
|
564
|
+
// never longer, so the 600 cap holds after the swap.
|
|
565
|
+
let used =
|
|
566
|
+
estimateTokens(`${PACKAGE_WRAPPER_OPEN}\n${packageHeader(input.query, direct.length, neighborhood.length, weak.length)}\n\n${PACKAGE_WRAPPER_CLOSE}`)
|
|
567
|
+
|
|
568
|
+
const layers: PackageResult['layers'] = { direct: [], neighborhood: [], weak: [] }
|
|
569
|
+
const directBlocks: string[] = []
|
|
570
|
+
for (const c of direct) {
|
|
571
|
+
if (directBlocks.length >= PACKAGE_DIRECT_MAX && !isEntityAnchored(input.queryTokens, c)) {
|
|
572
|
+
dropped.push({ slug: c.slug, reason: 'direct-cap' })
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
const budget = Math.min(PACKAGE_DIRECT_BUDGET, PACKAGE_TOTAL - used)
|
|
576
|
+
if (budget < 24) {
|
|
577
|
+
dropped.push({ slug: c.slug, reason: 'total-budget' })
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
const block = directEntryText(c, input.queryTokens, budget)
|
|
581
|
+
if (used + block.usedTokens > PACKAGE_TOTAL) {
|
|
582
|
+
dropped.push({ slug: c.slug, reason: 'total-budget' })
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
directBlocks.push(block.text)
|
|
586
|
+
layers.direct.push(c.slug)
|
|
587
|
+
used += block.usedTokens
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const neighBlocks: string[] = []
|
|
591
|
+
for (const c of neighborhood) {
|
|
592
|
+
if (neighBlocks.length >= PACKAGE_NEIGHBOR_MAX) {
|
|
593
|
+
dropped.push({ slug: c.slug, reason: 'neighborhood-cap' })
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
const budget = Math.min(PACKAGE_NEIGHBOR_BUDGET, PACKAGE_TOTAL - used)
|
|
597
|
+
if (budget < 16) {
|
|
598
|
+
dropped.push({ slug: c.slug, reason: 'total-budget' })
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const block = neighborhoodEntryText(c)
|
|
602
|
+
if (used + block.usedTokens > PACKAGE_TOTAL) {
|
|
603
|
+
dropped.push({ slug: c.slug, reason: 'total-budget' })
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
neighBlocks.push(block.text)
|
|
607
|
+
layers.neighborhood.push(c.slug)
|
|
608
|
+
used += block.usedTokens
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const weakTake = weak.slice(0, PACKAGE_WEAK_MAX)
|
|
612
|
+
for (const c of weak.slice(PACKAGE_WEAK_MAX)) dropped.push({ slug: c.slug, reason: 'weak-cap' })
|
|
613
|
+
let weakText: string | null = null
|
|
614
|
+
if (weakTake.length > 0) {
|
|
615
|
+
const full = weakSignalLine(weakTake, input.queryTokens)
|
|
616
|
+
if (used + full.usedTokens <= PACKAGE_TOTAL) {
|
|
617
|
+
weakText = full.text
|
|
618
|
+
layers.weak = full.slugs
|
|
619
|
+
used += full.usedTokens
|
|
620
|
+
} else if (weakTake.length > 1) {
|
|
621
|
+
const single = weakSignalLine(weakTake.slice(0, 1), input.queryTokens)
|
|
622
|
+
if (used + single.usedTokens <= PACKAGE_TOTAL) {
|
|
623
|
+
weakText = single.text
|
|
624
|
+
layers.weak = single.slugs
|
|
625
|
+
used += single.usedTokens
|
|
626
|
+
for (const slug of weakTake.slice(1).map((c) => c.slug)) dropped.push({ slug, reason: 'total-budget' })
|
|
627
|
+
} else {
|
|
628
|
+
for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
for (const slug of full.slugs) dropped.push({ slug, reason: 'total-budget' })
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const parts: string[] = [packageHeader(input.query, layers.direct.length, layers.neighborhood.length, layers.weak.length)]
|
|
636
|
+
if (directBlocks.length > 0) parts.push(['### 直接相关', ...directBlocks].join('\n\n'))
|
|
637
|
+
if (neighBlocks.length > 0) parts.push(['### 邻域', ...neighBlocks].join('\n'))
|
|
638
|
+
if (weakText !== null) parts.push(weakText)
|
|
639
|
+
if (parts.length === 1) return null // everything budget-cut → 零命中零注入
|
|
640
|
+
const text = `${PACKAGE_WRAPPER_OPEN}\n${parts.join('\n\n')}\n${PACKAGE_WRAPPER_CLOSE}`
|
|
641
|
+
return {
|
|
642
|
+
text,
|
|
643
|
+
usedTokens: estimateTokens(text),
|
|
644
|
+
included: [...layers.direct, ...layers.neighborhood, ...layers.weak],
|
|
645
|
+
dropped,
|
|
646
|
+
layers,
|
|
647
|
+
}
|
|
648
|
+
}
|