@aiwayds/dsh-topics-memory 0.6.0 → 0.7.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/lib/observer.d.ts CHANGED
@@ -16,6 +16,19 @@
16
16
  * @module observer
17
17
  */
18
18
  import type { TopicsService } from './service.ts';
19
+ /** One completed turn in the ring buffer — the slow lane's only context source. */
20
+ export interface RingEntry {
21
+ user: string;
22
+ assistant: string;
23
+ at: string;
24
+ }
25
+ /**
26
+ * Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
27
+ * turns. Deliberately small — query building wants the current intent, not a
28
+ * transcript; larger windows re-introduce the verbatim-priming surface the
29
+ * lane exists to remove.
30
+ */
31
+ export declare const RING_CAPACITY = 3;
19
32
  export interface UserMessageLike {
20
33
  source?: {
21
34
  kind?: string;
@@ -34,6 +47,10 @@ export declare class Observer {
34
47
  private readonly onRequestDistill;
35
48
  constructor(service: TopicsService, onRequestDistill: (sessionId: string, reason: 'every-n' | 'session-end') => void);
36
49
  private stateFor;
50
+ /** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
51
+ recentTurns(sessionId: string): RingEntry[];
52
+ /** Current turn count for the session (0 before the first turn ends). */
53
+ turnCountOf(sessionId: string): number;
37
54
  /** Entry point wired to ctx.on('session/event', …). */
38
55
  onSessionEvent(sessionId: string, eventType: string, data: unknown): void;
39
56
  private handleAsync;
package/lib/observer.js CHANGED
@@ -15,6 +15,13 @@
15
15
  *
16
16
  * @module observer
17
17
  */
18
+ /**
19
+ * Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
20
+ * turns. Deliberately small — query building wants the current intent, not a
21
+ * transcript; larger windows re-introduce the verbatim-priming surface the
22
+ * lane exists to remove.
23
+ */
24
+ export const RING_CAPACITY = 3;
18
25
  export function textOf(message) {
19
26
  // Deliberately text-only and explicit: since dsh 0.1.2-alpha.3 sub-agent
20
27
  // follow-up messages may carry image (and other non-text) content blocks —
@@ -38,11 +45,19 @@ export class Observer {
38
45
  stateFor(sessionId) {
39
46
  let s = this.sessions.get(sessionId);
40
47
  if (s === undefined) {
41
- s = { turnCount: 0, userText: '', assistantText: '', capturing: false };
48
+ s = { turnCount: 0, userText: '', assistantText: '', capturing: false, ring: [] };
42
49
  this.sessions.set(sessionId, s);
43
50
  }
44
51
  return s;
45
52
  }
53
+ /** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
54
+ recentTurns(sessionId) {
55
+ return (this.sessions.get(sessionId)?.ring ?? []).map((e) => ({ ...e }));
56
+ }
57
+ /** Current turn count for the session (0 before the first turn ends). */
58
+ turnCountOf(sessionId) {
59
+ return this.sessions.get(sessionId)?.turnCount ?? 0;
60
+ }
46
61
  /** Entry point wired to ctx.on('session/event', …). */
47
62
  onSessionEvent(sessionId, eventType, data) {
48
63
  try {
@@ -72,8 +87,23 @@ export class Observer {
72
87
  }
73
88
  case 'turn/end': {
74
89
  state.turnCount += 1;
90
+ // Cadence trigger FIRST and synchronously: index.ts dispatches the
91
+ // slow quality lane the moment onSessionEvent returns — which happens
92
+ // at the first await below (the observation write). The lane's
93
+ // distill-yield guard reads distiller.hasPending, so the every-N slot
94
+ // must be claimed before that first await or the %15 collision would
95
+ // never yield. The trigger's run fetches observations lazily; this
96
+ // turn's capture below still lands in the pool for a later run.
97
+ if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
98
+ this.onRequestDistill(sessionId, 'every-n');
99
+ }
75
100
  const user = state.userText.trim();
76
101
  const assistant = state.assistantText.trim();
102
+ if (user !== '' || assistant !== '') {
103
+ state.ring.push({ user, assistant, at: new Date().toISOString() });
104
+ if (state.ring.length > RING_CAPACITY)
105
+ state.ring.splice(0, state.ring.length - RING_CAPACITY);
106
+ }
77
107
  if (cfg.autoObserve && user !== '' && assistant !== '') {
78
108
  await this.service.store.appendObservation({
79
109
  kind: 'turn',
@@ -87,9 +117,6 @@ export class Observer {
87
117
  }
88
118
  state.userText = '';
89
119
  state.assistantText = '';
90
- if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
91
- this.onRequestDistill(sessionId, 'every-n');
92
- }
93
120
  return;
94
121
  }
95
122
  // Real session teardown: both cordis events fire at teardown (agent
package/lib/okf.d.ts CHANGED
@@ -30,6 +30,13 @@ export interface TopicFrontmatter {
30
30
  title: string;
31
31
  description?: string;
32
32
  tags: string[];
33
+ /**
34
+ * Self-declared recall triggers (v4 dual-channel design §4.1): short
35
+ * phrases that should recall this topic when they appear in a query.
36
+ * Optional; bundles written before the field simply have no trigger
37
+ * matching (degraded, never an error). Highest-weight strong field.
38
+ */
39
+ triggers?: string[];
33
40
  /** Bundle-relative paths: `topics/<slug>.md`. */
34
41
  depends: string[];
35
42
  open_questions: string[];
package/lib/okf.js CHANGED
@@ -85,6 +85,15 @@ export function parseTopicDoc(raw) {
85
85
  };
86
86
  if (typeof fmRaw.description === 'string' && fmRaw.description !== '')
87
87
  fm.description = fmRaw.description;
88
+ // Triggers are producer-optional and must never invalidate an otherwise
89
+ // sound doc: non-string entries are filtered, not thrown (degrade, per the
90
+ // v4 design's "旧 bundle 无此字段降级不命中").
91
+ if (fmRaw.triggers !== undefined && fmRaw.triggers !== null) {
92
+ const raw = Array.isArray(fmRaw.triggers) ? fmRaw.triggers : [fmRaw.triggers];
93
+ const triggers = raw.filter((t) => typeof t === 'string' && t.trim() !== '').map((t) => t.trim());
94
+ if (triggers.length > 0)
95
+ fm.triggers = triggers;
96
+ }
88
97
  if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined)
89
98
  fm.verified = asTrust(fmRaw.verified, 'verified');
90
99
  if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '')
@@ -98,7 +107,7 @@ export function parseTopicDoc(raw) {
98
107
  });
99
108
  }
100
109
  // Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
101
- const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources']);
110
+ const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources']);
102
111
  for (const [k, v] of Object.entries(fmRaw)) {
103
112
  if (!known.has(k))
104
113
  fm[k] = v;
@@ -115,6 +124,8 @@ export function serializeTopicDoc(doc) {
115
124
  if (fm.description !== undefined)
116
125
  out.description = fm.description;
117
126
  out.tags = fm.tags;
127
+ if (fm.triggers !== undefined && fm.triggers.length > 0)
128
+ out.triggers = fm.triggers;
118
129
  out.depends = fm.depends;
119
130
  out.open_questions = fm.open_questions;
120
131
  out.impact = fm.impact;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Slow quality lane (v4 dual-channel design §4.2) — asynchronous, turn-end
3
+ * triggered, LLM-gated injection. Two serial aux calls per run:
4
+ *
5
+ * 1. intent-query build over the observer's last-K ring buffer — kills
6
+ * verbatim priming at the root (the query is what the model needs, not
7
+ * what the user typed);
8
+ * 2. lexical candidate band (hits + near-misses, recall-oriented) then an
9
+ * LLM rerank that releases 0-2 picks, each with a one-line why.
10
+ *
11
+ * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
+ * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
13
+ * 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
14
+ * contained and silent — I3 (可丢弃性): async products may be missing, never
15
+ * blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
16
+ * records verdicts into ilog and never blocks a pick.
17
+ *
18
+ * @module quality
19
+ */
20
+ import type { TopicsService } from './service.ts';
21
+ import type { ModelCaller } from './distill.ts';
22
+ import type { RingEntry } from './observer.ts';
23
+ import type { QueryBuildShape } from './ilog.ts';
24
+ export declare const PENDING_TTL_MS: number;
25
+ /** Hard drift bound: a pending older than this many turn-ends expires. */
26
+ export declare const TURN_LAG_LIMIT = 2;
27
+ export declare const CALL_TIMEOUT_MS = 20000;
28
+ export declare const PIPELINE_TIMEOUT_MS = 45000;
29
+ /** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
30
+ export declare const SAMPLED_EVERY = 3;
31
+ export declare const QUERY_BUILD_PROMPT: string;
32
+ export declare const RERANK_PROMPT: string;
33
+ export interface PendingInjection {
34
+ items: {
35
+ slug: string;
36
+ why: string;
37
+ }[];
38
+ computedAt: string;
39
+ /** Observer turnCount when the pending was produced (turn-lag clock). */
40
+ turnId: number;
41
+ queryBuild: QueryBuildShape;
42
+ model: string;
43
+ ms: number;
44
+ }
45
+ export type ConsumeResult = {
46
+ pending: PendingInjection;
47
+ } | {
48
+ expired: 'ttl' | 'turn-lag';
49
+ } | undefined;
50
+ export interface DispatchInput {
51
+ ring: readonly RingEntry[];
52
+ turnId: number;
53
+ }
54
+ /** Extract the first balanced JSON object from model output (tolerates fences). */
55
+ export declare function parseJsonObject(raw: string): Record<string, unknown>;
56
+ export declare class SlowLane {
57
+ private pending;
58
+ private inFlight;
59
+ private readonly service;
60
+ private readonly caller;
61
+ /**
62
+ * Settle hook: fires when a session's pipeline leaves the in-flight map.
63
+ * The host uses it to re-check the sessionLlm release guards — a disposal
64
+ * that landed mid-pipeline held the entry open for THIS pipeline, and this
65
+ * callback is the only chance to release it afterwards (the distill lane
66
+ * has the same shape via its run.finally).
67
+ */
68
+ private readonly onSettle;
69
+ constructor(service: TopicsService, caller: ModelCaller | undefined, onSettle?: (sessionId: string) => void);
70
+ /** True while the session has a pending slot awaiting the next spliced. */
71
+ hasPending(sessionId: string): boolean;
72
+ /** True while the session's produce pipeline is still running (llm-capture guard). */
73
+ hasInFlight(sessionId: string): boolean;
74
+ /** Drop the pending slot (session teardown, restore boundary). */
75
+ clear(sessionId: string): void;
76
+ /**
77
+ * Produce a pending slot for the session's next steer message. Fire-and-
78
+ * forget; every guard failure is silent. Policy gates live here (config,
79
+ * sampling, in-flight); host gates (delegation, distill yield) live with
80
+ * the caller, which owns those services.
81
+ */
82
+ dispatch(sessionId: string, input: DispatchInput): void;
83
+ private produce;
84
+ /**
85
+ * Consumption point (the next spliced). Returns the pending exactly once
86
+ * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
87
+ * count so the drift bound can judge. Undefined when nothing is pending.
88
+ */
89
+ consume(sessionId: string, turnId: number): ConsumeResult;
90
+ }
package/lib/quality.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Slow quality lane (v4 dual-channel design §4.2) — asynchronous, turn-end
3
+ * triggered, LLM-gated injection. Two serial aux calls per run:
4
+ *
5
+ * 1. intent-query build over the observer's last-K ring buffer — kills
6
+ * verbatim priming at the root (the query is what the model needs, not
7
+ * what the user typed);
8
+ * 2. lexical candidate band (hits + near-misses, recall-oriented) then an
9
+ * LLM rerank that releases 0-2 picks, each with a one-line why.
10
+ *
11
+ * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
+ * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
13
+ * 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
14
+ * contained and silent — I3 (可丢弃性): async products may be missing, never
15
+ * blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
16
+ * records verdicts into ilog and never blocks a pick.
17
+ *
18
+ * @module quality
19
+ */
20
+ import { searchTopics } from "./retrieval.js";
21
+ export const PENDING_TTL_MS = 10 * 60_000;
22
+ /** Hard drift bound: a pending older than this many turn-ends expires. */
23
+ export const TURN_LAG_LIMIT = 2;
24
+ export const CALL_TIMEOUT_MS = 20_000;
25
+ export const PIPELINE_TIMEOUT_MS = 45_000;
26
+ /** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
27
+ export const SAMPLED_EVERY = 3;
28
+ /** Max ring-buffer chars fed to the query build (features budget, not a transcript). */
29
+ const RING_CHARS = 6000;
30
+ /** Candidate band size fed to the rerank. */
31
+ const CANDIDATE_LIMIT = 6;
32
+ export const QUERY_BUILD_PROMPT = [
33
+ '你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
34
+ '只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
35
+ '规则:',
36
+ '- query 给词法检索用:写具体的名词、术语、项目名,不要照抄对话原句。',
37
+ '- 对话里粘贴的日志、代码块、URL、命令输出等大段内容必须忽略,并把类型写进 ignore 数组(如 "粘贴的命令输出")。',
38
+ '- 没有需要补充背景知识的迹象时输出 {"needs": false}。',
39
+ ].join('\n');
40
+ export const RERANK_PROMPT = [
41
+ '你是注入门禁。输入一个检索 query 和候选 topic 列表。逐个判断候选是否「真的与当前工作相关」。',
42
+ '只输出一个 JSON 对象,不要任何其他文字:{"picks": [{"slug": "候选里的 slug", "why": "一句话说明为什么现在需要它"}]}',
43
+ '规则:',
44
+ '- 最多 2 条,宁缺勿滥;没有真相关的就输出 {"picks": []}。',
45
+ '- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
46
+ '- slug 必须逐字取自候选列表,禁止编造。',
47
+ ].join('\n');
48
+ /** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
49
+ function withTimeout(p, ms, onTimeout) {
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => {
52
+ onTimeout();
53
+ reject(new Error(`slow-lane call timeout (${ms}ms)`));
54
+ }, ms);
55
+ timer.unref?.();
56
+ p.then((v) => {
57
+ clearTimeout(timer);
58
+ resolve(v);
59
+ }, (e) => {
60
+ clearTimeout(timer);
61
+ reject(e);
62
+ });
63
+ });
64
+ }
65
+ /** Extract the first balanced JSON object from model output (tolerates fences). */
66
+ export function parseJsonObject(raw) {
67
+ let text = raw.trim();
68
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
69
+ if (fence !== null)
70
+ text = fence[1].trim();
71
+ const start = text.indexOf('{');
72
+ if (start < 0)
73
+ throw new Error('no JSON object in output');
74
+ let depth = 0;
75
+ let inString = false;
76
+ let escaped = false;
77
+ for (let i = start; i < text.length; i += 1) {
78
+ const ch = text[i];
79
+ if (escaped) {
80
+ escaped = false;
81
+ continue;
82
+ }
83
+ if (ch === '\\') {
84
+ escaped = true;
85
+ continue;
86
+ }
87
+ if (ch === '"')
88
+ inString = !inString;
89
+ if (inString)
90
+ continue;
91
+ if (ch === '{')
92
+ depth += 1;
93
+ if (ch === '}') {
94
+ depth -= 1;
95
+ if (depth === 0) {
96
+ const parsed = JSON.parse(text.slice(start, i + 1));
97
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
98
+ throw new Error('not an object');
99
+ return parsed;
100
+ }
101
+ }
102
+ }
103
+ throw new Error('unbalanced JSON in output');
104
+ }
105
+ export class SlowLane {
106
+ pending = new Map();
107
+ inFlight = new Map();
108
+ service;
109
+ caller;
110
+ /**
111
+ * Settle hook: fires when a session's pipeline leaves the in-flight map.
112
+ * The host uses it to re-check the sessionLlm release guards — a disposal
113
+ * that landed mid-pipeline held the entry open for THIS pipeline, and this
114
+ * callback is the only chance to release it afterwards (the distill lane
115
+ * has the same shape via its run.finally).
116
+ */
117
+ onSettle;
118
+ constructor(service, caller, onSettle = () => undefined) {
119
+ this.service = service;
120
+ this.caller = caller;
121
+ this.onSettle = onSettle;
122
+ }
123
+ /** True while the session has a pending slot awaiting the next spliced. */
124
+ hasPending(sessionId) {
125
+ return this.pending.has(sessionId);
126
+ }
127
+ /** True while the session's produce pipeline is still running (llm-capture guard). */
128
+ hasInFlight(sessionId) {
129
+ return this.inFlight.has(sessionId);
130
+ }
131
+ /** Drop the pending slot (session teardown, restore boundary). */
132
+ clear(sessionId) {
133
+ this.pending.delete(sessionId);
134
+ }
135
+ /**
136
+ * Produce a pending slot for the session's next steer message. Fire-and-
137
+ * forget; every guard failure is silent. Policy gates live here (config,
138
+ * sampling, in-flight); host gates (delegation, distill yield) live with
139
+ * the caller, which owns those services.
140
+ */
141
+ dispatch(sessionId, input) {
142
+ try {
143
+ const cfg = this.service.cfg;
144
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
145
+ return;
146
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0)
147
+ return;
148
+ if (this.caller === undefined || this.inFlight.has(sessionId))
149
+ return;
150
+ if (input.ring.length === 0)
151
+ return;
152
+ const run = this.produce(sessionId, input).finally(() => {
153
+ this.inFlight.delete(sessionId);
154
+ this.onSettle(sessionId);
155
+ });
156
+ this.inFlight.set(sessionId, run);
157
+ void run.catch(() => undefined);
158
+ }
159
+ catch {
160
+ // contained — the lane must never break the event handler
161
+ }
162
+ }
163
+ async produce(sessionId, input) {
164
+ const caller = this.caller;
165
+ if (caller === undefined)
166
+ return;
167
+ const started = Date.now();
168
+ const controller = new AbortController();
169
+ const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS);
170
+ deadline.unref?.();
171
+ try {
172
+ const cfg = this.service.cfg;
173
+ const ringText = input.ring
174
+ .map((e, i) => {
175
+ const turn = `【轮 ${i + 1}】\n用户: ${e.user}\n助手: ${e.assistant}`;
176
+ return turn.length > RING_CHARS / input.ring.length ? turn.slice(0, Math.floor(RING_CHARS / input.ring.length)) : turn;
177
+ })
178
+ .join('\n\n');
179
+ const buildRaw = await withTimeout(caller({
180
+ system: QUERY_BUILD_PROMPT,
181
+ user: ringText,
182
+ purpose: 'topics-quality',
183
+ sessionId,
184
+ maxTokens: 300,
185
+ signal: controller.signal,
186
+ }), CALL_TIMEOUT_MS, () => controller.abort());
187
+ let built;
188
+ try {
189
+ built = parseJsonObject(buildRaw);
190
+ }
191
+ catch {
192
+ return; // unparseable build → no pending, nothing logged, next turn retries
193
+ }
194
+ if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '')
195
+ return;
196
+ const query = built.query.trim();
197
+ const stripped = Array.isArray(built.ignore)
198
+ ? built.ignore.filter((s) => typeof s === 'string' && s.trim() !== '').slice(0, 5)
199
+ : [];
200
+ const queryBuild = { rawChars: ringText.length, keptChars: query.length, stripped };
201
+ const roster = await this.service.roster().catch(() => []);
202
+ if (roster.length === 0)
203
+ return;
204
+ // Recall-oriented band: gate OFF (the rerank IS this lane's gate);
205
+ // candidates = threshold passers + the near-miss band beneath them.
206
+ const band = searchTopics(query, roster, {
207
+ threshold: this.service.cfg.matchThreshold,
208
+ topK: CANDIDATE_LIMIT,
209
+ tagBoost: this.service.cfg.tagBoost,
210
+ graphDepth: 0,
211
+ recencyWindowDays: this.service.cfg.recencyWindowDays,
212
+ structuralGate: false,
213
+ });
214
+ const candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT);
215
+ if (candidates.length === 0)
216
+ return;
217
+ const bySlug = new Map(roster.map((r) => [r.slug, r]));
218
+ const payload = candidates.flatMap((c) => {
219
+ const meta = bySlug.get(c.slug);
220
+ if (meta === undefined)
221
+ return [];
222
+ const conclusionFirst = meta.conclusion.split('\n').find((l) => l.trim() !== '') ?? '';
223
+ return [
224
+ {
225
+ slug: c.slug,
226
+ title: meta.title,
227
+ status: meta.status,
228
+ description: meta.description ?? '',
229
+ conclusion: conclusionFirst.slice(0, 160),
230
+ },
231
+ ];
232
+ });
233
+ if (payload.length === 0)
234
+ return;
235
+ const rerankRaw = await withTimeout(caller({
236
+ system: RERANK_PROMPT,
237
+ user: [
238
+ `检索 query:${query}`,
239
+ '',
240
+ `候选 topic(${payload.length} 个):`,
241
+ JSON.stringify(payload),
242
+ ].join('\n'),
243
+ purpose: 'topics-quality',
244
+ sessionId,
245
+ maxTokens: 400,
246
+ signal: controller.signal,
247
+ }), CALL_TIMEOUT_MS, () => controller.abort());
248
+ let reranked;
249
+ try {
250
+ reranked = parseJsonObject(rerankRaw);
251
+ }
252
+ catch {
253
+ return;
254
+ }
255
+ const legal = new Set(payload.map((p) => p.slug));
256
+ const rawPicks = Array.isArray(reranked.picks) ? reranked.picks : [];
257
+ const items = [];
258
+ const picked = new Set();
259
+ for (const pick of rawPicks) {
260
+ if (pick === null || typeof pick !== 'object')
261
+ continue;
262
+ const slug = pick.slug;
263
+ const why = pick.why;
264
+ if (typeof slug !== 'string' || !legal.has(slug))
265
+ continue;
266
+ if (typeof why !== 'string' || why.trim() === '')
267
+ continue;
268
+ if (picked.has(slug))
269
+ continue;
270
+ picked.add(slug);
271
+ items.push({ slug, why: why.trim().slice(0, 200) });
272
+ if (items.length >= 2)
273
+ break;
274
+ }
275
+ if (items.length === 0)
276
+ return;
277
+ const route = this.service.cfg;
278
+ this.pending.set(sessionId, {
279
+ items,
280
+ computedAt: new Date().toISOString(),
281
+ turnId: input.turnId,
282
+ queryBuild,
283
+ model: `${route.distillProvider}/${route.distillModel}`,
284
+ ms: Date.now() - started,
285
+ });
286
+ }
287
+ catch {
288
+ // timeouts, aborts, model errors — I3: the async product is simply absent
289
+ }
290
+ finally {
291
+ clearTimeout(deadline);
292
+ }
293
+ }
294
+ /**
295
+ * Consumption point (the next spliced). Returns the pending exactly once
296
+ * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
297
+ * count so the drift bound can judge. Undefined when nothing is pending.
298
+ */
299
+ consume(sessionId, turnId) {
300
+ const pending = this.pending.get(sessionId);
301
+ if (pending === undefined)
302
+ return undefined;
303
+ const produced = Date.parse(pending.computedAt);
304
+ if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
305
+ this.pending.delete(sessionId);
306
+ return { expired: 'ttl' };
307
+ }
308
+ if (turnId - pending.turnId > TURN_LAG_LIMIT) {
309
+ this.pending.delete(sessionId);
310
+ return { expired: 'turn-lag' };
311
+ }
312
+ this.pending.delete(sessionId);
313
+ return { pending };
314
+ }
315
+ }
@@ -17,6 +17,8 @@ export interface RetrievableTopic extends TopicMeta {
17
17
  conclusion: string;
18
18
  /** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
19
19
  links?: string[];
20
+ /** Self-declared recall triggers (v4 structural gate strong field). */
21
+ triggers?: string[];
20
22
  }
21
23
  export interface RetrievalConfig {
22
24
  threshold: number;
@@ -27,13 +29,27 @@ export interface RetrievalConfig {
27
29
  now?: Date;
28
30
  /** Slugs currently conflicted — demoted, not dropped. */
29
31
  conflicts?: ReadonlySet<string>;
32
+ /**
33
+ * v4 structural gate v0 (design §4.1): a threshold-passing candidate only
34
+ * injects when it ALSO hits a strong field (triggers/title/slug) or ≥2
35
+ * distinct body terms — tags alone and single body terms can no longer
36
+ * self-cross. Off for the explicit topic_search tool (the model asked).
37
+ */
38
+ structuralGate: boolean;
30
39
  }
31
40
  export declare const DEFAULT_RETRIEVAL: RetrievalConfig;
41
+ /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
42
+ export declare const TRIGGER_WEIGHT = 5;
32
43
  export interface SearchHit {
33
44
  slug: string;
34
45
  score: number;
35
46
  reasons: string[];
36
47
  viaGraph: boolean;
48
+ /** Structural gate v0 evidence (absent on graph-expanded entries). */
49
+ strong?: boolean;
50
+ bodyHits?: number;
51
+ /** Recency-free score; graph decay starts from it (settle ties with the gate). */
52
+ gateScore?: number;
37
53
  }
38
54
  export interface SearchOutcome {
39
55
  hits: SearchHit[];
@@ -50,12 +66,28 @@ export interface ScoredTopic {
50
66
  slug: string;
51
67
  score: number;
52
68
  reasons: string[];
69
+ /** Strong-field hit (triggers/title/slug) — gate v0 pass condition A. */
70
+ strong: boolean;
71
+ /** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
72
+ bodyHits: number;
73
+ /** Score with the recency tiebreaker removed — the only score the gate looks at. */
74
+ gateScore: number;
53
75
  }
54
76
  /** Score one topic against the query tokens. */
55
77
  export declare function scoreTopic(queryTokens: ReadonlySet<string>, topic: RetrievableTopic, cfg: RetrievalConfig): ScoredTopic;
78
+ /** Structural gate v0 verdict: strong-field hit, or ≥2 distinct body terms. */
79
+ export declare function passesGate(s: {
80
+ strong: boolean;
81
+ bodyHits: number;
82
+ }): boolean;
56
83
  /**
57
- * Search the roster: score every topic, split hits from near-misses, then
58
- * expand seeds through the `depends` graph (both directions, geometric decay,
59
- * depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
84
+ * Search the roster: score every topic, apply the numeric threshold to the
85
+ * gate score (recency excluded), split by the structural gate, then expand
86
+ * gate-passed seeds through the `depends` graph (both directions, geometric
87
+ * decay, depth ≤ cfg.graphDepth). Expanded entries enter at the back of the
88
+ * hits and are exempt from the structural gate — they carry no lexical
89
+ * evidence by design, only graph adjacency. Threshold-passing candidates the
90
+ * gate rejects lead the near-misses (reason `gate-blocked`) so the replay
91
+ * evidence shows what the gate is costing.
60
92
  */
61
93
  export declare function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg?: Partial<RetrievalConfig>): SearchOutcome;