@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.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Local embedding client (ollama) — the semantic layer behind the nearFloor
3
+ * reranker (A4 决议 2, spec §3.1).
4
+ *
5
+ * Semantics (mirror of jev/client's outcome mode):
6
+ * - every failure returns as a structured {ok:false, outcome} result, NEVER
7
+ * a throw — the caller degrades explicitly to the pure-lexical path;
8
+ * - one AbortSignal.timeout per request, NO retry (query 2s / batch 30s);
9
+ * - localhost only in v1 (ADR 0017 local exception). Remote providers do
10
+ * not exist here by design: a remote embedding may only ever enter a
11
+ * slow-lane/offline path, never the fast lane (red line ①).
12
+ *
13
+ * @module embedding
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process'
17
+
18
+ /** Default ollama bind (the plugin's own default; OLLAMA_HOST overrides). */
19
+ export const DEFAULT_EMBED_HOST = 'http://127.0.0.1:11434'
20
+
21
+ /** Provider default model when embeddingModel is the '' sentinel
22
+ * (spec §7: ollama → nomic-embed-text, 768 dims, A1 §5 measured). */
23
+ export const DEFAULT_EMBED_MODEL = 'nomic-embed-text'
24
+
25
+ /** Query-side timeout — the reranker sits in the spliced hot path; a slow
26
+ * ollama must lose the race, not the injection. */
27
+ export const EMBED_QUERY_TIMEOUT_MS = 2000
28
+ /** Batch-side timeout (index rebuild: A1 measured ~9.5s for 137 topics). */
29
+ export const EMBED_BATCH_TIMEOUT_MS = 30_000
30
+
31
+ /** Per-text byte cap before anything leaves the process (query端 p50=153 tok
32
+ * ~ well under this; the cap only bites on paste pollution). */
33
+ export const MAX_TEXT_BYTES = 2048
34
+
35
+ /** The only provider v1 speaks. Anything else is an explicit
36
+ * unsupported_provider outcome, not a silent attempt. */
37
+ export const SUPPORTED_PROVIDER = 'ollama'
38
+
39
+ /** The config slice the embed functions need (pass straight from
40
+ * TopicsConfigValue). '' sentinels: provider off / provider default model. */
41
+ export interface EmbeddingConfig {
42
+ provider?: string
43
+ model?: string
44
+ /** Explicit host; absent → OLLAMA_HOST env → DEFAULT_EMBED_HOST. */
45
+ host?: string
46
+ }
47
+
48
+ /** Resolve the ollama base URL: explicit arg wins, then OLLAMA_HOST, then
49
+ * the localhost default. Bare `host:port` values get the http:// prefix
50
+ * (ollama's own OLLAMA_HOST convention); trailing slashes are trimmed. */
51
+ export function resolveEmbedHost(explicit?: string): string {
52
+ const raw = (explicit ?? process.env.OLLAMA_HOST ?? '').trim()
53
+ const withScheme = raw === '' ? DEFAULT_EMBED_HOST : /^https?:\/\//.test(raw) ? raw : `http://${raw.replace(/^tcp:\/\//, '')}`
54
+ return withScheme.replace(/\/+$/, '')
55
+ }
56
+
57
+ /** '' sentinel → provider default model. */
58
+ export function resolveEmbedModel(model?: string): string {
59
+ const m = (model ?? '').trim()
60
+ return m === '' ? DEFAULT_EMBED_MODEL : m
61
+ }
62
+
63
+ /** Truncate a string to ≤ maxBytes UTF-8 bytes WITHOUT splitting a multibyte
64
+ * character: cut at the byte cap, then back off over any continuation bytes
65
+ * (0b10xxxxxx) so the decoded tail is never a replacement char. */
66
+ export function truncateUtf8(text: string, maxBytes: number = MAX_TEXT_BYTES): string {
67
+ if (maxBytes <= 0) return ''
68
+ const buf = Buffer.from(text, 'utf8')
69
+ if (buf.length <= maxBytes) return text
70
+ let end = maxBytes
71
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end -= 1
72
+ return buf.subarray(0, end).toString('utf8')
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Outcomes — the explicit-degradation vocabulary. Callers branch on `ok`;
77
+ // `outcome` is the human/queryable why (降级状态可查, spec §3.1).
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type EmbeddingFailureOutcome =
81
+ | 'provider_off' // config provider is the '' sentinel — semantic layer off
82
+ | 'unsupported_provider' // configured, but not a v1 provider (no remote!)
83
+ | 'empty_input' // nothing to embed — refused before any network activity
84
+ | 'network' // connect refused / DNS / socket — ollama not serving
85
+ | 'timeout' // the one AbortSignal fired
86
+ | 'model_missing' // ollama answered 404: model not pulled
87
+ | 'http_4xx'
88
+ | 'http_5xx'
89
+ | 'bad_json'
90
+ | 'bad_shape' // 200 but the embeddings array is malformed / dims disagree
91
+
92
+ export interface EmbedBatchOk {
93
+ ok: true
94
+ /** Vectors aligned 1:1 with the (truncated) input texts. */
95
+ vectors: number[][]
96
+ model: string
97
+ dim: number
98
+ latencyMs: number
99
+ }
100
+
101
+ export interface EmbedQueryOk {
102
+ ok: true
103
+ vector: number[]
104
+ model: string
105
+ dim: number
106
+ latencyMs: number
107
+ }
108
+
109
+ export interface EmbedFailure {
110
+ ok: false
111
+ outcome: EmbeddingFailureOutcome
112
+ message: string
113
+ host: string
114
+ model: string
115
+ latencyMs: number
116
+ }
117
+
118
+ export type EmbedResult = EmbedBatchOk | EmbedFailure
119
+ export type EmbedQueryResult = EmbedQueryOk | EmbedFailure
120
+ export type ProbeResult = { ok: true; host: string; model: string; dim: number; latencyMs: number } | EmbedFailure
121
+
122
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
123
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
124
+ }
125
+
126
+ /** Shared request path: POST {host}/api/embed, outcome-mode failures only. */
127
+ async function embedRequest(
128
+ texts: readonly string[],
129
+ config: EmbeddingConfig,
130
+ timeoutMs: number,
131
+ ): Promise<EmbedResult> {
132
+ const provider = (config.provider ?? '').trim()
133
+ if (provider === '') {
134
+ return { ok: false, outcome: 'provider_off', message: "embeddingProvider is '' — the semantic layer is off (pure lexical).", host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
135
+ }
136
+ if (provider !== SUPPORTED_PROVIDER) {
137
+ return { ok: false, outcome: 'unsupported_provider', message: `embeddingProvider '${provider}' is not supported in v1 (only '${SUPPORTED_PROVIDER}'); remote providers may not enter the fast lane.`, host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
138
+ }
139
+ if (texts.length === 0) {
140
+ return { ok: false, outcome: 'empty_input', message: 'no texts to embed.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
141
+ }
142
+ const host = resolveEmbedHost(config.host)
143
+ const model = resolveEmbedModel(config.model)
144
+ const started = Date.now()
145
+ let res: Response
146
+ try {
147
+ res = await fetch(`${host}/api/embed`, {
148
+ method: 'POST',
149
+ headers: { 'content-type': 'application/json' },
150
+ body: JSON.stringify({ model, input: texts.map((t) => truncateUtf8(t)) }),
151
+ signal: AbortSignal.timeout(timeoutMs),
152
+ })
153
+ } catch (err) {
154
+ const outcome: EmbeddingFailureOutcome =
155
+ err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'network'
156
+ return { ok: false, outcome, message: `${outcome}: ${err instanceof Error ? err.message : String(err)}`, host, model, latencyMs: Date.now() - started }
157
+ }
158
+ if (!res.ok) {
159
+ const text = (await res.text().catch(() => '')).slice(0, 300)
160
+ // ollama answers 404 {"error":"model \"…\" not found, try pulling it first"}
161
+ const outcome: EmbeddingFailureOutcome = res.status === 404 ? 'model_missing' : res.status >= 500 ? 'http_5xx' : 'http_4xx'
162
+ return { ok: false, outcome, message: `ollama responded ${res.status}: ${text}`, host, model, latencyMs: Date.now() - started }
163
+ }
164
+ let json: unknown
165
+ try {
166
+ json = await res.json()
167
+ } catch (err) {
168
+ return { ok: false, outcome: 'bad_json', message: `non-JSON body: ${err instanceof Error ? err.message : String(err)}`, host, model, latencyMs: Date.now() - started }
169
+ }
170
+ const vectors = isPlainObject(json) ? json.embeddings : undefined
171
+ if (
172
+ !Array.isArray(vectors) ||
173
+ vectors.length !== texts.length ||
174
+ !vectors.every((v) => Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === 'number' && Number.isFinite(n))) ||
175
+ new Set(vectors.map((v) => (v as number[]).length)).size !== 1
176
+ ) {
177
+ return { ok: false, outcome: 'bad_shape', message: 'embeddings must be a rectangular [texts.length][dim] array of finite numbers.', host, model, latencyMs: Date.now() - started }
178
+ }
179
+ return { ok: true, vectors: vectors as number[][], model, dim: (vectors[0] as number[]).length, latencyMs: Date.now() - started }
180
+ }
181
+
182
+ /** Batch-embed texts (index build/rebuild path). Vectors align 1:1 with the
183
+ * input order; each text is truncated to MAX_TEXT_BYTES first. */
184
+ export async function embedTexts(
185
+ texts: readonly string[],
186
+ config: EmbeddingConfig = {},
187
+ opts: { timeoutMs?: number } = {},
188
+ ): Promise<EmbedResult> {
189
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_BATCH_TIMEOUT_MS
190
+ return embedRequest(texts, config, timeoutMs)
191
+ }
192
+
193
+ /** Embed one query (reranker hot path, 2s budget). Empty/whitespace queries
194
+ * fail locally with 'empty_input' — nothing leaves the process. */
195
+ export async function embedQuery(
196
+ query: string,
197
+ config: EmbeddingConfig = {},
198
+ opts: { timeoutMs?: number } = {},
199
+ ): Promise<EmbedQueryResult> {
200
+ if ((query ?? '').trim() === '') {
201
+ return { ok: false, outcome: 'empty_input', message: 'empty query.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
202
+ }
203
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_QUERY_TIMEOUT_MS
204
+ const r = await embedRequest([query], config, timeoutMs)
205
+ if (!r.ok) return r
206
+ return { ok: true, vector: r.vectors[0]!, model: r.model, dim: r.dim, latencyMs: r.latencyMs }
207
+ }
208
+
209
+ /**
210
+ * Probe: is the server there, is the model pulled? One micro embed call —
211
+ * unlike a /api/tags listing it validates the FULL path (server + model +
212
+ * response shape) and yields the vector dim for index validation, at the
213
+ * same 15-30ms cost (A1 §5). Structured result; never throws.
214
+ */
215
+ export async function probeEmbedding(config: EmbeddingConfig = {}, opts: { timeoutMs?: number } = {}): Promise<ProbeResult> {
216
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_QUERY_TIMEOUT_MS
217
+ const r = await embedRequest(['dsh-topics-memory semantic layer probe'], config, timeoutMs)
218
+ if (!r.ok) return r
219
+ return { ok: true, host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), dim: r.dim, latencyMs: r.latencyMs }
220
+ }
221
+
222
+ /**
223
+ * Build an embedder handle for retrieval's `ensureIndexFresh` — a callable
224
+ * embedTexts bound to this config, carrying the RESOLVED model identity the
225
+ * index must match. ensureIndexFresh reads `.model` only; the actual
226
+ * rebuild stays with the caller.
227
+ */
228
+ export function embedderWith(
229
+ config: EmbeddingConfig,
230
+ ): ((texts: readonly string[]) => Promise<EmbedResult>) & { model: string } {
231
+ const fn = (texts: readonly string[]) => embedTexts(texts, config)
232
+ return Object.assign(fn, { model: resolveEmbedModel(config.model) })
233
+ }
234
+
235
+ // --- Sync query embed (Phase-2 wiring) --------------------------------------
236
+ // The spliced hot path is synchronous (the chancelu lesson): retrieveSync
237
+ // cannot await. A local ollama query is 15-30ms steady state (A1 §5), so a
238
+ // bounded SYNC subprocess call keeps the reranker on the hot lane inside the
239
+ // ADR 0017 local exception — hard-capped so a wedged ollama loses the race,
240
+ // never the injection. curl ships with the host OS; node has no sync HTTP.
241
+
242
+ /** Hard sync budget for the hot-lane query embed. Generous over the measured
243
+ * 15-30ms, merciless for a wedged server. */
244
+ export const EMBED_QUERY_SYNC_TIMEOUT_MS = 250
245
+
246
+ export function embedQuerySync(query: string, config: EmbeddingConfig = {}): EmbedQueryResult {
247
+ const provider = (config.provider ?? '').trim()
248
+ if (provider === '') {
249
+ return { ok: false, outcome: 'provider_off', message: "embeddingProvider is '' — the semantic layer is off.", host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
250
+ }
251
+ if (provider !== SUPPORTED_PROVIDER) {
252
+ return { ok: false, outcome: 'unsupported_provider', message: `embeddingProvider '${provider}' is not supported in v1.`, host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
253
+ }
254
+ if ((query ?? '').trim() === '') {
255
+ return { ok: false, outcome: 'empty_input', message: 'empty query.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 }
256
+ }
257
+ const host = resolveEmbedHost(config.host)
258
+ const model = resolveEmbedModel(config.model)
259
+ const started = Date.now()
260
+ let raw: string
261
+ try {
262
+ raw = execFileSync(
263
+ 'curl',
264
+ [
265
+ '-sS',
266
+ '--max-time', String(Math.ceil(EMBED_QUERY_SYNC_TIMEOUT_MS / 1000)),
267
+ '-X', 'POST', `${host}/api/embed`,
268
+ '-H', 'content-type: application/json',
269
+ '-d', JSON.stringify({ model, input: [truncateUtf8(query)] }),
270
+ ],
271
+ { timeout: EMBED_QUERY_SYNC_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
272
+ )
273
+ } catch (err) {
274
+ const message = err instanceof Error ? err.message : String(err)
275
+ const outcome: EmbeddingFailureOutcome = /timed out|ETIMEDOUT|maxTime/i.test(message) ? 'timeout' : 'network'
276
+ return { ok: false, outcome, message: `${outcome}: ${message}`, host, model, latencyMs: Date.now() - started }
277
+ }
278
+ let json: unknown
279
+ try {
280
+ json = JSON.parse(raw)
281
+ } catch {
282
+ return { ok: false, outcome: 'bad_json', message: 'ollama returned non-JSON (sync path).', host, model, latencyMs: Date.now() - started }
283
+ }
284
+ const embeddings = isPlainObject(json) ? (json as Record<string, unknown>).embeddings : undefined
285
+ const first = Array.isArray(embeddings) && embeddings.length > 0 ? embeddings[0] : undefined
286
+ if (!Array.isArray(first)) {
287
+ return { ok: false, outcome: 'bad_shape', message: 'ollama response has no embeddings[0] array (sync path).', host, model, latencyMs: Date.now() - started }
288
+ }
289
+ const vector = (first as unknown[]).filter((n): n is number => typeof n === 'number')
290
+ if (vector.length === 0 || vector.length !== (first as unknown[]).length) {
291
+ return { ok: false, outcome: 'bad_shape', message: 'embeddings[0] malformed (sync path).', host, model, latencyMs: Date.now() - started }
292
+ }
293
+ return { ok: true, vector, model, dim: vector.length, latencyMs: Date.now() - started }
294
+ }
package/src/ilog.ts CHANGED
@@ -27,6 +27,14 @@ export interface SlowItem {
27
27
  why: string
28
28
  }
29
29
 
30
+ /**
31
+ * Slow-lane full-lifecycle outcome (A4 决议 3, spec §3.2): where a slow-lane
32
+ * run (or round with a slow-lane stake) ended. Precedent = decisions.jsonl's
33
+ * outcome enum — a closed vocabulary so 「没跑」(no-run/no-claim) and 「跑了
34
+ * 全败」(llm-error/timeout) are finally distinguishable in the log.
35
+ */
36
+ export type SlowOutcome = 'no-run' | 'no-claim' | 'no-candidates' | 'llm-error' | 'timeout' | 'delivered' | 'expired'
37
+
30
38
  export interface InjectionRecord {
31
39
  at: string
32
40
  sessionId?: string
@@ -60,6 +68,33 @@ export interface InjectionRecord {
60
68
  slowMs?: number
61
69
  /** Slow-lane pointers this round, in delivery order. */
62
70
  slow?: SlowItem[]
71
+ /**
72
+ * Slow-lane disposition for this round (A4 决议 3, spec §8.2): `delivered`
73
+ * when a pending was consumed and packed, `expired` at its hard bounds, and
74
+ * the producing run's failure point (no-run/no-claim/no-candidates/
75
+ * llm-error/timeout) when a spliced arrived with no pending. Optional —
76
+ * older lines predate the field and pure fast-lane rounds may omit it.
77
+ * Written once with the record, never backfilled (ilog append-only).
78
+ */
79
+ slowOutcome?: SlowOutcome
80
+ }
81
+
82
+ /**
83
+ * One topic_open event (pointer-open log, v4 §4.3). Everything beyond
84
+ * `slug`/`at` is optional and late-added: older lines carry only the
85
+ * original pair, and every reader must tolerate the rest being absent.
86
+ */
87
+ export interface OpenRecord {
88
+ slug: string
89
+ at: string
90
+ /** Session that issued the topic_open, when the host surface exposed one. */
91
+ sessionId?: string
92
+ /** Attribution: opened off an injected pointer, or off a topic_search hit. */
93
+ source?: 'pointer' | 'search'
94
+ /** Retrieval score of the matched injection hit (source='pointer' only). */
95
+ score?: number
96
+ /** open time − the matched injection record's at, in ms (source='pointer' only). */
97
+ sinceInjectionMs?: number
63
98
  }
64
99
 
65
100
  export interface AggregateStats {
@@ -191,3 +226,274 @@ export function aggregateUsage(
191
226
  }
192
227
  return out
193
228
  }
229
+
230
+ // ---------------------------------------------------------------------
231
+ // Injection-vs-open metrics (v5) — open enrichment attribution + the
232
+ // /topics stats panel aggregate. All pure: file I/O stays in store/service.
233
+ // ---------------------------------------------------------------------
234
+
235
+ /** How many tail injection records the cheap open attribution scans. */
236
+ export const OPEN_ATTRIBUTION_TAIL = 50
237
+ /** An injected pointer stays attributable for 48h (a work session's span). */
238
+ export const OPEN_ATTRIBUTION_WINDOW_MS = 48 * 3_600_000
239
+
240
+ /**
241
+ * Cheap source attribution for one open event: scan the injection tail
242
+ * (newest first) for a same-session record within the attribution window
243
+ * where the slug was truly delivered to the model's eyes. That is hits
244
+ * minus echoed/dropped (echo suppression and budget drops never rendered),
245
+ * PLUS slow-lane deliveries, PLUS deduped hits — dedup only blocks
246
+ * re-packing THIS round; the pointer rendered earlier in the same session
247
+ * and the model saw it (deliberately wider than renderedPointers: the panel
248
+ * denominator asks "did it render this round", attribution asks "did the
249
+ * model ever see it"). A fast-hit match carries that hit's score; a
250
+ * slow-only match carries none (slow pointers have no lexical score).
251
+ * Anything else (no session id, other session, expired, never rendered)
252
+ * is `search`.
253
+ */
254
+ export function attributeOpen(
255
+ injections: readonly InjectionRecord[],
256
+ slug: string,
257
+ sessionId: string | undefined,
258
+ nowMs: number,
259
+ ): { source: 'pointer' | 'search'; score?: number; sinceInjectionMs?: number } {
260
+ if (sessionId === undefined) return { source: 'search' }
261
+ for (let i = injections.length - 1; i >= 0; i -= 1) {
262
+ const record = injections[i]
263
+ if (record.sessionId !== sessionId) continue
264
+ const atMs = Date.parse(record.at)
265
+ if (!Number.isFinite(atMs) || nowMs - atMs > OPEN_ATTRIBUTION_WINDOW_MS) continue
266
+ const sinceInjectionMs = Math.max(0, nowMs - atMs)
267
+ const suppressed = new Set<string>([...(record.echoed ?? []), ...(record.dropped ?? []).map((d) => d.slug)])
268
+ const hit = record.hits.find((h) => h.slug === slug && !suppressed.has(h.slug))
269
+ if (hit !== undefined) return { source: 'pointer', score: hit.score, sinceInjectionMs }
270
+ if ((record.slow ?? []).some((s) => s.slug === slug)) return { source: 'pointer', sinceInjectionMs }
271
+ }
272
+ return { source: 'search' }
273
+ }
274
+
275
+ /** Score bands for the open-rate-by-score cut of the stats panel. */
276
+ export const SCORE_BANDS: readonly { label: string; lo: number; hi: number }[] = [
277
+ { label: '[0.3,0.5)', lo: 0.3, hi: 0.5 },
278
+ { label: '[0.5,1)', lo: 0.5, hi: 1 },
279
+ { label: '[1,2)', lo: 1, hi: 2 },
280
+ { label: '[2,5)', lo: 2, hi: 5 },
281
+ { label: '[5,∞)', lo: 5, hi: Number.POSITIVE_INFINITY },
282
+ ]
283
+
284
+ /** Zero-open listing threshold: injections at or above this with zero opens. */
285
+ export const ZERO_OPEN_MIN_INJECTIONS = 5
286
+ /** Rolling window of the zero-open rule: only rendered injections inside it count. */
287
+ export const ZERO_OPEN_WINDOW_DAYS = 30
288
+ /** Injection-tail rows the decay read scans — a bound comfortably covering
289
+ * the 30-day window at observed round volumes. */
290
+ export const ZERO_OPEN_TAIL = 500
291
+
292
+ /**
293
+ * Slugs that truly rendered in one round, in delivery order: raw hits minus
294
+ * the round's exclusions (deduped/echoed/dropped were never assembled), plus
295
+ * delivered slow pointers; a slug the fast lane already delivered counts
296
+ * once even when the slow lane also reports it delivered ("not packed
297
+ * twice"). Scores ride along from the fast hit only — slow pointers carry
298
+ * no lexical score. Shared by the stats panel and the zero-open rule.
299
+ */
300
+ export function renderedPointers(record: InjectionRecord): { slug: string; score?: number }[] {
301
+ const excluded = new Set<string>([
302
+ ...(record.deduped ?? []),
303
+ ...(record.echoed ?? []),
304
+ ...(record.dropped ?? []).map((d) => d.slug),
305
+ ])
306
+ const seen = new Set<string>()
307
+ const out: { slug: string; score?: number }[] = []
308
+ for (const h of record.hits) {
309
+ if (excluded.has(h.slug) || seen.has(h.slug)) continue
310
+ seen.add(h.slug)
311
+ out.push({ slug: h.slug, score: h.score })
312
+ }
313
+ for (const s of record.slow ?? []) {
314
+ if (seen.has(s.slug)) continue
315
+ seen.add(s.slug)
316
+ out.push({ slug: s.slug })
317
+ }
318
+ return out
319
+ }
320
+
321
+ /**
322
+ * Zero-open slug set — the single shared 零打开高频 rule (v5): slugs whose
323
+ * truly-rendered pointer count within the rolling ZERO_OPEN_WINDOW_DAYS
324
+ * reaches ZERO_OPEN_MIN_INJECTIONS while the WHOLE open log has never
325
+ * recorded an open (wide scope — an open long after the last injection
326
+ * still disqualifies). Returns slug → windowed rendered-pointer count for
327
+ * the qualifying slugs; `/topics stats` (the panel's zero-open list) and
328
+ * the retrieval decay (`zeroOpenDecay`) both consume this one
329
+ * implementation. Records with unparsable `at` cannot prove window
330
+ * membership and are skipped (same tolerance as aggregateUsage).
331
+ */
332
+ export function aggregateZeroOpen(
333
+ injections: readonly InjectionRecord[],
334
+ opens: readonly { slug: string }[],
335
+ nowMs: number,
336
+ ): Map<string, number> {
337
+ const cutoff = nowMs - ZERO_OPEN_WINDOW_DAYS * 86_400_000
338
+ const counts = new Map<string, number>()
339
+ for (const record of injections) {
340
+ const at = Date.parse(record.at)
341
+ if (!Number.isFinite(at) || at < cutoff) continue
342
+ for (const p of renderedPointers(record)) {
343
+ counts.set(p.slug, (counts.get(p.slug) ?? 0) + 1)
344
+ }
345
+ }
346
+ const opened = new Set(opens.map((o) => o.slug))
347
+ const out = new Map<string, number>()
348
+ for (const [slug, n] of counts) {
349
+ if (n >= ZERO_OPEN_MIN_INJECTIONS && !opened.has(slug)) out.set(slug, n)
350
+ }
351
+ return out
352
+ }
353
+
354
+ export interface OpenTopicRow {
355
+ slug: string
356
+ /** Truly rendered pointers for this slug (deduped/echoed/dropped excluded). */
357
+ injections: number
358
+ /** Opens attributed by slug over the whole open log (wide scope, by design). */
359
+ opens: number
360
+ openRate: number
361
+ }
362
+
363
+ export interface OpenBandRow {
364
+ label: string
365
+ /** Rendered fast pointers whose hit score falls in this band. */
366
+ pointers: number
367
+ /** Pointer-sourced opens whose attributed score falls in this band. */
368
+ opens: number
369
+ openRate: number
370
+ }
371
+
372
+ export interface OpenPanelStats {
373
+ /** Rendered pointer entries across the window: hits − deduped − echoed − dropped, plus slow pointers (same-slug fast+slow pairs counted once). */
374
+ pointerEntries: number
375
+ /** DISTINCT slugs from the window's rendered set with an in-window open
376
+ * (search-sourced and never-rendered slugs excluded; one per slug). */
377
+ opensInWindow: number
378
+ openRate: number
379
+ topics: OpenTopicRow[]
380
+ zeroOpen: { slug: string; injections: number }[]
381
+ zeroOpenTotal: number
382
+ pointerOpens: number
383
+ searchOpens: number
384
+ /** False when no open record carries a source field (pre-enrichment data). */
385
+ hasSourceData: boolean
386
+ bands: OpenBandRow[]
387
+ }
388
+
389
+ /**
390
+ * Aggregate the injection-vs-open panel from the two JSONL windows. The
391
+ * per-topic/band denominators count only pointers that truly rendered
392
+ * (deduped/echoed/dropped excluded — same exclusion family as
393
+ * aggregateUsage); the header numerator counts only DISTINCT slugs from
394
+ * that rendered set with an open inside the injection window — a search
395
+ * open or an open of a never-rendered slug cannot enter, and repeat opens
396
+ * of the same slug count once, so the rate can no longer be pinned at
397
+ * 100% by repeated or off-scope opens. Opens per slug stay wide-scope
398
+ * (the model may open a topic long after its last injection). The
399
+ * zero-open list shares the decay's rule: rendered injections counted
400
+ * within the rolling 30-day window (anchored at the newest round), opens
401
+ * wide-scope.
402
+ */
403
+ export function aggregateOpenPanel(
404
+ injections: readonly InjectionRecord[],
405
+ opens: readonly OpenRecord[],
406
+ ): OpenPanelStats {
407
+ // Numerator window: the earliest round represented in the denominator;
408
+ // the zero-open anchor is the newest round — the panel describes the log
409
+ // window it was fed, so a stale log still shows the topics that
410
+ // dominated it. The rendered-slug set below shares that window: only
411
+ // opens of slugs this window rendered can enter the numerator.
412
+ let windowStart = Number.POSITIVE_INFINITY
413
+ let windowEnd = Number.NEGATIVE_INFINITY
414
+ for (const r of injections) {
415
+ const atMs = Date.parse(r.at)
416
+ if (!Number.isFinite(atMs)) continue
417
+ if (atMs < windowStart) windowStart = atMs
418
+ if (atMs > windowEnd) windowEnd = atMs
419
+ }
420
+ const topicCounts = new Map<string, number>()
421
+ const bandPointers = new Array<number>(SCORE_BANDS.length).fill(0)
422
+ let pointerEntries = 0
423
+ const renderedSlugs = new Set<string>()
424
+ for (const r of injections) {
425
+ // renderedPointers keeps the entry/per-topic counts honest: excluded
426
+ // slugs never counted, a fast+slow pair for the same slug counted once.
427
+ for (const p of renderedPointers(r)) {
428
+ pointerEntries += 1
429
+ renderedSlugs.add(p.slug)
430
+ topicCounts.set(p.slug, (topicCounts.get(p.slug) ?? 0) + 1)
431
+ const score = p.score
432
+ if (score === undefined) continue
433
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi)
434
+ if (bi >= 0) bandPointers[bi] += 1
435
+ }
436
+ }
437
+ const opensBySlug = new Map<string, number>()
438
+ const bandOpens = new Array<number>(SCORE_BANDS.length).fill(0)
439
+ let pointerOpens = 0
440
+ let searchOpens = 0
441
+ let hasSourceData = false
442
+ // Numerator: DISTINCT slugs from the window's rendered set with an
443
+ // in-window open — search-sourced opens never count (the model found the
444
+ // topic by asking, not by following a rendered pointer), a never-rendered
445
+ // slug never counts, and repeat opens of the same slug count once.
446
+ const openedSlugsInWindow = new Set<string>()
447
+ for (const o of opens) {
448
+ opensBySlug.set(o.slug, (opensBySlug.get(o.slug) ?? 0) + 1)
449
+ if (o.source === 'pointer' || o.source === 'search') hasSourceData = true
450
+ if (o.source === 'pointer') pointerOpens += 1
451
+ else if (o.source === 'search') searchOpens += 1
452
+ if (o.source === 'pointer' && typeof o.score === 'number') {
453
+ const score = o.score
454
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi)
455
+ if (bi >= 0) bandOpens[bi] += 1
456
+ }
457
+ if (o.source === 'search') continue
458
+ const atMs = Date.parse(o.at)
459
+ // An open joins the windowed numerator unless it is provably older than
460
+ // the window's earliest round (unparsable stamps cannot prove either
461
+ // way) — and only when the window actually rendered its slug.
462
+ if (Number.isFinite(windowStart) && Number.isFinite(atMs) && atMs < windowStart) continue
463
+ if (!renderedSlugs.has(o.slug)) continue
464
+ openedSlugsInWindow.add(o.slug)
465
+ }
466
+ const opensInWindow = openedSlugsInWindow.size
467
+ const rate = (num: number, den: number): number => (den === 0 ? 0 : Math.min(1, num / den))
468
+ const topics: OpenTopicRow[] = [...topicCounts.entries()]
469
+ .map(([slug, injections]) => {
470
+ const opens = opensBySlug.get(slug) ?? 0
471
+ return { slug, injections, opens, openRate: rate(opens, injections) }
472
+ })
473
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug))
474
+ // Zero-open cut: the shared 30-day rule (aggregateZeroOpen), anchored at
475
+ // the newest round so window semantics match the decay's rolling window
476
+ // without growing a time-bomb dependency on the wall clock.
477
+ const zeroOpenCounts =
478
+ windowEnd === Number.NEGATIVE_INFINITY ? new Map<string, number>() : aggregateZeroOpen(injections, opens, windowEnd)
479
+ const zeroOpenAll = [...zeroOpenCounts.entries()]
480
+ .map(([slug, injections]) => ({ slug, injections }))
481
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug))
482
+ return {
483
+ pointerEntries,
484
+ opensInWindow,
485
+ openRate: rate(opensInWindow, pointerEntries),
486
+ topics: topics.slice(0, 10),
487
+ zeroOpen: zeroOpenAll.slice(0, 10).map((t) => ({ slug: t.slug, injections: t.injections })),
488
+ zeroOpenTotal: zeroOpenAll.length,
489
+ pointerOpens,
490
+ searchOpens,
491
+ hasSourceData,
492
+ bands: SCORE_BANDS.map((b, i) => ({
493
+ label: b.label,
494
+ pointers: bandPointers[i],
495
+ opens: bandOpens[i],
496
+ openRate: rate(bandOpens[i], bandPointers[i]),
497
+ })),
498
+ }
499
+ }
package/src/index.ts CHANGED
@@ -501,8 +501,9 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
501
501
  if (cfg.autoInject) {
502
502
  const consumed = slowLane.consume(sessionId, observer.turnCountOf(sessionId))
503
503
  if (consumed !== undefined) {
504
- if ('pending' in consumed) slow = consumed.pending
505
- else slowExpired = consumed.expired
504
+ if ('pending' in consumed) {
505
+ slow = { ...consumed.pending, outcome: consumed.pending.outcome }
506
+ } else slowExpired = consumed.expired
506
507
  }
507
508
  }
508
509
  const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
@@ -708,7 +709,10 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
708
709
  // and its own settle hook does the release.
709
710
  const run = distiller.request(sessionId, reason)
710
711
  if (run !== undefined) {
711
- void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined)
712
+ void run
713
+ .finally(() => releaseSessionLlm(sessionId))
714
+ .then(() => service.refreshEmbeddingIndex())
715
+ .catch(() => undefined)
712
716
  }
713
717
  })
