@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/commands.js
CHANGED
|
@@ -94,8 +94,9 @@ async function renderStatus(service) {
|
|
|
94
94
|
` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
|
|
95
95
|
` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
|
|
96
96
|
` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
|
|
97
|
-
` 注入:${cfg.autoInject ?
|
|
97
|
+
` 注入:${cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
|
|
98
98
|
` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
|
|
99
|
+
` 慢道:${cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`}`,
|
|
99
100
|
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
100
101
|
];
|
|
101
102
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
@@ -154,13 +155,33 @@ async function renderStats(service) {
|
|
|
154
155
|
` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
|
|
155
156
|
` 平均预算占用:${stats.avgBudgetUtilization} tok`,
|
|
156
157
|
];
|
|
158
|
+
// v4 lane split — how much of the injection traffic each lane carries.
|
|
159
|
+
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length;
|
|
160
|
+
if (slowRounds > 0) {
|
|
161
|
+
const consumed = records.filter((r) => r.consumedAt !== undefined && r.computedAt !== undefined);
|
|
162
|
+
let medianLag = '';
|
|
163
|
+
if (consumed.length > 0) {
|
|
164
|
+
const lags = consumed
|
|
165
|
+
.map((r) => Math.max(0, Date.parse(r.consumedAt) - Date.parse(r.computedAt)))
|
|
166
|
+
.sort((a, b) => a - b);
|
|
167
|
+
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`;
|
|
168
|
+
}
|
|
169
|
+
lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`);
|
|
170
|
+
}
|
|
171
|
+
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
172
|
+
const opens = await service.store.readOpenRecords();
|
|
173
|
+
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
174
|
+
const entries = records.reduce((acc, r) => acc + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0), 0);
|
|
175
|
+
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries);
|
|
176
|
+
lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`);
|
|
177
|
+
}
|
|
157
178
|
if (stats.topTopics.length > 0) {
|
|
158
179
|
lines.push(' Top-N 被注入 Topic:');
|
|
159
180
|
for (const t of stats.topTopics.slice(0, 5))
|
|
160
181
|
lines.push(` ${t.slug} ×${t.count}`);
|
|
161
182
|
}
|
|
162
183
|
if (stats.nearMissHistogram.length > 0) {
|
|
163
|
-
lines.push(' Near-miss
|
|
184
|
+
lines.push(' Near-miss 分布(低于阈值或被结构门挡下):');
|
|
164
185
|
for (const b of stats.nearMissHistogram)
|
|
165
186
|
lines.push(` ${b.bucket}: ${b.count}`);
|
|
166
187
|
const hint = tuningHint(stats, service.cfg.matchThreshold);
|
package/lib/config.d.ts
CHANGED
|
@@ -20,15 +20,21 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
|
|
|
20
20
|
totalBudget: z<number, number>;
|
|
21
21
|
/** Retrieval score threshold — tune via /topics stats near-miss evidence. */
|
|
22
22
|
matchThreshold: z<number, number>;
|
|
23
|
-
/** Additive boost per tag hit. */
|
|
23
|
+
/** Additive boost per tag hit (v4: total cap = this value, was ×3). */
|
|
24
24
|
tagBoost: z<number, number>;
|
|
25
|
+
/** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
|
|
26
|
+
injectMode: z<string, string>;
|
|
27
|
+
/** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
|
|
28
|
+
qualityLane: z<string, string>;
|
|
25
29
|
/** depends-graph expansion depth (0 disables). */
|
|
26
30
|
graphDepth: z<number, number>;
|
|
27
31
|
/** Days within which a topic counts as recent (+0.2). */
|
|
28
32
|
recencyWindowDays: z<number, number>;
|
|
29
33
|
/** Capture each turn's user/assistant text as raw observations (M2). */
|
|
30
34
|
autoObserve: z<boolean, boolean>;
|
|
31
|
-
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
35
|
+
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
36
|
+
* v4 default flipped to false: one-shot subagent turns diluted the pool.
|
|
37
|
+
* The slow quality lane never runs for subagents either way (hard guard). */
|
|
32
38
|
includeSubagents: z<boolean, boolean>;
|
|
33
39
|
/** Max auto-captured chars per side (user/assistant) per turn. */
|
|
34
40
|
observationMaxChars: z<number, number>;
|
|
@@ -62,15 +68,21 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
|
|
|
62
68
|
totalBudget: z<number, number>;
|
|
63
69
|
/** Retrieval score threshold — tune via /topics stats near-miss evidence. */
|
|
64
70
|
matchThreshold: z<number, number>;
|
|
65
|
-
/** Additive boost per tag hit. */
|
|
71
|
+
/** Additive boost per tag hit (v4: total cap = this value, was ×3). */
|
|
66
72
|
tagBoost: z<number, number>;
|
|
73
|
+
/** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
|
|
74
|
+
injectMode: z<string, string>;
|
|
75
|
+
/** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
|
|
76
|
+
qualityLane: z<string, string>;
|
|
67
77
|
/** depends-graph expansion depth (0 disables). */
|
|
68
78
|
graphDepth: z<number, number>;
|
|
69
79
|
/** Days within which a topic counts as recent (+0.2). */
|
|
70
80
|
recencyWindowDays: z<number, number>;
|
|
71
81
|
/** Capture each turn's user/assistant text as raw observations (M2). */
|
|
72
82
|
autoObserve: z<boolean, boolean>;
|
|
73
|
-
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
83
|
+
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
84
|
+
* v4 default flipped to false: one-shot subagent turns diluted the pool.
|
|
85
|
+
* The slow quality lane never runs for subagents either way (hard guard). */
|
|
74
86
|
includeSubagents: z<boolean, boolean>;
|
|
75
87
|
/** Max auto-captured chars per side (user/assistant) per turn. */
|
|
76
88
|
observationMaxChars: z<number, number>;
|
|
@@ -99,6 +111,8 @@ export type TopicsConfigValue = {
|
|
|
99
111
|
totalBudget: number;
|
|
100
112
|
matchThreshold: number;
|
|
101
113
|
tagBoost: number;
|
|
114
|
+
injectMode: string;
|
|
115
|
+
qualityLane: string;
|
|
102
116
|
graphDepth: number;
|
|
103
117
|
recencyWindowDays: number;
|
|
104
118
|
autoObserve: boolean;
|
|
@@ -112,7 +126,7 @@ export type TopicsConfigValue = {
|
|
|
112
126
|
distillMaxModelCalls: number;
|
|
113
127
|
pushDebounceSeconds: number;
|
|
114
128
|
};
|
|
115
|
-
export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "pushDebounceSeconds"];
|
|
129
|
+
export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "pushDebounceSeconds"];
|
|
116
130
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
117
131
|
/** CamelCase → dash-display (topK → top-k) for command output. */
|
|
118
132
|
export declare function displayKey(key: string): string;
|
package/lib/config.js
CHANGED
|
@@ -20,16 +20,22 @@ export const TopicsConfig = z.object({
|
|
|
20
20
|
totalBudget: z.number().default(1500),
|
|
21
21
|
/** Retrieval score threshold — tune via /topics stats near-miss evidence. */
|
|
22
22
|
matchThreshold: z.number().default(0.3),
|
|
23
|
-
/** Additive boost per tag hit. */
|
|
23
|
+
/** Additive boost per tag hit (v4: total cap = this value, was ×3). */
|
|
24
24
|
tagBoost: z.number().default(0.15),
|
|
25
|
+
/** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
|
|
26
|
+
injectMode: z.string().default('pointer'),
|
|
27
|
+
/** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
|
|
28
|
+
qualityLane: z.string().default('sampled'),
|
|
25
29
|
/** depends-graph expansion depth (0 disables). */
|
|
26
30
|
graphDepth: z.number().default(2),
|
|
27
31
|
/** Days within which a topic counts as recent (+0.2). */
|
|
28
32
|
recencyWindowDays: z.number().default(7),
|
|
29
33
|
/** Capture each turn's user/assistant text as raw observations (M2). */
|
|
30
34
|
autoObserve: z.boolean().default(true),
|
|
31
|
-
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
32
|
-
|
|
35
|
+
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
|
|
36
|
+
* v4 default flipped to false: one-shot subagent turns diluted the pool.
|
|
37
|
+
* The slow quality lane never runs for subagents either way (hard guard). */
|
|
38
|
+
includeSubagents: z.boolean().default(false),
|
|
33
39
|
/** Max auto-captured chars per side (user/assistant) per turn. */
|
|
34
40
|
observationMaxChars: z.number().default(2000),
|
|
35
41
|
/** Background distill cadence: every N turns of a long session. */
|
|
@@ -57,6 +63,8 @@ export const CONFIG_KEYS = [
|
|
|
57
63
|
'totalBudget',
|
|
58
64
|
'matchThreshold',
|
|
59
65
|
'tagBoost',
|
|
66
|
+
'injectMode',
|
|
67
|
+
'qualityLane',
|
|
60
68
|
'graphDepth',
|
|
61
69
|
'recencyWindowDays',
|
|
62
70
|
'autoObserve',
|
|
@@ -109,6 +117,16 @@ export function parseConfigValue(key, raw) {
|
|
|
109
117
|
return { error: `${key} 需要非负整数` };
|
|
110
118
|
return n;
|
|
111
119
|
}
|
|
120
|
+
case 'injectMode': {
|
|
121
|
+
if (raw === 'pointer' || raw === 'digest')
|
|
122
|
+
return raw;
|
|
123
|
+
return { error: 'inject-mode 取值 pointer|digest' };
|
|
124
|
+
}
|
|
125
|
+
case 'qualityLane': {
|
|
126
|
+
if (raw === 'off' || raw === 'sampled' || raw === 'always')
|
|
127
|
+
return raw;
|
|
128
|
+
return { error: 'quality-lane 取值 off|sampled|always' };
|
|
129
|
+
}
|
|
112
130
|
case 'matchThreshold':
|
|
113
131
|
case 'tagBoost': {
|
|
114
132
|
const n = Number(raw);
|
package/lib/digest.d.ts
CHANGED
|
@@ -22,6 +22,19 @@ export declare function topicDigest(input: DigestInput, budgetTokens: number): {
|
|
|
22
22
|
text: string;
|
|
23
23
|
usedTokens: number;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Pointer view (v4 dual-channel design §4.1) — the default fast-lane shape.
|
|
27
|
+
* Each entry is a two-line pointer (title/status/slug/score + one-line
|
|
28
|
+
* description, ≤80 tok); the model pulls full content via topic_open when the
|
|
29
|
+
* pointer is actually relevant. 宁缺勿滥: the retrieval gate decides what
|
|
30
|
+
* becomes a pointer, the budget only caps how many land.
|
|
31
|
+
*/
|
|
32
|
+
export declare const POINTER_PER_TOPIC = 80;
|
|
33
|
+
export declare const POINTER_TOTAL = 600;
|
|
34
|
+
export declare function pointerEntry(input: DigestInput, budgetTokens: number): {
|
|
35
|
+
text: string;
|
|
36
|
+
usedTokens: number;
|
|
37
|
+
};
|
|
25
38
|
export interface AssembleResult {
|
|
26
39
|
text: string;
|
|
27
40
|
usedTokens: number;
|
|
@@ -30,13 +43,37 @@ export interface AssembleResult {
|
|
|
30
43
|
slug: string;
|
|
31
44
|
reason: string;
|
|
32
45
|
}[];
|
|
46
|
+
/** Slow-lane pointers that entered the context (v4 §4.2). */
|
|
47
|
+
slowIncluded?: string[];
|
|
33
48
|
}
|
|
34
49
|
export interface AssembleConfig {
|
|
35
50
|
perTopicBudget: number;
|
|
36
51
|
totalBudget: number;
|
|
37
52
|
}
|
|
53
|
+
/** Slow-lane pointer input — roster metadata plus the lane's why line. */
|
|
54
|
+
export interface SlowPointerInput {
|
|
55
|
+
slug: string;
|
|
56
|
+
title: string;
|
|
57
|
+
status: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
why: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One slow-lane pointer: same shape as a fast pointer plus the mandatory why
|
|
63
|
+
* line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
|
|
64
|
+
* not the lexical threshold.
|
|
65
|
+
*/
|
|
66
|
+
export declare function slowEntryText(input: SlowPointerInput, budgetTokens: number): {
|
|
67
|
+
text: string;
|
|
68
|
+
usedTokens: number;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Greedily pack pointer entries (hits already score-ordered) under the total
|
|
72
|
+
* budget. Zero hits → empty text (ADR 0006: 零命中零注入).
|
|
73
|
+
*/
|
|
74
|
+
export declare function assemblePointer(entries: readonly DigestInput[], cfg: AssembleConfig, slow?: readonly SlowPointerInput[]): AssembleResult;
|
|
38
75
|
/**
|
|
39
76
|
* Greedily pack per-topic digests (hits already score-ordered) under the
|
|
40
77
|
* total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
|
|
41
78
|
*/
|
|
42
|
-
export declare function assembleInjection(entries: readonly DigestInput[], cfg: AssembleConfig): AssembleResult;
|
|
79
|
+
export declare function assembleInjection(entries: readonly DigestInput[], cfg: AssembleConfig, slow?: readonly SlowPointerInput[]): AssembleResult;
|
package/lib/digest.js
CHANGED
|
@@ -66,36 +66,117 @@ export function topicDigest(input, budgetTokens) {
|
|
|
66
66
|
const text = lines.join('\n');
|
|
67
67
|
return { text, usedTokens: estimateTokens(text) };
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Pointer view (v4 dual-channel design §4.1) — the default fast-lane shape.
|
|
71
|
+
* Each entry is a two-line pointer (title/status/slug/score + one-line
|
|
72
|
+
* description, ≤80 tok); the model pulls full content via topic_open when the
|
|
73
|
+
* pointer is actually relevant. 宁缺勿滥: the retrieval gate decides what
|
|
74
|
+
* becomes a pointer, the budget only caps how many land.
|
|
75
|
+
*/
|
|
76
|
+
export const POINTER_PER_TOPIC = 80;
|
|
77
|
+
export const POINTER_TOTAL = 600;
|
|
78
|
+
export function pointerEntry(input, budgetTokens) {
|
|
79
|
+
const { doc, hit } = input;
|
|
80
|
+
const fm = doc.fm;
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`### ${fm.title} [${fm.status}] (topics:${input.slug} score:${hit.score.toFixed(2)})`);
|
|
83
|
+
let one = fm.description ?? '';
|
|
84
|
+
if (one === '') {
|
|
85
|
+
const conclusion = sectionOf(doc.body, CONCLUSION_HEADING) ?? '';
|
|
86
|
+
one = firstParagraph(conclusion);
|
|
87
|
+
}
|
|
88
|
+
if (one !== '')
|
|
89
|
+
lines.push(truncateToTokens(one.replace(/\s+/g, ' ').trim(), Math.max(16, budgetTokens - 24)).text);
|
|
90
|
+
if (hit.reasons.includes('conflicted-demoted'))
|
|
91
|
+
lines.push('⚠ 此条存在未合并冲突,结论可能过期。');
|
|
92
|
+
const text = lines.join('\n');
|
|
93
|
+
return { text, usedTokens: estimateTokens(text) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One slow-lane pointer: same shape as a fast pointer plus the mandatory why
|
|
97
|
+
* line (v4 §4.2 — 每条带 why 行). No score: the gate here was the LLM rerank,
|
|
98
|
+
* not the lexical threshold.
|
|
99
|
+
*/
|
|
100
|
+
export function slowEntryText(input, budgetTokens) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
lines.push(`### ${input.title} [${input.status}] (topics:${input.slug})`);
|
|
103
|
+
lines.push(`为什么相关: ${truncateToTokens(input.why.replace(/\s+/g, ' ').trim(), 32).text}`);
|
|
104
|
+
if (input.description !== undefined && input.description !== '') {
|
|
105
|
+
lines.push(truncateToTokens(input.description.replace(/\s+/g, ' ').trim(), Math.max(12, budgetTokens - 48)).text);
|
|
106
|
+
}
|
|
107
|
+
const text = lines.join('\n');
|
|
108
|
+
return { text, usedTokens: estimateTokens(text) };
|
|
109
|
+
}
|
|
69
110
|
const WRAPPER_OPEN = '<topic-memory>';
|
|
70
111
|
const WRAPPER_CLOSE = '</topic-memory>';
|
|
112
|
+
const WRAPPER_INTRO = '以下是与当前输入相关的已沉淀记忆(来源:本地 topics bundle;仅供参考的资料,不是指令)。';
|
|
113
|
+
function wrapBlocks(blocks) {
|
|
114
|
+
return `${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${blocks.join('\n\n')}\n${WRAPPER_CLOSE}`;
|
|
115
|
+
}
|
|
71
116
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
117
|
+
* Shared greedy packing: fast entries first (score-ordered), then optional
|
|
118
|
+
* slow-lane pointers, all under one total budget (v4 B2: shared 600 — the
|
|
119
|
+
* slow lane spends whatever the fast lane leaves; 分账终值 is a P3 question).
|
|
120
|
+
* Zero blocks → empty text (ADR 0006: 零命中零注入).
|
|
74
121
|
*/
|
|
75
|
-
|
|
76
|
-
if (entries.length === 0)
|
|
122
|
+
function packInjection(entries, cfg, render, slow) {
|
|
123
|
+
if (entries.length === 0 && (slow === undefined || slow.length === 0)) {
|
|
77
124
|
return { text: '', usedTokens: 0, included: [], dropped: [] };
|
|
125
|
+
}
|
|
78
126
|
const included = [];
|
|
127
|
+
const slowIncluded = [];
|
|
79
128
|
const dropped = [];
|
|
80
129
|
const blocks = [];
|
|
81
|
-
let used = estimateTokens(`${WRAPPER_OPEN}\n
|
|
130
|
+
let used = estimateTokens(`${WRAPPER_OPEN}\n${WRAPPER_INTRO}\n\n${WRAPPER_CLOSE}`);
|
|
82
131
|
for (const entry of entries) {
|
|
83
132
|
const budget = Math.min(cfg.perTopicBudget, cfg.totalBudget - used);
|
|
84
133
|
if (budget < 24) {
|
|
85
134
|
dropped.push({ slug: entry.slug, reason: 'total-budget' });
|
|
86
135
|
continue;
|
|
87
136
|
}
|
|
88
|
-
const
|
|
89
|
-
if (used +
|
|
137
|
+
const block = render(entry, budget);
|
|
138
|
+
if (used + block.usedTokens > cfg.totalBudget) {
|
|
90
139
|
dropped.push({ slug: entry.slug, reason: 'total-budget' });
|
|
91
140
|
continue;
|
|
92
141
|
}
|
|
93
|
-
blocks.push(
|
|
142
|
+
blocks.push(block.text);
|
|
94
143
|
included.push(entry.slug);
|
|
95
|
-
used +=
|
|
144
|
+
used += block.usedTokens;
|
|
145
|
+
}
|
|
146
|
+
for (const item of slow ?? []) {
|
|
147
|
+
const budget = cfg.totalBudget - used;
|
|
148
|
+
if (budget < 24) {
|
|
149
|
+
dropped.push({ slug: item.slug, reason: 'slow-budget' });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const block = slowEntryText(item, Math.min(POINTER_PER_TOPIC, budget));
|
|
153
|
+
if (used + block.usedTokens > cfg.totalBudget) {
|
|
154
|
+
dropped.push({ slug: item.slug, reason: 'slow-budget' });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
blocks.push(block.text);
|
|
158
|
+
slowIncluded.push(item.slug);
|
|
159
|
+
used += block.usedTokens;
|
|
96
160
|
}
|
|
97
161
|
if (blocks.length === 0)
|
|
98
162
|
return { text: '', usedTokens: 0, included: [], dropped };
|
|
99
|
-
const text =
|
|
100
|
-
|
|
163
|
+
const text = wrapBlocks(blocks);
|
|
164
|
+
const result = { text, usedTokens: estimateTokens(text), included, dropped };
|
|
165
|
+
if (slowIncluded.length > 0)
|
|
166
|
+
result.slowIncluded = slowIncluded;
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Greedily pack pointer entries (hits already score-ordered) under the total
|
|
171
|
+
* budget. Zero hits → empty text (ADR 0006: 零命中零注入).
|
|
172
|
+
*/
|
|
173
|
+
export function assemblePointer(entries, cfg, slow) {
|
|
174
|
+
return packInjection(entries, cfg, pointerEntry, slow);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Greedily pack per-topic digests (hits already score-ordered) under the
|
|
178
|
+
* total budget. Zero hits → empty text (ADR 0006: 零命中零注入).
|
|
179
|
+
*/
|
|
180
|
+
export function assembleInjection(entries, cfg, slow) {
|
|
181
|
+
return packInjection(entries, cfg, topicDigest, slow);
|
|
101
182
|
}
|
package/lib/distill.d.ts
CHANGED
package/lib/distill.js
CHANGED
|
@@ -14,12 +14,13 @@ export const SYSTEM_PROMPT = [
|
|
|
14
14
|
'你是 topic 记忆库的蒸馏引擎。输入是若干条未蒸馏的会话观察(JSON)和现有 topic 索引。',
|
|
15
15
|
'任务:把观察沉淀成 Topic 操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
|
|
16
16
|
'输出格式:{"ops": [...]},每个元素:',
|
|
17
|
-
' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"depends":["前置topic的slug"],',
|
|
17
|
+
' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"triggers":["应想起本条的触发词"],"depends":["前置topic的slug"],',
|
|
18
18
|
' "open_questions":["未决问题"],"impact":["影响面"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
|
|
19
19
|
' "observed_ids":["obs-..."]}',
|
|
20
20
|
' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"observed_ids":["obs-..."]}',
|
|
21
21
|
'规则:',
|
|
22
22
|
'- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「未蒸馏观察」里列出的 id 中逐字复制(形如 "obs-...");create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
|
|
23
|
+
'- create 建议带 triggers:3-8 个「什么输入出现时应该想起这条记忆」的短语,写具体名词/术语,禁止 dsh、配置、错误 之类的泛词。',
|
|
23
24
|
'- conclusion 必须自含(不依赖观察原文也能读懂),写「目前有效的结论」,不是流水账。',
|
|
24
25
|
'- 同一主题只允许一个 create;已有相近 topic 时用 update 修订它的 conclusion。',
|
|
25
26
|
'- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
|
|
@@ -512,6 +513,7 @@ export class Distiller {
|
|
|
512
513
|
conclusion: op.conclusion,
|
|
513
514
|
description: op.description,
|
|
514
515
|
tags: op.tags,
|
|
516
|
+
triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
|
|
515
517
|
depends: op.depends,
|
|
516
518
|
openQuestions: op.open_questions,
|
|
517
519
|
impact: op.impact,
|
package/lib/ilog.d.ts
CHANGED
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @module ilog
|
|
7
7
|
*/
|
|
8
|
+
/** Shadow re-gate verdict for one slow-lane pick (log-only, never blocks — v4 B3). */
|
|
9
|
+
export interface ShadowVerdict {
|
|
10
|
+
slug: string;
|
|
11
|
+
pass: boolean;
|
|
12
|
+
why: string;
|
|
13
|
+
}
|
|
14
|
+
/** Slow-lane query-build shape audit (v4 §4.3): priming evidence, no content. */
|
|
15
|
+
export interface QueryBuildShape {
|
|
16
|
+
rawChars: number;
|
|
17
|
+
keptChars: number;
|
|
18
|
+
/** Content categories the query build dropped (e.g. pasted dumps). */
|
|
19
|
+
stripped: string[];
|
|
20
|
+
}
|
|
21
|
+
/** One slow-lane pointer that entered (or was budgeted out of) the context. */
|
|
22
|
+
export interface SlowItem {
|
|
23
|
+
slug: string;
|
|
24
|
+
why: string;
|
|
25
|
+
}
|
|
8
26
|
export interface InjectionRecord {
|
|
9
27
|
at: string;
|
|
10
28
|
sessionId?: string;
|
|
@@ -17,10 +35,13 @@ export interface InjectionRecord {
|
|
|
17
35
|
score: number;
|
|
18
36
|
reasons: string[];
|
|
19
37
|
viaGraph: boolean;
|
|
38
|
+
strong?: boolean;
|
|
39
|
+
bodyHits?: number;
|
|
20
40
|
}[];
|
|
21
41
|
nearMisses: {
|
|
22
42
|
slug: string;
|
|
23
43
|
score: number;
|
|
44
|
+
reasons?: string[];
|
|
24
45
|
}[];
|
|
25
46
|
injected: boolean;
|
|
26
47
|
why?: string;
|
|
@@ -31,6 +52,21 @@ export interface InjectionRecord {
|
|
|
31
52
|
/** Slugs blocked this round by session-level injection dedup (never assembled). */
|
|
32
53
|
deduped?: string[];
|
|
33
54
|
usedTokens?: number;
|
|
55
|
+
/** fast = lexical pointers only; slow = async picks only; mixed = both. */
|
|
56
|
+
lane?: 'fast' | 'slow' | 'mixed';
|
|
57
|
+
/** Slow lane: the consumed pending died at its hard bounds (TTL / turn-lag). */
|
|
58
|
+
slowExpired?: 'ttl' | 'turn-lag';
|
|
59
|
+
/** Slow lane: when the pending was computed (turn/end) and consumed (spliced). */
|
|
60
|
+
computedAt?: string;
|
|
61
|
+
consumedAt?: string;
|
|
62
|
+
/** Log-only lexical re-gate verdicts taken at consumption time. */
|
|
63
|
+
shadowVerdict?: ShadowVerdict[];
|
|
64
|
+
queryBuild?: QueryBuildShape;
|
|
65
|
+
/** Slow-lane model route (`provider/model`) and pipeline wall time in ms. */
|
|
66
|
+
slowModel?: string;
|
|
67
|
+
slowMs?: number;
|
|
68
|
+
/** Slow-lane pointers this round, in delivery order. */
|
|
69
|
+
slow?: SlowItem[];
|
|
34
70
|
}
|
|
35
71
|
export interface AggregateStats {
|
|
36
72
|
rounds: number;
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildTopicTools } from "./tools.js";
|
|
|
6
6
|
import { buildTopicsCommand } from "./commands.js";
|
|
7
7
|
import { Observer, textOf } from "./observer.js";
|
|
8
8
|
import { Distiller, defaultModelCaller } from "./distill.js";
|
|
9
|
+
import { SlowLane } from "./quality.js";
|
|
9
10
|
import { CONFIG_KEYS, TopicsConfig } from "./config.js";
|
|
10
11
|
import { isDelegated } from "./delegation.js";
|
|
11
12
|
export const name = 'dsh-topics-memory';
|
|
@@ -167,12 +168,27 @@ export function apply(ctx) {
|
|
|
167
168
|
const state = turns.get(sessionId);
|
|
168
169
|
if (state === undefined)
|
|
169
170
|
return;
|
|
170
|
-
const
|
|
171
|
-
const seen =
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
171
|
+
const cfg = cfgNow();
|
|
172
|
+
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined;
|
|
173
|
+
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
174
|
+
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
175
|
+
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
176
|
+
let slow;
|
|
177
|
+
let slowExpired;
|
|
178
|
+
if (cfg.autoInject) {
|
|
179
|
+
const consumed = slowLane.consume(sessionId, observer.turnCountOf(sessionId));
|
|
180
|
+
if (consumed !== undefined) {
|
|
181
|
+
if ('pending' in consumed)
|
|
182
|
+
slow = consumed.pending;
|
|
183
|
+
else
|
|
184
|
+
slowExpired = consumed.expired;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired);
|
|
188
|
+
// Mark only what entered the context this round (hits AND slow picks →
|
|
189
|
+
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
190
|
+
// injectable).
|
|
191
|
+
if (cfg.injectDedup && (r.included.length > 0 || r.slowIncluded.length > 0)) {
|
|
176
192
|
let marked = injectedBySession.get(sessionId);
|
|
177
193
|
if (marked === undefined) {
|
|
178
194
|
marked = new Set();
|
|
@@ -180,6 +196,8 @@ export function apply(ctx) {
|
|
|
180
196
|
}
|
|
181
197
|
for (const slug of r.included)
|
|
182
198
|
marked.add(slug);
|
|
199
|
+
for (const slug of r.slowIncluded)
|
|
200
|
+
marked.add(slug);
|
|
183
201
|
}
|
|
184
202
|
state.injectionText = r.text;
|
|
185
203
|
}
|
|
@@ -187,6 +205,42 @@ export function apply(ctx) {
|
|
|
187
205
|
// Contained: a retrieval failure must never break the turn.
|
|
188
206
|
}
|
|
189
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Slow-lane production trigger at turn/end (v4 §4.2). Host-side gates live
|
|
210
|
+
* here: subagent sessions never run the lane (hard guard, independent of
|
|
211
|
+
* includeSubagents) and a distill run in flight makes the lane yield (the
|
|
212
|
+
* every-N-turns distill cadence shares this trigger).
|
|
213
|
+
*/
|
|
214
|
+
function dispatchSlowLane(sessionId) {
|
|
215
|
+
try {
|
|
216
|
+
// Injection is off → the lane would burn aux LLM calls on pendings no
|
|
217
|
+
// spliced will ever consume (the spliced branch early-returns on
|
|
218
|
+
// autoInject before reaching the consume point).
|
|
219
|
+
if (!cfgNow().autoInject)
|
|
220
|
+
return;
|
|
221
|
+
if (isDelegated(agents()?.get(sessionId)))
|
|
222
|
+
return;
|
|
223
|
+
if (distiller.hasPending(sessionId))
|
|
224
|
+
return;
|
|
225
|
+
// Same trigger-time capture the distill trigger uses: the agent's
|
|
226
|
+
// scoped llm is alive NOW, and the lane's caller reads the captured
|
|
227
|
+
// entry lazily inside its async pipeline (released via the
|
|
228
|
+
// slowLane-aware guards once the pipeline settles).
|
|
229
|
+
try {
|
|
230
|
+
captureFromAgent(agents()?.get(sessionId));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// contained — the captured instance, if any, still walks the chain
|
|
234
|
+
}
|
|
235
|
+
slowLane.dispatch(sessionId, {
|
|
236
|
+
ring: observer.recentTurns(sessionId),
|
|
237
|
+
turnId: observer.turnCountOf(sessionId),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// contained — the lane must never break the event handler
|
|
242
|
+
}
|
|
243
|
+
}
|
|
190
244
|
// ---- Tools ----
|
|
191
245
|
const tools = ctx.tools;
|
|
192
246
|
for (const tool of buildTopicTools(service))
|
|
@@ -229,6 +283,23 @@ export function apply(ctx) {
|
|
|
229
283
|
return c.distillProvider !== '' && c.distillModel !== '' ? { provider: c.distillProvider, model: c.distillModel } : undefined;
|
|
230
284
|
});
|
|
231
285
|
const distiller = new Distiller(service, caller);
|
|
286
|
+
/**
|
|
287
|
+
* The ONE sessionLlm release decision, shared by every settle path (distill
|
|
288
|
+
* run settle, slow-lane pipeline settle, teardown, manual-distill decline):
|
|
289
|
+
* drop the captured entry only when no future read can need it. Keeping
|
|
290
|
+
* this in one place is what stopped the slow lane from re-opening the
|
|
291
|
+
* concern-1 leak (a disposal landing mid-pipeline must be closed out by
|
|
292
|
+
* the pipeline's own settle, not only by the one-shot disposal event).
|
|
293
|
+
*/
|
|
294
|
+
const releaseSessionLlm = (sessionId) => {
|
|
295
|
+
if (!distiller.hasPending(sessionId) && !slowLane.hasInFlight(sessionId))
|
|
296
|
+
sessionLlm.delete(sessionId);
|
|
297
|
+
};
|
|
298
|
+
// Slow quality lane (v4 §4.2): shares the distill caller/route; owns its
|
|
299
|
+
// own pending lifecycle (produce at turn/end, consume at the next splice).
|
|
300
|
+
// The settle hook re-runs the release check because a disposal that landed
|
|
301
|
+
// mid-pipeline deferred to THIS pipeline and never fires again.
|
|
302
|
+
const slowLane = new SlowLane(service, caller, releaseSessionLlm);
|
|
232
303
|
// ---- Observer (M2) ----
|
|
233
304
|
// The process-exit disposer awaits this run (bounded): the callback below
|
|
234
305
|
// records it synchronously when the fake 'dispose' session's end trigger
|
|
@@ -252,12 +323,7 @@ export function apply(ctx) {
|
|
|
252
323
|
if (run !== undefined) {
|
|
253
324
|
if (sessionId === 'dispose')
|
|
254
325
|
exitDistill = run;
|
|
255
|
-
void run
|
|
256
|
-
.finally(() => {
|
|
257
|
-
if (!distiller.hasPending(sessionId))
|
|
258
|
-
sessionLlm.delete(sessionId);
|
|
259
|
-
})
|
|
260
|
-
.catch(() => undefined);
|
|
326
|
+
void run.finally(() => releaseSessionLlm(sessionId)).catch(() => undefined);
|
|
261
327
|
}
|
|
262
328
|
});
|
|
263
329
|
// ---- Session events: injection + observation ----
|
|
@@ -337,8 +403,16 @@ export function apply(ctx) {
|
|
|
337
403
|
// Restore/resume boundary: the persisted context is being replayed — a
|
|
338
404
|
// fresh process has no dedup registry, so allow re-injection.
|
|
339
405
|
injectedBySession.delete(sessionId);
|
|
406
|
+
slowLane.clear(sessionId);
|
|
340
407
|
}
|
|
341
408
|
observer.onSessionEvent(sessionId, event.type, event.data);
|
|
409
|
+
// AFTER the observer processed the turn (turnCount already bumped, so
|
|
410
|
+
// the pending stamps the correct turn id; the every-N distill trigger
|
|
411
|
+
// has already claimed its cadence slot — the lane yields to it inside
|
|
412
|
+
// dispatchSlowLane). The turns-map lifecycle above never touches the
|
|
413
|
+
// lane's pending map — the two coexist by design (v4 生命周期表).
|
|
414
|
+
if (event.type === 'turn/end')
|
|
415
|
+
dispatchSlowLane(sessionId);
|
|
342
416
|
}));
|
|
343
417
|
// ---- Session teardown: real cordis events, not session/event types ----
|
|
344
418
|
// dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
|
|
@@ -363,12 +437,12 @@ export function apply(ctx) {
|
|
|
363
437
|
captureFromAgent(subject.agent);
|
|
364
438
|
observer.onSessionEvent(sessionId, name, undefined);
|
|
365
439
|
injectedBySession.delete(sessionId);
|
|
440
|
+
slowLane.clear(sessionId);
|
|
366
441
|
// A teardown-triggered run (session-end distill) reads the payload
|
|
367
442
|
// capture lazily: drop the entry here only when no run can still read
|
|
368
|
-
// it.
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
sessionLlm.delete(sessionId);
|
|
443
|
+
// it. The slow lane's in-flight pipeline reads it too (shared caller);
|
|
444
|
+
// its settle hook re-runs this check when the pipeline finishes.
|
|
445
|
+
releaseSessionLlm(sessionId);
|
|
372
446
|
}));
|
|
373
447
|
}
|
|
374
448
|
// ---- Sync lifecycle: pull on session start, flush on dispose ----
|
|
@@ -465,16 +539,12 @@ export function apply(ctx) {
|
|
|
465
539
|
// capture above may have (re)armed the session's llm entry; with no
|
|
466
540
|
// run pending to consume-and-release it, drop it here — a declined
|
|
467
541
|
// request leaves no settle hook behind.
|
|
468
|
-
|
|
469
|
-
sessionLlm.delete(sessionId);
|
|
542
|
+
releaseSessionLlm(sessionId);
|
|
470
543
|
return distiller.configured
|
|
471
544
|
? { ok: false, reason: 'in-flight', created: [], updated: [], marked: 0 }
|
|
472
545
|
: { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 };
|
|
473
546
|
}
|
|
474
|
-
return run.finally(() =>
|
|
475
|
-
if (!distiller.hasPending(sessionId))
|
|
476
|
-
sessionLlm.delete(sessionId);
|
|
477
|
-
});
|
|
547
|
+
return run.finally(() => releaseSessionLlm(sessionId));
|
|
478
548
|
};
|
|
479
549
|
cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill)), 'topics: /topics');
|
|
480
550
|
});
|
|
@@ -489,10 +559,12 @@ const DEFAULTS = {
|
|
|
489
559
|
totalBudget: 1500,
|
|
490
560
|
matchThreshold: 0.3,
|
|
491
561
|
tagBoost: 0.15,
|
|
562
|
+
injectMode: 'pointer',
|
|
563
|
+
qualityLane: 'sampled',
|
|
492
564
|
graphDepth: 2,
|
|
493
565
|
recencyWindowDays: 7,
|
|
494
566
|
autoObserve: true,
|
|
495
|
-
includeSubagents:
|
|
567
|
+
includeSubagents: false,
|
|
496
568
|
observationMaxChars: 2000,
|
|
497
569
|
distillEveryTurns: 5,
|
|
498
570
|
distillOnSessionEnd: true,
|