@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/commands.js +23 -2
- package/lib/config.d.ts +19 -5
- package/lib/config.js +21 -3
- package/lib/digest.d.ts +38 -1
- package/lib/digest.js +92 -11
- package/lib/distill.d.ts +1 -0
- package/lib/distill.js +3 -1
- package/lib/ilog.d.ts +36 -0
- package/lib/index.js +95 -23
- package/lib/observer.d.ts +17 -0
- package/lib/observer.js +31 -4
- package/lib/okf.d.ts +7 -0
- package/lib/okf.js +12 -1
- package/lib/quality.d.ts +90 -0
- package/lib/quality.js +315 -0
- package/lib/retrieval.d.ts +35 -3
- package/lib/retrieval.js +73 -19
- package/lib/service.d.ts +48 -7
- package/lib/service.js +198 -25
- package/lib/store.d.ts +13 -0
- package/lib/store.js +54 -0
- package/lib/tools.js +49 -1
- package/package.json +2 -2
- package/src/commands.ts +26 -2
- package/src/config.ts +21 -3
- package/src/digest.ts +118 -11
- package/src/distill.ts +4 -1
- package/src/ilog.ts +39 -2
- package/src/index.ts +87 -19
- package/src/observer.ts +42 -4
- package/src/okf.ts +17 -1
- package/src/quality.ts +335 -0
- package/src/retrieval.ts +93 -19
- package/src/service.ts +214 -25
- package/src/store.ts +53 -0
- package/src/tools.ts +51 -1
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;
|
package/lib/quality.d.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/retrieval.d.ts
CHANGED
|
@@ -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,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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;
|