@aiwayds/dsh-topics-memory 0.10.0 → 0.12.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 +83 -36
- package/lib/config.d.ts +12 -1
- package/lib/config.js +7 -0
- package/lib/ilog.d.ts +2 -0
- package/lib/ilog.js +6 -4
- package/lib/index.js +18 -1
- package/lib/quality.d.ts +8 -0
- package/lib/quality.js +7 -2
- package/lib/service.d.ts +24 -6
- package/lib/service.js +105 -11
- package/lib/tools.js +14 -1
- package/package.json +1 -1
- package/src/commands.ts +86 -35
- package/src/config.ts +8 -0
- package/src/ilog.ts +8 -4
- package/src/index.ts +15 -1
- package/src/quality.ts +15 -2
- package/src/service.ts +108 -12
- package/src/tools.ts +13 -1
package/lib/service.js
CHANGED
|
@@ -15,6 +15,8 @@ import { aggregateStats, querySample } from "./ilog.js";
|
|
|
15
15
|
import { fileHistory, fileAtRev } from "./git.js";
|
|
16
16
|
export class TopicsService {
|
|
17
17
|
cache = new Map();
|
|
18
|
+
/** mtime-keyed parse of the observations log, grouped by session. */
|
|
19
|
+
echoCache;
|
|
18
20
|
store;
|
|
19
21
|
getConfig;
|
|
20
22
|
sync;
|
|
@@ -82,6 +84,60 @@ export class TopicsService {
|
|
|
82
84
|
/** Drop the roster cache (tests, bulk external edits). */
|
|
83
85
|
invalidate() {
|
|
84
86
|
this.cache.clear();
|
|
87
|
+
this.echoCache = undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Slugs distilled from THIS session's own turns — the echo set (2026-09
|
|
91
|
+
* audit finding #3: a topic distilled from in-session knowledge injected
|
|
92
|
+
* back into the same session is a lagging echo of what the model already
|
|
93
|
+
* said, occasionally stale enough to contradict the newer in-session
|
|
94
|
+
* conclusion). Provenance is durable in the observations log: each line
|
|
95
|
+
* carries `sessionId` and, once distilled, `distilledInto`. Sync read on
|
|
96
|
+
* the injection hot path, mtime-cached like the roster.
|
|
97
|
+
*/
|
|
98
|
+
echoSlugsSync(sessionId) {
|
|
99
|
+
const file = join(this.store.metaDir(), 'observations.jsonl');
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = statSync(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return new Set();
|
|
106
|
+
}
|
|
107
|
+
let bySession = this.echoCache;
|
|
108
|
+
if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
|
|
109
|
+
bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() };
|
|
110
|
+
try {
|
|
111
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.trim() === '')
|
|
114
|
+
continue;
|
|
115
|
+
let obs;
|
|
116
|
+
try {
|
|
117
|
+
obs = JSON.parse(line);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue; // torn tail line
|
|
121
|
+
}
|
|
122
|
+
if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto))
|
|
123
|
+
continue;
|
|
124
|
+
let set = bySession.bySession.get(obs.sessionId);
|
|
125
|
+
if (set === undefined) {
|
|
126
|
+
set = new Set();
|
|
127
|
+
bySession.bySession.set(obs.sessionId, set);
|
|
128
|
+
}
|
|
129
|
+
for (const slug of obs.distilledInto) {
|
|
130
|
+
if (typeof slug === 'string' && slug !== '')
|
|
131
|
+
set.add(okf.slugify(slug));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return new Set(); // unreadable log: fail open (no suppression)
|
|
137
|
+
}
|
|
138
|
+
this.echoCache = bySession;
|
|
139
|
+
}
|
|
140
|
+
return bySession.bySession.get(sessionId) ?? new Set();
|
|
85
141
|
}
|
|
86
142
|
/**
|
|
87
143
|
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
@@ -151,15 +207,19 @@ export class TopicsService {
|
|
|
151
207
|
*
|
|
152
208
|
* `dedup.exclude` (session-level injection dedup) filters hits AND slow
|
|
153
209
|
* picks before assembly; excluded slugs are reported back as `deduped`
|
|
154
|
-
* (and logged as `record.deduped`), never packed. `
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
210
|
+
* (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
|
|
211
|
+
* v4 audit) filters the same way but reports separately as `echoed` /
|
|
212
|
+
* `record.echoed`: slugs distilled from THIS session's own turns.
|
|
213
|
+
* `included` mirrors the fast pointers that entered the context and
|
|
214
|
+
* `slowIncluded` the slow ones — only these may be marked as injected by
|
|
215
|
+
* the caller; budget-dropped slugs stay injectable later.
|
|
158
216
|
*
|
|
159
217
|
* `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
|
|
160
218
|
* packed after the fast pointers under the same budget, take log-only
|
|
161
219
|
* shadow re-gate verdicts against the CURRENT query (B3: record, never
|
|
162
|
-
* block), and fill the record's lane field family.
|
|
220
|
+
* block), and fill the record's lane field family. A pick the fast lane
|
|
221
|
+
* already delivers THIS round is counted as slow-delivered (it entered the
|
|
222
|
+
* context) but not packed twice.
|
|
163
223
|
*/
|
|
164
224
|
retrieveSync(query, sessionId, dedup, slow, slowExpired) {
|
|
165
225
|
const cfg = this.cfg;
|
|
@@ -194,7 +254,7 @@ export class TopicsService {
|
|
|
194
254
|
}
|
|
195
255
|
void this.store.appendInjectionRecord(trace).catch(() => undefined);
|
|
196
256
|
}
|
|
197
|
-
return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] };
|
|
257
|
+
return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] };
|
|
198
258
|
}
|
|
199
259
|
const conflicts = this.store.getConflictsSync();
|
|
200
260
|
const outcome = searchTopics(query, roster, {
|
|
@@ -206,13 +266,19 @@ export class TopicsService {
|
|
|
206
266
|
conflicts,
|
|
207
267
|
});
|
|
208
268
|
const exclude = dedup?.exclude;
|
|
269
|
+
const echo = dedup?.echo;
|
|
209
270
|
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
|
|
271
|
+
const echoed = [];
|
|
210
272
|
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
211
273
|
const entries = [];
|
|
212
274
|
const unreadable = [];
|
|
213
275
|
for (const hit of outcome.hits) {
|
|
214
276
|
if (exclude?.has(hit.slug))
|
|
215
277
|
continue;
|
|
278
|
+
if (echo?.has(hit.slug)) {
|
|
279
|
+
echoed.push(hit.slug);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
216
282
|
const doc = this.readDocSync(hit.slug);
|
|
217
283
|
if (doc === undefined) {
|
|
218
284
|
// The hit has no readable doc — without this accounting it used to
|
|
@@ -236,9 +302,21 @@ export class TopicsService {
|
|
|
236
302
|
deduped.push(item.slug);
|
|
237
303
|
continue;
|
|
238
304
|
}
|
|
305
|
+
if (echo?.has(item.slug)) {
|
|
306
|
+
if (!echoed.includes(item.slug))
|
|
307
|
+
echoed.push(item.slug);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
239
310
|
const meta = bySlug.get(item.slug);
|
|
240
311
|
if (meta === undefined)
|
|
241
312
|
continue; // vanished between production and consumption
|
|
313
|
+
if (entries.some((e) => e.slug === item.slug)) {
|
|
314
|
+
// The fast lane delivers the same slug THIS round — the content
|
|
315
|
+
// enters the context exactly once (never packed twice), and the
|
|
316
|
+
// pick still counts as delivered for the lane bookkeeping.
|
|
317
|
+
slowDelivered.push({ slug: item.slug, why: item.why });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
242
320
|
const verdict = scoreTopic(queryTokens, meta, {
|
|
243
321
|
threshold: cfg.matchThreshold,
|
|
244
322
|
topK: cfg.topK,
|
|
@@ -289,6 +367,8 @@ export class TopicsService {
|
|
|
289
367
|
record.sessionId = sessionId;
|
|
290
368
|
if (deduped.length > 0)
|
|
291
369
|
record.deduped = deduped;
|
|
370
|
+
if (echoed.length > 0)
|
|
371
|
+
record.echoed = echoed;
|
|
292
372
|
if (unreadable.length > 0) {
|
|
293
373
|
record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
|
|
294
374
|
}
|
|
@@ -328,7 +408,7 @@ export class TopicsService {
|
|
|
328
408
|
if (injection.dropped.length > 0)
|
|
329
409
|
record.dropped = [...(record.dropped ?? []), ...injection.dropped];
|
|
330
410
|
void this.store.appendInjectionRecord(record).catch(() => undefined);
|
|
331
|
-
return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded };
|
|
411
|
+
return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded };
|
|
332
412
|
}
|
|
333
413
|
/**
|
|
334
414
|
* topic_open tool path (v4 §4.1): full conclusion / open questions /
|
|
@@ -336,8 +416,16 @@ export class TopicsService {
|
|
|
336
416
|
* the store stamps it on every write, so it IS the last-updated instant).
|
|
337
417
|
* Logs an open record for the pointer-open-rate stat.
|
|
338
418
|
*/
|
|
339
|
-
async openTopic(
|
|
340
|
-
|
|
419
|
+
async openTopic(rawSlug, sessionId) {
|
|
420
|
+
// Pointers render slugs as `(topics:<slug>)` and models copy that whole
|
|
421
|
+
// token back as the argument — unwrap every `topics:`/`topics/` wrap the
|
|
422
|
+
// same way the depends edges do (audit ⑨: the wrapped form read as a
|
|
423
|
+
// missing topic). Then CANONICALIZE with the save path's own slugify:
|
|
424
|
+
// unwrap alone is case-preserving, and on a case-sensitive filesystem
|
|
425
|
+
// (Linux bundles) an uppercase-carrying argument would miss the file the
|
|
426
|
+
// save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
|
|
427
|
+
const slug = okf.slugify(okf.unwrapTopicRef(rawSlug));
|
|
428
|
+
const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined);
|
|
341
429
|
if (doc === undefined)
|
|
342
430
|
return { found: false, slug };
|
|
343
431
|
// Awaited (tool path, not hot): a settled write makes the pointer-open
|
|
@@ -345,17 +433,23 @@ export class TopicsService {
|
|
|
345
433
|
await this.store
|
|
346
434
|
.appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
|
|
347
435
|
.catch(() => undefined);
|
|
348
|
-
|
|
436
|
+
// Optional fields are OMITTED, never present-as-undefined: the host's
|
|
437
|
+
// tool-output validation rejects any object carrying an undefined-valued
|
|
438
|
+
// key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
|
|
439
|
+
// without a description made every open of them fail).
|
|
440
|
+
const found = {
|
|
349
441
|
found: true,
|
|
350
442
|
slug,
|
|
351
443
|
title: doc.fm.title,
|
|
352
444
|
status: doc.fm.status,
|
|
353
445
|
updatedAt: doc.fm.generated.at,
|
|
354
|
-
description: doc.fm.description,
|
|
355
446
|
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
356
447
|
openQuestions: doc.fm.open_questions,
|
|
357
448
|
recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
|
|
358
449
|
};
|
|
450
|
+
if (doc.fm.description !== undefined)
|
|
451
|
+
found.description = doc.fm.description;
|
|
452
|
+
return found;
|
|
359
453
|
}
|
|
360
454
|
/** Sync roster read (same mtime cache as roster()). */
|
|
361
455
|
rosterSync() {
|
package/lib/tools.js
CHANGED
|
@@ -244,7 +244,20 @@ export function buildTopicTools(service) {
|
|
|
244
244
|
},
|
|
245
245
|
async execute(args) {
|
|
246
246
|
const { entries } = await service.history(args.slug, args.limit ?? 20);
|
|
247
|
-
|
|
247
|
+
// conclusion is omitted, never present-as-undefined — the host rejects
|
|
248
|
+
// undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
|
|
249
|
+
return {
|
|
250
|
+
entries: entries.flatMap((e) => {
|
|
251
|
+
const entry = {
|
|
252
|
+
hash: e.hash,
|
|
253
|
+
date: e.date,
|
|
254
|
+
message: e.message,
|
|
255
|
+
};
|
|
256
|
+
if (typeof e.conclusion === 'string')
|
|
257
|
+
entry.conclusion = e.conclusion;
|
|
258
|
+
return [entry];
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
248
261
|
},
|
|
249
262
|
});
|
|
250
263
|
return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/commands.ts
CHANGED
|
@@ -114,18 +114,17 @@ function fail(text: string): CommandResult {
|
|
|
114
114
|
async function renderStatus(service: TopicsService): Promise<string> {
|
|
115
115
|
const s = await service.store.status()
|
|
116
116
|
const cfg = service.cfg
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
117
|
+
const rows: [string, string][] = [
|
|
118
|
+
['模式', service.githubMode ? `github(${cell(cfg.repo)})` : 'local-only'],
|
|
119
|
+
['Topics', `${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`],
|
|
120
|
+
['观察积压', `${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`],
|
|
121
|
+
['冲突', s.conflicts.length === 0 ? '无' : s.conflicts.map((c) => cell(c)).join('、')],
|
|
122
|
+
['损坏文件', s.broken.length === 0 ? '无' : s.broken.map((b) => cell(b)).join('、')],
|
|
123
|
+
['git', s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'],
|
|
124
|
+
['注入', cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'],
|
|
125
|
+
['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
|
|
126
|
+
['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
|
|
127
|
+
['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
|
|
129
128
|
]
|
|
130
129
|
// Last lane outcome (distill-state summary) — what "checkable via
|
|
131
130
|
// /topics status" promises; absent until the first run of this bundle.
|
|
@@ -136,13 +135,19 @@ async function renderStatus(service: TopicsService): Promise<string> {
|
|
|
136
135
|
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
137
136
|
: `失败(${String(lastRun.reason ?? 'unknown')})`
|
|
138
137
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
|
|
139
|
-
|
|
138
|
+
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
|
|
140
139
|
}
|
|
141
140
|
if (service.sync !== undefined) {
|
|
142
|
-
|
|
143
|
-
if (service.sync.lastError !== '')
|
|
141
|
+
rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))])
|
|
142
|
+
if (service.sync.lastError !== '') rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')])
|
|
144
143
|
}
|
|
145
|
-
return
|
|
144
|
+
return [
|
|
145
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
146
|
+
'',
|
|
147
|
+
'| 字段 | 值 |',
|
|
148
|
+
'| --- | --- |',
|
|
149
|
+
...rows.map(([k, v]) => `| ${k} | ${v} |`),
|
|
150
|
+
].join('\n')
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
/**
|
|
@@ -178,9 +183,13 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
178
183
|
if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
|
|
179
184
|
const lines = [
|
|
180
185
|
`注入统计(最近 ${records.length} 轮):`,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
'',
|
|
187
|
+
'| 指标 | 值 |',
|
|
188
|
+
'| --- | --- |',
|
|
189
|
+
`| hit rate | ${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入) |`,
|
|
190
|
+
`| 零命中轮 | ${stats.zeroHitRounds} |`,
|
|
191
|
+
`| 平均命中 | ${stats.avgHitsPerRound} 条/轮 |`,
|
|
192
|
+
`| 平均预算占用 | ${stats.avgBudgetUtilization} tok |`,
|
|
184
193
|
]
|
|
185
194
|
// v4 lane split — how much of the injection traffic each lane carries.
|
|
186
195
|
const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length
|
|
@@ -193,27 +202,35 @@ async function renderStats(service: TopicsService): Promise<string> {
|
|
|
193
202
|
.sort((a, b) => a - b)
|
|
194
203
|
medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`
|
|
195
204
|
}
|
|
196
|
-
lines.push(
|
|
205
|
+
lines.push(`| 慢道参与轮 | ${slowRounds}(快 ${records.length - slowRounds} / 慢或混合 ${slowRounds}${medianLag}) |`)
|
|
197
206
|
}
|
|
198
207
|
// Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
|
|
208
|
+
// Echoed hits ride the retrieval but never became pointers — excluded here.
|
|
199
209
|
const opens = await service.store.readOpenRecords()
|
|
200
210
|
if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
|
|
201
211
|
const entries = records.reduce(
|
|
202
|
-
(acc, r) =>
|
|
212
|
+
(acc, r) =>
|
|
213
|
+
acc +
|
|
214
|
+
r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug) && !(r.echoed ?? []).includes(h.slug)).length +
|
|
215
|
+
(r.slow?.length ?? 0),
|
|
203
216
|
0,
|
|
204
217
|
)
|
|
205
218
|
const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
|
|
206
|
-
lines.push(
|
|
219
|
+
lines.push(`| 指针打开率 | ${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针) |`)
|
|
220
|
+
}
|
|
221
|
+
const echoedCount = records.reduce((acc, r) => acc + (r.echoed?.length ?? 0), 0)
|
|
222
|
+
if (echoedCount > 0) {
|
|
223
|
+
lines.push(`| 回声抑制 | ${echoedCount} 次(本会话蒸馏出的 topic 不回注) |`)
|
|
207
224
|
}
|
|
208
225
|
if (stats.topTopics.length > 0) {
|
|
209
|
-
lines.push('
|
|
210
|
-
for (const t of stats.topTopics.slice(0, 5)) lines.push(
|
|
226
|
+
lines.push('', 'Top-N 被注入 Topic:', '', '| Slug | 注入次数 |', '| --- | --- |')
|
|
227
|
+
for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
|
|
211
228
|
}
|
|
212
229
|
if (stats.nearMissHistogram.length > 0) {
|
|
213
|
-
lines.push('
|
|
214
|
-
for (const b of stats.nearMissHistogram) lines.push(
|
|
230
|
+
lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |')
|
|
231
|
+
for (const b of stats.nearMissHistogram) lines.push(`| ${cell(b.bucket)} | ${b.count} |`)
|
|
215
232
|
const hint = tuningHint(stats, service.cfg.matchThreshold)
|
|
216
|
-
if (hint !== undefined) lines.push(
|
|
233
|
+
if (hint !== undefined) lines.push('', `💡 ${hint}`)
|
|
217
234
|
}
|
|
218
235
|
return lines.join('\n')
|
|
219
236
|
}
|
|
@@ -229,16 +246,40 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
|
|
|
229
246
|
return undefined
|
|
230
247
|
}
|
|
231
248
|
|
|
249
|
+
/** /topics list caps the table at this many newest topics. */
|
|
250
|
+
const LIST_LIMIT = 100
|
|
251
|
+
|
|
232
252
|
async function renderList(service: TopicsService): Promise<string> {
|
|
233
253
|
const metas = await service.store.listTopics()
|
|
234
254
|
if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
255
|
+
// Newest first, capped: with a large bundle the table is for scanning
|
|
256
|
+
// recent work, not an exhaustive roster — /topics show <slug> reaches any
|
|
257
|
+
// topic the cap hides. (store.ts re-stamps generated.at on every topic
|
|
258
|
+
// write, so it IS the updated-at time; the slug tiebreak keeps same-stamp
|
|
259
|
+
// rows stable.)
|
|
260
|
+
metas.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt) || a.slug.localeCompare(b.slug))
|
|
261
|
+
const shown = metas.slice(0, LIST_LIMIT)
|
|
262
|
+
const lines = [
|
|
263
|
+
`共 ${metas.length} 个 Topic(按最近更新排序):`,
|
|
264
|
+
'',
|
|
265
|
+
'| # | Slug | 标题 | 状态 | 标签 | 更新 |',
|
|
266
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
267
|
+
]
|
|
268
|
+
shown.forEach((m, i) => {
|
|
269
|
+
const tags = m.tags.length > 0 ? m.tags.map((t) => `#${cell(t)}`).join(' ') : '—'
|
|
270
|
+
lines.push(`| ${i + 1} | \`${cell(m.slug)}\` | ${cell(m.title)} | ${m.status} | ${tags} | ${cell(m.generatedAt.slice(0, 10))} |`)
|
|
271
|
+
})
|
|
272
|
+
if (metas.length > shown.length) {
|
|
273
|
+
lines.push('', `… 其余 ${metas.length - shown.length} 个更早的 Topic 未显示(/topics show <slug> 直达)。`)
|
|
238
274
|
}
|
|
239
275
|
return lines.join('\n')
|
|
240
276
|
}
|
|
241
277
|
|
|
278
|
+
/** One markdown table cell: pipes escaped, whitespace flattened. */
|
|
279
|
+
function cell(text: string): string {
|
|
280
|
+
return text.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ')
|
|
281
|
+
}
|
|
282
|
+
|
|
242
283
|
async function renderShow(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
|
|
243
284
|
if (slug === undefined || slug === '') return fail('用法:/topics show <slug>')
|
|
244
285
|
const doc = await service.store.readTopic(slug)
|
|
@@ -261,10 +302,15 @@ async function renderHistory(service: TopicsService, slug: string | undefined):
|
|
|
261
302
|
if (slug === undefined || slug === '') return fail('用法:/topics history <slug>')
|
|
262
303
|
const { entries } = await service.history(slug, 30)
|
|
263
304
|
if (entries.length === 0) return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`)
|
|
264
|
-
const lines = [
|
|
305
|
+
const lines = [
|
|
306
|
+
`${slug} 的变更史(${entries.length} 条):`,
|
|
307
|
+
'',
|
|
308
|
+
'| Hash | 时间 | 变更 | 当时的结论 |',
|
|
309
|
+
'| --- | --- | --- | --- |',
|
|
310
|
+
]
|
|
265
311
|
for (const e of entries) {
|
|
266
|
-
|
|
267
|
-
|
|
312
|
+
const conclusion = e.conclusion !== undefined && e.conclusion !== '' ? cell(e.conclusion) : '—'
|
|
313
|
+
lines.push(`| \`${cell(e.hash)}\` | ${cell(e.date.slice(0, 19).replace('T', ' '))} | ${cell(e.message)} | ${conclusion} |`)
|
|
268
314
|
}
|
|
269
315
|
return ok(lines.join('\n'))
|
|
270
316
|
}
|
|
@@ -321,9 +367,14 @@ async function doSync(service: TopicsService, direction: string | undefined): Pr
|
|
|
321
367
|
}
|
|
322
368
|
|
|
323
369
|
function renderConfig(cfg: TopicsConfigValue): string {
|
|
324
|
-
const lines = [
|
|
370
|
+
const lines = [
|
|
371
|
+
'当前配置:',
|
|
372
|
+
'',
|
|
373
|
+
'| 配置项 | 值 |',
|
|
374
|
+
'| --- | --- |',
|
|
375
|
+
]
|
|
325
376
|
for (const key of CONFIG_KEYS) {
|
|
326
|
-
lines.push(
|
|
377
|
+
lines.push(`| ${displayKey(key)} | ${cell(String(cfg[key]))} |`)
|
|
327
378
|
}
|
|
328
379
|
return lines.join('\n')
|
|
329
380
|
}
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,11 @@ export const TopicsConfig = z.object({
|
|
|
14
14
|
autoInject: z.boolean().default(true),
|
|
15
15
|
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
16
16
|
injectDedup: z.boolean().default(true),
|
|
17
|
+
/** Skip re-injecting topics distilled from the CURRENT session's own turns
|
|
18
|
+
* (蒸馏回声): the conversation already carries that knowledge, so a
|
|
19
|
+
* pointer is a stale echo at best (2026-09 audit: 3 of 6 useless rounds).
|
|
20
|
+
* Provenance rides the observations log (sessionId → distilledInto). */
|
|
21
|
+
suppressEcho: z.boolean().default(true),
|
|
17
22
|
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
18
23
|
topK: z.number().default(4),
|
|
19
24
|
/** Per-topic digest budget in tokens. */
|
|
@@ -61,6 +66,7 @@ export type TopicsConfigValue = {
|
|
|
61
66
|
repo: string
|
|
62
67
|
autoInject: boolean
|
|
63
68
|
injectDedup: boolean
|
|
69
|
+
suppressEcho: boolean
|
|
64
70
|
topK: number
|
|
65
71
|
perTopicBudget: number
|
|
66
72
|
totalBudget: number
|
|
@@ -86,6 +92,7 @@ export const CONFIG_KEYS = [
|
|
|
86
92
|
'repo',
|
|
87
93
|
'autoInject',
|
|
88
94
|
'injectDedup',
|
|
95
|
+
'suppressEcho',
|
|
89
96
|
'topK',
|
|
90
97
|
'perTopicBudget',
|
|
91
98
|
'totalBudget',
|
|
@@ -125,6 +132,7 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
125
132
|
}
|
|
126
133
|
case 'autoInject':
|
|
127
134
|
case 'injectDedup':
|
|
135
|
+
case 'suppressEcho':
|
|
128
136
|
case 'autoObserve':
|
|
129
137
|
case 'distillOnSessionEnd':
|
|
130
138
|
case 'includeSubagents': {
|
package/src/ilog.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface InjectionRecord {
|
|
|
41
41
|
dropped?: { slug: string; reason: string }[]
|
|
42
42
|
/** Slugs blocked this round by session-level injection dedup (never assembled). */
|
|
43
43
|
deduped?: string[]
|
|
44
|
+
/** Slugs blocked this round as 蒸馏回声 (distilled from this session's own turns). */
|
|
45
|
+
echoed?: string[]
|
|
44
46
|
usedTokens?: number
|
|
45
47
|
// ---- v4 lane field family (§4.3) — absent on pure fast-lane rounds ----
|
|
46
48
|
/** fast = lexical pointers only; slow = async picks only; mixed = both. */
|
|
@@ -88,12 +90,14 @@ export function aggregateStats(records: readonly InjectionRecord[]): AggregateSt
|
|
|
88
90
|
budgetSamples += 1
|
|
89
91
|
budgetSum += r.usedTokens
|
|
90
92
|
}
|
|
91
|
-
// topTopics feeds 「Top-N 被注入 Topic」— deduped hits were NOT
|
|
92
|
-
// so they must not inflate the per-slug injection counts.
|
|
93
|
-
// metrics above (hits/rounds, zero-hit rounds) keep
|
|
93
|
+
// topTopics feeds 「Top-N 被注入 Topic」— deduped/echoed hits were NOT
|
|
94
|
+
// injected, so they must not inflate the per-slug injection counts.
|
|
95
|
+
// Retrieval-shape metrics above (hits/rounds, zero-hit rounds) keep
|
|
96
|
+
// counting raw hits.
|
|
94
97
|
const deduped = r.deduped === undefined ? undefined : new Set(r.deduped)
|
|
98
|
+
const echoed = r.echoed === undefined ? undefined : new Set(r.echoed)
|
|
95
99
|
for (const h of r.hits) {
|
|
96
|
-
if (deduped?.has(h.slug)) continue
|
|
100
|
+
if (deduped?.has(h.slug) || echoed?.has(h.slug)) continue
|
|
97
101
|
topicCounts.set(h.slug, (topicCounts.get(h.slug) ?? 0) + 1)
|
|
98
102
|
}
|
|
99
103
|
for (const nm of r.nearMisses) {
|
package/src/index.ts
CHANGED
|
@@ -244,6 +244,7 @@ export function apply(ctx: Context): void {
|
|
|
244
244
|
if (state === undefined) return
|
|
245
245
|
const cfg = cfgNow()
|
|
246
246
|
const seen = cfg.injectDedup ? injectedBySession.get(sessionId) : undefined
|
|
247
|
+
const echo = cfg.suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
247
248
|
// Slow-lane consumption point (v4 生命周期表): the pending produced at
|
|
248
249
|
// the previous turn/end rides THIS splice — consumed or expired, the
|
|
249
250
|
// slot is gone either way (消费即清; expiry surfaces in record.why).
|
|
@@ -256,7 +257,7 @@ export function apply(ctx: Context): void {
|
|
|
256
257
|
else slowExpired = consumed.expired
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
|
-
const r = service.retrieveSync(query, sessionId, seen === undefined ? undefined : { exclude: seen }, slow, slowExpired)
|
|
260
|
+
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
|
|
260
261
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
261
262
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
262
263
|
// injectable).
|
|
@@ -301,6 +302,18 @@ export function apply(ctx: Context): void {
|
|
|
301
302
|
slowLane.dispatch(sessionId, {
|
|
302
303
|
ring: observer.recentTurns(sessionId),
|
|
303
304
|
turnId: observer.turnCountOf(sessionId),
|
|
305
|
+
// The lane must not spend its rerank on slugs this session already
|
|
306
|
+
// carries (injected earlier / distilled from its own turns) — such a
|
|
307
|
+
// pick is silently swallowed at consumption and the pending dies.
|
|
308
|
+
exclude: (() => {
|
|
309
|
+
const seen = injectedBySession.get(sessionId)
|
|
310
|
+
const echo = cfgNow().suppressEcho ? service.echoSlugsSync(sessionId) : undefined
|
|
311
|
+
if (seen === undefined || seen.size === 0) return echo
|
|
312
|
+
if (echo === undefined || echo.size === 0) return seen
|
|
313
|
+
const merged = new Set(seen)
|
|
314
|
+
for (const slug of echo) merged.add(slug)
|
|
315
|
+
return merged
|
|
316
|
+
})(),
|
|
304
317
|
})
|
|
305
318
|
} catch {
|
|
306
319
|
// contained — the lane must never break the event handler
|
|
@@ -646,6 +659,7 @@ const DEFAULTS: TopicsConfigValue = {
|
|
|
646
659
|
repo: '',
|
|
647
660
|
autoInject: true,
|
|
648
661
|
injectDedup: true,
|
|
662
|
+
suppressEcho: true,
|
|
649
663
|
topK: 4,
|
|
650
664
|
perTopicBudget: 300,
|
|
651
665
|
totalBudget: 1500,
|
package/src/quality.ts
CHANGED
|
@@ -36,6 +36,8 @@ export const SAMPLED_EVERY = 3
|
|
|
36
36
|
const RING_CHARS = 6000
|
|
37
37
|
/** Candidate band size fed to the rerank. */
|
|
38
38
|
const CANDIDATE_LIMIT = 6
|
|
39
|
+
/** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
|
|
40
|
+
const CANDIDATE_OVERFETCH = 6
|
|
39
41
|
|
|
40
42
|
export const QUERY_BUILD_PROMPT = [
|
|
41
43
|
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
@@ -70,6 +72,14 @@ export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | '
|
|
|
70
72
|
export interface DispatchInput {
|
|
71
73
|
ring: readonly RingEntry[]
|
|
72
74
|
turnId: number
|
|
75
|
+
/**
|
|
76
|
+
* Slugs the session already carries — injected earlier this session or
|
|
77
|
+
* distilled from its own turns (echo). The candidate band drops them
|
|
78
|
+
* BEFORE the rerank: a pick the fast-lane dedup would silently swallow at
|
|
79
|
+
* consumption is a wasted aux call and a dead pending (2026-09 audit: 2 of
|
|
80
|
+
* the first 3 slow-lane outcomes died exactly this way).
|
|
81
|
+
*/
|
|
82
|
+
exclude?: ReadonlySet<string>
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
|
|
@@ -233,13 +243,16 @@ export class SlowLane {
|
|
|
233
243
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
234
244
|
const band = searchTopics(query, roster, {
|
|
235
245
|
threshold: this.service.cfg.matchThreshold,
|
|
236
|
-
topK: CANDIDATE_LIMIT,
|
|
246
|
+
topK: CANDIDATE_LIMIT + CANDIDATE_OVERFETCH,
|
|
237
247
|
tagBoost: this.service.cfg.tagBoost,
|
|
238
248
|
graphDepth: 0,
|
|
239
249
|
recencyWindowDays: this.service.cfg.recencyWindowDays,
|
|
240
250
|
structuralGate: false,
|
|
241
251
|
})
|
|
242
|
-
const
|
|
252
|
+
const exclude = input.exclude
|
|
253
|
+
const candidates = [...band.hits, ...band.nearMisses]
|
|
254
|
+
.filter((c) => !exclude?.has(c.slug))
|
|
255
|
+
.slice(0, CANDIDATE_LIMIT)
|
|
243
256
|
if (candidates.length === 0) return
|
|
244
257
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
245
258
|
const payload = candidates.flatMap((c) => {
|