@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,208 @@
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
+ import { execFileSync } from 'node:child_process';
16
+ /** Default ollama bind (the plugin's own default; OLLAMA_HOST overrides). */
17
+ export const DEFAULT_EMBED_HOST = 'http://127.0.0.1:11434';
18
+ /** Provider default model when embeddingModel is the '' sentinel
19
+ * (spec §7: ollama → nomic-embed-text, 768 dims, A1 §5 measured). */
20
+ export const DEFAULT_EMBED_MODEL = 'nomic-embed-text';
21
+ /** Query-side timeout — the reranker sits in the spliced hot path; a slow
22
+ * ollama must lose the race, not the injection. */
23
+ export const EMBED_QUERY_TIMEOUT_MS = 2000;
24
+ /** Batch-side timeout (index rebuild: A1 measured ~9.5s for 137 topics). */
25
+ export const EMBED_BATCH_TIMEOUT_MS = 30_000;
26
+ /** Per-text byte cap before anything leaves the process (query端 p50=153 tok
27
+ * ~ well under this; the cap only bites on paste pollution). */
28
+ export const MAX_TEXT_BYTES = 2048;
29
+ /** The only provider v1 speaks. Anything else is an explicit
30
+ * unsupported_provider outcome, not a silent attempt. */
31
+ export const SUPPORTED_PROVIDER = 'ollama';
32
+ /** Resolve the ollama base URL: explicit arg wins, then OLLAMA_HOST, then
33
+ * the localhost default. Bare `host:port` values get the http:// prefix
34
+ * (ollama's own OLLAMA_HOST convention); trailing slashes are trimmed. */
35
+ export function resolveEmbedHost(explicit) {
36
+ const raw = (explicit ?? process.env.OLLAMA_HOST ?? '').trim();
37
+ const withScheme = raw === '' ? DEFAULT_EMBED_HOST : /^https?:\/\//.test(raw) ? raw : `http://${raw.replace(/^tcp:\/\//, '')}`;
38
+ return withScheme.replace(/\/+$/, '');
39
+ }
40
+ /** '' sentinel → provider default model. */
41
+ export function resolveEmbedModel(model) {
42
+ const m = (model ?? '').trim();
43
+ return m === '' ? DEFAULT_EMBED_MODEL : m;
44
+ }
45
+ /** Truncate a string to ≤ maxBytes UTF-8 bytes WITHOUT splitting a multibyte
46
+ * character: cut at the byte cap, then back off over any continuation bytes
47
+ * (0b10xxxxxx) so the decoded tail is never a replacement char. */
48
+ export function truncateUtf8(text, maxBytes = MAX_TEXT_BYTES) {
49
+ if (maxBytes <= 0)
50
+ return '';
51
+ const buf = Buffer.from(text, 'utf8');
52
+ if (buf.length <= maxBytes)
53
+ return text;
54
+ let end = maxBytes;
55
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
56
+ end -= 1;
57
+ return buf.subarray(0, end).toString('utf8');
58
+ }
59
+ function isPlainObject(v) {
60
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
61
+ }
62
+ /** Shared request path: POST {host}/api/embed, outcome-mode failures only. */
63
+ async function embedRequest(texts, config, timeoutMs) {
64
+ const provider = (config.provider ?? '').trim();
65
+ if (provider === '') {
66
+ 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 };
67
+ }
68
+ if (provider !== SUPPORTED_PROVIDER) {
69
+ 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 };
70
+ }
71
+ if (texts.length === 0) {
72
+ return { ok: false, outcome: 'empty_input', message: 'no texts to embed.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 };
73
+ }
74
+ const host = resolveEmbedHost(config.host);
75
+ const model = resolveEmbedModel(config.model);
76
+ const started = Date.now();
77
+ let res;
78
+ try {
79
+ res = await fetch(`${host}/api/embed`, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({ model, input: texts.map((t) => truncateUtf8(t)) }),
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ });
85
+ }
86
+ catch (err) {
87
+ const outcome = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'network';
88
+ return { ok: false, outcome, message: `${outcome}: ${err instanceof Error ? err.message : String(err)}`, host, model, latencyMs: Date.now() - started };
89
+ }
90
+ if (!res.ok) {
91
+ const text = (await res.text().catch(() => '')).slice(0, 300);
92
+ // ollama answers 404 {"error":"model \"…\" not found, try pulling it first"}
93
+ const outcome = res.status === 404 ? 'model_missing' : res.status >= 500 ? 'http_5xx' : 'http_4xx';
94
+ return { ok: false, outcome, message: `ollama responded ${res.status}: ${text}`, host, model, latencyMs: Date.now() - started };
95
+ }
96
+ let json;
97
+ try {
98
+ json = await res.json();
99
+ }
100
+ catch (err) {
101
+ return { ok: false, outcome: 'bad_json', message: `non-JSON body: ${err instanceof Error ? err.message : String(err)}`, host, model, latencyMs: Date.now() - started };
102
+ }
103
+ const vectors = isPlainObject(json) ? json.embeddings : undefined;
104
+ if (!Array.isArray(vectors) ||
105
+ vectors.length !== texts.length ||
106
+ !vectors.every((v) => Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === 'number' && Number.isFinite(n))) ||
107
+ new Set(vectors.map((v) => v.length)).size !== 1) {
108
+ 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 };
109
+ }
110
+ return { ok: true, vectors: vectors, model, dim: vectors[0].length, latencyMs: Date.now() - started };
111
+ }
112
+ /** Batch-embed texts (index build/rebuild path). Vectors align 1:1 with the
113
+ * input order; each text is truncated to MAX_TEXT_BYTES first. */
114
+ export async function embedTexts(texts, config = {}, opts = {}) {
115
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_BATCH_TIMEOUT_MS;
116
+ return embedRequest(texts, config, timeoutMs);
117
+ }
118
+ /** Embed one query (reranker hot path, 2s budget). Empty/whitespace queries
119
+ * fail locally with 'empty_input' — nothing leaves the process. */
120
+ export async function embedQuery(query, config = {}, opts = {}) {
121
+ if ((query ?? '').trim() === '') {
122
+ return { ok: false, outcome: 'empty_input', message: 'empty query.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 };
123
+ }
124
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_QUERY_TIMEOUT_MS;
125
+ const r = await embedRequest([query], config, timeoutMs);
126
+ if (!r.ok)
127
+ return r;
128
+ return { ok: true, vector: r.vectors[0], model: r.model, dim: r.dim, latencyMs: r.latencyMs };
129
+ }
130
+ /**
131
+ * Probe: is the server there, is the model pulled? One micro embed call —
132
+ * unlike a /api/tags listing it validates the FULL path (server + model +
133
+ * response shape) and yields the vector dim for index validation, at the
134
+ * same 15-30ms cost (A1 §5). Structured result; never throws.
135
+ */
136
+ export async function probeEmbedding(config = {}, opts = {}) {
137
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : EMBED_QUERY_TIMEOUT_MS;
138
+ const r = await embedRequest(['dsh-topics-memory semantic layer probe'], config, timeoutMs);
139
+ if (!r.ok)
140
+ return r;
141
+ return { ok: true, host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), dim: r.dim, latencyMs: r.latencyMs };
142
+ }
143
+ /**
144
+ * Build an embedder handle for retrieval's `ensureIndexFresh` — a callable
145
+ * embedTexts bound to this config, carrying the RESOLVED model identity the
146
+ * index must match. ensureIndexFresh reads `.model` only; the actual
147
+ * rebuild stays with the caller.
148
+ */
149
+ export function embedderWith(config) {
150
+ const fn = (texts) => embedTexts(texts, config);
151
+ return Object.assign(fn, { model: resolveEmbedModel(config.model) });
152
+ }
153
+ // --- Sync query embed (Phase-2 wiring) --------------------------------------
154
+ // The spliced hot path is synchronous (the chancelu lesson): retrieveSync
155
+ // cannot await. A local ollama query is 15-30ms steady state (A1 §5), so a
156
+ // bounded SYNC subprocess call keeps the reranker on the hot lane inside the
157
+ // ADR 0017 local exception — hard-capped so a wedged ollama loses the race,
158
+ // never the injection. curl ships with the host OS; node has no sync HTTP.
159
+ /** Hard sync budget for the hot-lane query embed. Generous over the measured
160
+ * 15-30ms, merciless for a wedged server. */
161
+ export const EMBED_QUERY_SYNC_TIMEOUT_MS = 250;
162
+ export function embedQuerySync(query, config = {}) {
163
+ const provider = (config.provider ?? '').trim();
164
+ if (provider === '') {
165
+ return { ok: false, outcome: 'provider_off', message: "embeddingProvider is '' — the semantic layer is off.", host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 };
166
+ }
167
+ if (provider !== SUPPORTED_PROVIDER) {
168
+ return { ok: false, outcome: 'unsupported_provider', message: `embeddingProvider '${provider}' is not supported in v1.`, host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 };
169
+ }
170
+ if ((query ?? '').trim() === '') {
171
+ return { ok: false, outcome: 'empty_input', message: 'empty query.', host: resolveEmbedHost(config.host), model: resolveEmbedModel(config.model), latencyMs: 0 };
172
+ }
173
+ const host = resolveEmbedHost(config.host);
174
+ const model = resolveEmbedModel(config.model);
175
+ const started = Date.now();
176
+ let raw;
177
+ try {
178
+ raw = execFileSync('curl', [
179
+ '-sS',
180
+ '--max-time', String(Math.ceil(EMBED_QUERY_SYNC_TIMEOUT_MS / 1000)),
181
+ '-X', 'POST', `${host}/api/embed`,
182
+ '-H', 'content-type: application/json',
183
+ '-d', JSON.stringify({ model, input: [truncateUtf8(query)] }),
184
+ ], { timeout: EMBED_QUERY_SYNC_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
185
+ }
186
+ catch (err) {
187
+ const message = err instanceof Error ? err.message : String(err);
188
+ const outcome = /timed out|ETIMEDOUT|maxTime/i.test(message) ? 'timeout' : 'network';
189
+ return { ok: false, outcome, message: `${outcome}: ${message}`, host, model, latencyMs: Date.now() - started };
190
+ }
191
+ let json;
192
+ try {
193
+ json = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ return { ok: false, outcome: 'bad_json', message: 'ollama returned non-JSON (sync path).', host, model, latencyMs: Date.now() - started };
197
+ }
198
+ const embeddings = isPlainObject(json) ? json.embeddings : undefined;
199
+ const first = Array.isArray(embeddings) && embeddings.length > 0 ? embeddings[0] : undefined;
200
+ if (!Array.isArray(first)) {
201
+ return { ok: false, outcome: 'bad_shape', message: 'ollama response has no embeddings[0] array (sync path).', host, model, latencyMs: Date.now() - started };
202
+ }
203
+ const vector = first.filter((n) => typeof n === 'number');
204
+ if (vector.length === 0 || vector.length !== first.length) {
205
+ return { ok: false, outcome: 'bad_shape', message: 'embeddings[0] malformed (sync path).', host, model, latencyMs: Date.now() - started };
206
+ }
207
+ return { ok: true, vector, model, dim: vector.length, latencyMs: Date.now() - started };
208
+ }
package/lib/ilog.d.ts CHANGED
@@ -23,6 +23,13 @@ export interface SlowItem {
23
23
  slug: string;
24
24
  why: string;
25
25
  }
26
+ /**
27
+ * Slow-lane full-lifecycle outcome (A4 决议 3, spec §3.2): where a slow-lane
28
+ * run (or round with a slow-lane stake) ended. Precedent = decisions.jsonl's
29
+ * outcome enum — a closed vocabulary so 「没跑」(no-run/no-claim) and 「跑了
30
+ * 全败」(llm-error/timeout) are finally distinguishable in the log.
31
+ */
32
+ export type SlowOutcome = 'no-run' | 'no-claim' | 'no-candidates' | 'llm-error' | 'timeout' | 'delivered' | 'expired';
26
33
  export interface InjectionRecord {
27
34
  at: string;
28
35
  sessionId?: string;
@@ -69,6 +76,32 @@ export interface InjectionRecord {
69
76
  slowMs?: number;
70
77
  /** Slow-lane pointers this round, in delivery order. */
71
78
  slow?: SlowItem[];
79
+ /**
80
+ * Slow-lane disposition for this round (A4 决议 3, spec §8.2): `delivered`
81
+ * when a pending was consumed and packed, `expired` at its hard bounds, and
82
+ * the producing run's failure point (no-run/no-claim/no-candidates/
83
+ * llm-error/timeout) when a spliced arrived with no pending. Optional —
84
+ * older lines predate the field and pure fast-lane rounds may omit it.
85
+ * Written once with the record, never backfilled (ilog append-only).
86
+ */
87
+ slowOutcome?: SlowOutcome;
88
+ }
89
+ /**
90
+ * One topic_open event (pointer-open log, v4 §4.3). Everything beyond
91
+ * `slug`/`at` is optional and late-added: older lines carry only the
92
+ * original pair, and every reader must tolerate the rest being absent.
93
+ */
94
+ export interface OpenRecord {
95
+ slug: string;
96
+ at: string;
97
+ /** Session that issued the topic_open, when the host surface exposed one. */
98
+ sessionId?: string;
99
+ /** Attribution: opened off an injected pointer, or off a topic_search hit. */
100
+ source?: 'pointer' | 'search';
101
+ /** Retrieval score of the matched injection hit (source='pointer' only). */
102
+ score?: number;
103
+ /** open time − the matched injection record's at, in ms (source='pointer' only). */
104
+ sinceInjectionMs?: number;
72
105
  }
73
106
  export interface AggregateStats {
74
107
  rounds: number;
@@ -107,3 +140,115 @@ export declare function aggregateUsage(injections: readonly InjectionRecord[], o
107
140
  slug: string;
108
141
  at: string;
109
142
  }[], windowDays: number, now: number): Map<string, UsageSignal>;
143
+ /** How many tail injection records the cheap open attribution scans. */
144
+ export declare const OPEN_ATTRIBUTION_TAIL = 50;
145
+ /** An injected pointer stays attributable for 48h (a work session's span). */
146
+ export declare const OPEN_ATTRIBUTION_WINDOW_MS: number;
147
+ /**
148
+ * Cheap source attribution for one open event: scan the injection tail
149
+ * (newest first) for a same-session record within the attribution window
150
+ * where the slug was truly delivered to the model's eyes. That is hits
151
+ * minus echoed/dropped (echo suppression and budget drops never rendered),
152
+ * PLUS slow-lane deliveries, PLUS deduped hits — dedup only blocks
153
+ * re-packing THIS round; the pointer rendered earlier in the same session
154
+ * and the model saw it (deliberately wider than renderedPointers: the panel
155
+ * denominator asks "did it render this round", attribution asks "did the
156
+ * model ever see it"). A fast-hit match carries that hit's score; a
157
+ * slow-only match carries none (slow pointers have no lexical score).
158
+ * Anything else (no session id, other session, expired, never rendered)
159
+ * is `search`.
160
+ */
161
+ export declare function attributeOpen(injections: readonly InjectionRecord[], slug: string, sessionId: string | undefined, nowMs: number): {
162
+ source: 'pointer' | 'search';
163
+ score?: number;
164
+ sinceInjectionMs?: number;
165
+ };
166
+ /** Score bands for the open-rate-by-score cut of the stats panel. */
167
+ export declare const SCORE_BANDS: readonly {
168
+ label: string;
169
+ lo: number;
170
+ hi: number;
171
+ }[];
172
+ /** Zero-open listing threshold: injections at or above this with zero opens. */
173
+ export declare const ZERO_OPEN_MIN_INJECTIONS = 5;
174
+ /** Rolling window of the zero-open rule: only rendered injections inside it count. */
175
+ export declare const ZERO_OPEN_WINDOW_DAYS = 30;
176
+ /** Injection-tail rows the decay read scans — a bound comfortably covering
177
+ * the 30-day window at observed round volumes. */
178
+ export declare const ZERO_OPEN_TAIL = 500;
179
+ /**
180
+ * Slugs that truly rendered in one round, in delivery order: raw hits minus
181
+ * the round's exclusions (deduped/echoed/dropped were never assembled), plus
182
+ * delivered slow pointers; a slug the fast lane already delivered counts
183
+ * once even when the slow lane also reports it delivered ("not packed
184
+ * twice"). Scores ride along from the fast hit only — slow pointers carry
185
+ * no lexical score. Shared by the stats panel and the zero-open rule.
186
+ */
187
+ export declare function renderedPointers(record: InjectionRecord): {
188
+ slug: string;
189
+ score?: number;
190
+ }[];
191
+ /**
192
+ * Zero-open slug set — the single shared 零打开高频 rule (v5): slugs whose
193
+ * truly-rendered pointer count within the rolling ZERO_OPEN_WINDOW_DAYS
194
+ * reaches ZERO_OPEN_MIN_INJECTIONS while the WHOLE open log has never
195
+ * recorded an open (wide scope — an open long after the last injection
196
+ * still disqualifies). Returns slug → windowed rendered-pointer count for
197
+ * the qualifying slugs; `/topics stats` (the panel's zero-open list) and
198
+ * the retrieval decay (`zeroOpenDecay`) both consume this one
199
+ * implementation. Records with unparsable `at` cannot prove window
200
+ * membership and are skipped (same tolerance as aggregateUsage).
201
+ */
202
+ export declare function aggregateZeroOpen(injections: readonly InjectionRecord[], opens: readonly {
203
+ slug: string;
204
+ }[], nowMs: number): Map<string, number>;
205
+ export interface OpenTopicRow {
206
+ slug: string;
207
+ /** Truly rendered pointers for this slug (deduped/echoed/dropped excluded). */
208
+ injections: number;
209
+ /** Opens attributed by slug over the whole open log (wide scope, by design). */
210
+ opens: number;
211
+ openRate: number;
212
+ }
213
+ export interface OpenBandRow {
214
+ label: string;
215
+ /** Rendered fast pointers whose hit score falls in this band. */
216
+ pointers: number;
217
+ /** Pointer-sourced opens whose attributed score falls in this band. */
218
+ opens: number;
219
+ openRate: number;
220
+ }
221
+ export interface OpenPanelStats {
222
+ /** Rendered pointer entries across the window: hits − deduped − echoed − dropped, plus slow pointers (same-slug fast+slow pairs counted once). */
223
+ pointerEntries: number;
224
+ /** DISTINCT slugs from the window's rendered set with an in-window open
225
+ * (search-sourced and never-rendered slugs excluded; one per slug). */
226
+ opensInWindow: number;
227
+ openRate: number;
228
+ topics: OpenTopicRow[];
229
+ zeroOpen: {
230
+ slug: string;
231
+ injections: number;
232
+ }[];
233
+ zeroOpenTotal: number;
234
+ pointerOpens: number;
235
+ searchOpens: number;
236
+ /** False when no open record carries a source field (pre-enrichment data). */
237
+ hasSourceData: boolean;
238
+ bands: OpenBandRow[];
239
+ }
240
+ /**
241
+ * Aggregate the injection-vs-open panel from the two JSONL windows. The
242
+ * per-topic/band denominators count only pointers that truly rendered
243
+ * (deduped/echoed/dropped excluded — same exclusion family as
244
+ * aggregateUsage); the header numerator counts only DISTINCT slugs from
245
+ * that rendered set with an open inside the injection window — a search
246
+ * open or an open of a never-rendered slug cannot enter, and repeat opens
247
+ * of the same slug count once, so the rate can no longer be pinned at
248
+ * 100% by repeated or off-scope opens. Opens per slug stay wide-scope
249
+ * (the model may open a topic long after its last injection). The
250
+ * zero-open list shares the decay's rule: rendered injections counted
251
+ * within the rolling 30-day window (anchored at the newest round), opens
252
+ * wide-scope.
253
+ */
254
+ export declare function aggregateOpenPanel(injections: readonly InjectionRecord[], opens: readonly OpenRecord[]): OpenPanelStats;
package/lib/ilog.js CHANGED
@@ -117,3 +117,239 @@ export function aggregateUsage(injections, opens, windowDays, now) {
117
117
  }
118
118
  return out;
119
119
  }
120
+ // ---------------------------------------------------------------------
121
+ // Injection-vs-open metrics (v5) — open enrichment attribution + the
122
+ // /topics stats panel aggregate. All pure: file I/O stays in store/service.
123
+ // ---------------------------------------------------------------------
124
+ /** How many tail injection records the cheap open attribution scans. */
125
+ export const OPEN_ATTRIBUTION_TAIL = 50;
126
+ /** An injected pointer stays attributable for 48h (a work session's span). */
127
+ export const OPEN_ATTRIBUTION_WINDOW_MS = 48 * 3_600_000;
128
+ /**
129
+ * Cheap source attribution for one open event: scan the injection tail
130
+ * (newest first) for a same-session record within the attribution window
131
+ * where the slug was truly delivered to the model's eyes. That is hits
132
+ * minus echoed/dropped (echo suppression and budget drops never rendered),
133
+ * PLUS slow-lane deliveries, PLUS deduped hits — dedup only blocks
134
+ * re-packing THIS round; the pointer rendered earlier in the same session
135
+ * and the model saw it (deliberately wider than renderedPointers: the panel
136
+ * denominator asks "did it render this round", attribution asks "did the
137
+ * model ever see it"). A fast-hit match carries that hit's score; a
138
+ * slow-only match carries none (slow pointers have no lexical score).
139
+ * Anything else (no session id, other session, expired, never rendered)
140
+ * is `search`.
141
+ */
142
+ export function attributeOpen(injections, slug, sessionId, nowMs) {
143
+ if (sessionId === undefined)
144
+ return { source: 'search' };
145
+ for (let i = injections.length - 1; i >= 0; i -= 1) {
146
+ const record = injections[i];
147
+ if (record.sessionId !== sessionId)
148
+ continue;
149
+ const atMs = Date.parse(record.at);
150
+ if (!Number.isFinite(atMs) || nowMs - atMs > OPEN_ATTRIBUTION_WINDOW_MS)
151
+ continue;
152
+ const sinceInjectionMs = Math.max(0, nowMs - atMs);
153
+ const suppressed = new Set([...(record.echoed ?? []), ...(record.dropped ?? []).map((d) => d.slug)]);
154
+ const hit = record.hits.find((h) => h.slug === slug && !suppressed.has(h.slug));
155
+ if (hit !== undefined)
156
+ return { source: 'pointer', score: hit.score, sinceInjectionMs };
157
+ if ((record.slow ?? []).some((s) => s.slug === slug))
158
+ return { source: 'pointer', sinceInjectionMs };
159
+ }
160
+ return { source: 'search' };
161
+ }
162
+ /** Score bands for the open-rate-by-score cut of the stats panel. */
163
+ export const SCORE_BANDS = [
164
+ { label: '[0.3,0.5)', lo: 0.3, hi: 0.5 },
165
+ { label: '[0.5,1)', lo: 0.5, hi: 1 },
166
+ { label: '[1,2)', lo: 1, hi: 2 },
167
+ { label: '[2,5)', lo: 2, hi: 5 },
168
+ { label: '[5,∞)', lo: 5, hi: Number.POSITIVE_INFINITY },
169
+ ];
170
+ /** Zero-open listing threshold: injections at or above this with zero opens. */
171
+ export const ZERO_OPEN_MIN_INJECTIONS = 5;
172
+ /** Rolling window of the zero-open rule: only rendered injections inside it count. */
173
+ export const ZERO_OPEN_WINDOW_DAYS = 30;
174
+ /** Injection-tail rows the decay read scans — a bound comfortably covering
175
+ * the 30-day window at observed round volumes. */
176
+ export const ZERO_OPEN_TAIL = 500;
177
+ /**
178
+ * Slugs that truly rendered in one round, in delivery order: raw hits minus
179
+ * the round's exclusions (deduped/echoed/dropped were never assembled), plus
180
+ * delivered slow pointers; a slug the fast lane already delivered counts
181
+ * once even when the slow lane also reports it delivered ("not packed
182
+ * twice"). Scores ride along from the fast hit only — slow pointers carry
183
+ * no lexical score. Shared by the stats panel and the zero-open rule.
184
+ */
185
+ export function renderedPointers(record) {
186
+ const excluded = new Set([
187
+ ...(record.deduped ?? []),
188
+ ...(record.echoed ?? []),
189
+ ...(record.dropped ?? []).map((d) => d.slug),
190
+ ]);
191
+ const seen = new Set();
192
+ const out = [];
193
+ for (const h of record.hits) {
194
+ if (excluded.has(h.slug) || seen.has(h.slug))
195
+ continue;
196
+ seen.add(h.slug);
197
+ out.push({ slug: h.slug, score: h.score });
198
+ }
199
+ for (const s of record.slow ?? []) {
200
+ if (seen.has(s.slug))
201
+ continue;
202
+ seen.add(s.slug);
203
+ out.push({ slug: s.slug });
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * Zero-open slug set — the single shared 零打开高频 rule (v5): slugs whose
209
+ * truly-rendered pointer count within the rolling ZERO_OPEN_WINDOW_DAYS
210
+ * reaches ZERO_OPEN_MIN_INJECTIONS while the WHOLE open log has never
211
+ * recorded an open (wide scope — an open long after the last injection
212
+ * still disqualifies). Returns slug → windowed rendered-pointer count for
213
+ * the qualifying slugs; `/topics stats` (the panel's zero-open list) and
214
+ * the retrieval decay (`zeroOpenDecay`) both consume this one
215
+ * implementation. Records with unparsable `at` cannot prove window
216
+ * membership and are skipped (same tolerance as aggregateUsage).
217
+ */
218
+ export function aggregateZeroOpen(injections, opens, nowMs) {
219
+ const cutoff = nowMs - ZERO_OPEN_WINDOW_DAYS * 86_400_000;
220
+ const counts = new Map();
221
+ for (const record of injections) {
222
+ const at = Date.parse(record.at);
223
+ if (!Number.isFinite(at) || at < cutoff)
224
+ continue;
225
+ for (const p of renderedPointers(record)) {
226
+ counts.set(p.slug, (counts.get(p.slug) ?? 0) + 1);
227
+ }
228
+ }
229
+ const opened = new Set(opens.map((o) => o.slug));
230
+ const out = new Map();
231
+ for (const [slug, n] of counts) {
232
+ if (n >= ZERO_OPEN_MIN_INJECTIONS && !opened.has(slug))
233
+ out.set(slug, n);
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * Aggregate the injection-vs-open panel from the two JSONL windows. The
239
+ * per-topic/band denominators count only pointers that truly rendered
240
+ * (deduped/echoed/dropped excluded — same exclusion family as
241
+ * aggregateUsage); the header numerator counts only DISTINCT slugs from
242
+ * that rendered set with an open inside the injection window — a search
243
+ * open or an open of a never-rendered slug cannot enter, and repeat opens
244
+ * of the same slug count once, so the rate can no longer be pinned at
245
+ * 100% by repeated or off-scope opens. Opens per slug stay wide-scope
246
+ * (the model may open a topic long after its last injection). The
247
+ * zero-open list shares the decay's rule: rendered injections counted
248
+ * within the rolling 30-day window (anchored at the newest round), opens
249
+ * wide-scope.
250
+ */
251
+ export function aggregateOpenPanel(injections, opens) {
252
+ // Numerator window: the earliest round represented in the denominator;
253
+ // the zero-open anchor is the newest round — the panel describes the log
254
+ // window it was fed, so a stale log still shows the topics that
255
+ // dominated it. The rendered-slug set below shares that window: only
256
+ // opens of slugs this window rendered can enter the numerator.
257
+ let windowStart = Number.POSITIVE_INFINITY;
258
+ let windowEnd = Number.NEGATIVE_INFINITY;
259
+ for (const r of injections) {
260
+ const atMs = Date.parse(r.at);
261
+ if (!Number.isFinite(atMs))
262
+ continue;
263
+ if (atMs < windowStart)
264
+ windowStart = atMs;
265
+ if (atMs > windowEnd)
266
+ windowEnd = atMs;
267
+ }
268
+ const topicCounts = new Map();
269
+ const bandPointers = new Array(SCORE_BANDS.length).fill(0);
270
+ let pointerEntries = 0;
271
+ const renderedSlugs = new Set();
272
+ for (const r of injections) {
273
+ // renderedPointers keeps the entry/per-topic counts honest: excluded
274
+ // slugs never counted, a fast+slow pair for the same slug counted once.
275
+ for (const p of renderedPointers(r)) {
276
+ pointerEntries += 1;
277
+ renderedSlugs.add(p.slug);
278
+ topicCounts.set(p.slug, (topicCounts.get(p.slug) ?? 0) + 1);
279
+ const score = p.score;
280
+ if (score === undefined)
281
+ continue;
282
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi);
283
+ if (bi >= 0)
284
+ bandPointers[bi] += 1;
285
+ }
286
+ }
287
+ const opensBySlug = new Map();
288
+ const bandOpens = new Array(SCORE_BANDS.length).fill(0);
289
+ let pointerOpens = 0;
290
+ let searchOpens = 0;
291
+ let hasSourceData = false;
292
+ // Numerator: DISTINCT slugs from the window's rendered set with an
293
+ // in-window open — search-sourced opens never count (the model found the
294
+ // topic by asking, not by following a rendered pointer), a never-rendered
295
+ // slug never counts, and repeat opens of the same slug count once.
296
+ const openedSlugsInWindow = new Set();
297
+ for (const o of opens) {
298
+ opensBySlug.set(o.slug, (opensBySlug.get(o.slug) ?? 0) + 1);
299
+ if (o.source === 'pointer' || o.source === 'search')
300
+ hasSourceData = true;
301
+ if (o.source === 'pointer')
302
+ pointerOpens += 1;
303
+ else if (o.source === 'search')
304
+ searchOpens += 1;
305
+ if (o.source === 'pointer' && typeof o.score === 'number') {
306
+ const score = o.score;
307
+ const bi = SCORE_BANDS.findIndex((b) => score >= b.lo && score < b.hi);
308
+ if (bi >= 0)
309
+ bandOpens[bi] += 1;
310
+ }
311
+ if (o.source === 'search')
312
+ continue;
313
+ const atMs = Date.parse(o.at);
314
+ // An open joins the windowed numerator unless it is provably older than
315
+ // the window's earliest round (unparsable stamps cannot prove either
316
+ // way) — and only when the window actually rendered its slug.
317
+ if (Number.isFinite(windowStart) && Number.isFinite(atMs) && atMs < windowStart)
318
+ continue;
319
+ if (!renderedSlugs.has(o.slug))
320
+ continue;
321
+ openedSlugsInWindow.add(o.slug);
322
+ }
323
+ const opensInWindow = openedSlugsInWindow.size;
324
+ const rate = (num, den) => (den === 0 ? 0 : Math.min(1, num / den));
325
+ const topics = [...topicCounts.entries()]
326
+ .map(([slug, injections]) => {
327
+ const opens = opensBySlug.get(slug) ?? 0;
328
+ return { slug, injections, opens, openRate: rate(opens, injections) };
329
+ })
330
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug));
331
+ // Zero-open cut: the shared 30-day rule (aggregateZeroOpen), anchored at
332
+ // the newest round so window semantics match the decay's rolling window
333
+ // without growing a time-bomb dependency on the wall clock.
334
+ const zeroOpenCounts = windowEnd === Number.NEGATIVE_INFINITY ? new Map() : aggregateZeroOpen(injections, opens, windowEnd);
335
+ const zeroOpenAll = [...zeroOpenCounts.entries()]
336
+ .map(([slug, injections]) => ({ slug, injections }))
337
+ .sort((a, b) => b.injections - a.injections || a.slug.localeCompare(b.slug));
338
+ return {
339
+ pointerEntries,
340
+ opensInWindow,
341
+ openRate: rate(opensInWindow, pointerEntries),
342
+ topics: topics.slice(0, 10),
343
+ zeroOpen: zeroOpenAll.slice(0, 10).map((t) => ({ slug: t.slug, injections: t.injections })),
344
+ zeroOpenTotal: zeroOpenAll.length,
345
+ pointerOpens,
346
+ searchOpens,
347
+ hasSourceData,
348
+ bands: SCORE_BANDS.map((b, i) => ({
349
+ label: b.label,
350
+ pointers: bandPointers[i],
351
+ opens: bandOpens[i],
352
+ openRate: rate(bandOpens[i], bandPointers[i]),
353
+ })),
354
+ };
355
+ }
package/lib/index.d.ts CHANGED
@@ -112,6 +112,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
112
112
  consolidateCadence: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
113
113
  deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
114
114
  usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
115
+ zeroOpenDecay: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
115
116
  pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
116
117
  jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
117
118
  jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
@@ -120,6 +121,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
120
121
  jevSecretFile: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
121
122
  jevLayaFallback: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
122
123
  jevLayaUrl: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
124
+ embeddingProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
125
+ embeddingModel: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
126
+ packageMode: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
123
127
  }>>, Schemastery.ObjectT<NoInfer<{
124
128
  repo: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
125
129
  autoInject: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
@@ -146,6 +150,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
146
150
  consolidateCadence: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
147
151
  deprecatedTtlDays: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
148
152
  usageBoost: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
153
+ zeroOpenDecay: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
149
154
  pushDebounceSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
150
155
  jevEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
151
156
  jevBackend: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
@@ -154,6 +159,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
154
159
  jevSecretFile: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
155
160
  jevLayaFallback: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
156
161
  jevLayaUrl: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
162
+ embeddingProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
163
+ embeddingModel: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
164
+ packageMode: import("@deepseek-ai/schemastery").default<string, string, "volatile-defined">;
157
165
  }>>, "plain">;
158
166
  /** Live config reference as a 0.1.7 host passes it (cron / mcp-adapter
159
167
  * spelling): `.get()` snapshots the current value — the host swaps the