714
718
 
@@ -916,7 +920,11 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
916
920
  // no-adapter detail instead
917
921
  }
918
922
  const run = distiller.request(sessionId, 'boot-replay')
919
- if (run !== undefined) void run.catch(() => undefined)
923
+ if (run !== undefined) {
924
+ void run
925
+ .then(() => service.refreshEmbeddingIndex())
926
+ .catch(() => undefined)
927
+ }
920
928
  })
921
929
  .catch(() => undefined)
922
930
  // Consolidation cadence check — after the pull (freshest pool),
@@ -951,6 +959,9 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
951
959
  })
952
960
  .catch(() => undefined)
953
961
  }
962
+ // Embedding index maintenance (A4/A6, spec §3): boot-time freshness
963
+ // check + incremental rebuild — provider '' / packageMode off = no-op.
964
+ void service.refreshEmbeddingIndex().catch(() => undefined)
954
965
  })
955
966
  .catch((error: unknown) => {
956
967
  warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
@@ -1075,7 +1086,11 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
1075
1086
  ? { ok: false, reason: 'in-flight', created: [], updated: [], marked: 0 }
1076
1087
  : { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 }
1077
1088
  }
1078
- return run.finally(() => releaseSessionLlm(sessionId))
1089
+ void run
1090
+ .finally(() => releaseSessionLlm(sessionId))
1091
+ .then(() => service.refreshEmbeddingIndex())
1092
+ .catch(() => undefined)
1093
+ return run
1079
1094
  }
1080
1095
  // Manual /topics consolidate trigger: same lane as the cadence path, same
1081
1096
  // trigger-time llm capture and in-flight guard (the command runs inside a
@@ -1146,6 +1161,9 @@ const DEFAULTS: TopicsConfigValue = {
1146
1161
  distillMaxModelCalls: 8,
1147
1162
  consolidateCadence: 'daily',
1148
1163
  deprecatedTtlDays: 15,
1164
+ embeddingProvider: '',
1165
+ embeddingModel: '',
1166
+ packageMode: 'off',
1149
1167
  usageBoost: 0.15,
1150
1168
  pushDebounceSeconds: 45,
1151
1169
  }