@aiwayds/dsh-topics-memory 0.6.0 → 0.8.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/service.js CHANGED
@@ -9,8 +9,8 @@ import { readFile, stat } from 'node:fs/promises';
9
9
  import { readdirSync, readFileSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import * as okf from "./okf.js";
12
- import { searchTopics, tokenize } from "./retrieval.js";
13
- import { assembleInjection } from "./digest.js";
12
+ import { searchTopics, scoreTopic, passesGate, tokenize } from "./retrieval.js";
13
+ import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
14
14
  import { aggregateStats, querySample } from "./ilog.js";
15
15
  import { fileHistory, fileAtRev } from "./git.js";
16
16
  export class TopicsService {
@@ -70,6 +70,7 @@ export class TopicsService {
70
70
  description: doc.fm.description,
71
71
  status: doc.fm.status,
72
72
  tags: doc.fm.tags,
73
+ triggers: doc.fm.triggers,
73
74
  depends: doc.fm.depends,
74
75
  links: okf.bodyLinkSlugs(doc.body),
75
76
  generatedAt: doc.fm.generated.at,
@@ -116,10 +117,12 @@ export class TopicsService {
116
117
  if (doc !== undefined)
117
118
  entries.push({ slug: hit.slug, doc, hit });
118
119
  }
119
- const injection = assembleInjection(entries, {
120
- perTopicBudget: cfg.perTopicBudget,
121
- totalBudget: cfg.totalBudget,
122
- });
120
+ const injection = cfg.injectMode !== 'digest'
121
+ ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) })
122
+ : assembleInjection(entries, {
123
+ perTopicBudget: cfg.perTopicBudget,
124
+ totalBudget: cfg.totalBudget,
125
+ });
123
126
  const record = {
124
127
  at: new Date().toISOString(),
125
128
  queryTokenCount: tokenize(query).length,
@@ -146,18 +149,52 @@ export class TopicsService {
146
149
  * would always lose the race and inject nothing. Reads files with sync fs
147
150
  * behind the mtime cache; the log record is written fire-and-forget.
148
151
  *
149
- * `dedup.exclude` (session-level injection dedup) filters hits AFTER
150
- * retrieval and BEFORE assembly: excluded slugs are reported back as
151
- * `deduped` (and logged as `record.deduped`), never packed. `included`
152
- * mirrors what actually entered the context — only these may be marked
153
- * as injected by the caller; budget-dropped slugs stay injectable later.
152
+ * `dedup.exclude` (session-level injection dedup) filters hits AND slow
153
+ * picks before assembly; excluded slugs are reported back as `deduped`
154
+ * (and logged as `record.deduped`), never packed. `included` mirrors the
155
+ * fast pointers that entered the context and `slowIncluded` the slow ones
156
+ * — only these may be marked as injected by the caller; budget-dropped
157
+ * slugs stay injectable later.
158
+ *
159
+ * `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
160
+ * packed after the fast pointers under the same budget, take log-only
161
+ * shadow re-gate verdicts against the CURRENT query (B3: record, never
162
+ * block), and fill the record's lane field family.
154
163
  */
155
- retrieveSync(query, sessionId, dedup) {
164
+ retrieveSync(query, sessionId, dedup, slow, slowExpired) {
156
165
  const cfg = this.cfg;
157
166
  const roster = this.rosterSync();
158
167
  const empty = { hits: [], nearMisses: [], rosterSize: roster.length };
159
168
  if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
160
- return { text: '', outcome: empty, deduped: [], included: [] };
169
+ // A consumed/expired pending must always leave an ilog trace (ADR 0014:
170
+ // 过期/消费都写 ilog) — even when the fast path early-returns here
171
+ // because the roster vanished between production and consumption.
172
+ if (cfg.autoInject && query.trim() !== '' && (slow !== undefined || slowExpired !== undefined)) {
173
+ const trace = {
174
+ at: new Date().toISOString(),
175
+ queryTokenCount: tokenize(query).length,
176
+ querySample: querySample(query),
177
+ rosterSize: roster.length,
178
+ hits: [],
179
+ nearMisses: [],
180
+ injected: false,
181
+ why: slowExpired !== undefined ? `slow-expired-${slowExpired}` : 'slow-no-roster',
182
+ lane: 'slow',
183
+ };
184
+ if (sessionId !== undefined)
185
+ trace.sessionId = sessionId;
186
+ if (slowExpired !== undefined)
187
+ trace.slowExpired = slowExpired;
188
+ if (slow !== undefined) {
189
+ trace.computedAt = slow.computedAt;
190
+ trace.queryBuild = slow.queryBuild;
191
+ trace.slowModel = slow.model;
192
+ trace.slowMs = slow.ms;
193
+ trace.slow = [];
194
+ }
195
+ void this.store.appendInjectionRecord(trace).catch(() => undefined);
196
+ }
197
+ return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] };
161
198
  }
162
199
  const conflicts = this.store.getConflictsSync();
163
200
  const outcome = searchTopics(query, roster, {
@@ -170,25 +207,81 @@ export class TopicsService {
170
207
  });
171
208
  const exclude = dedup?.exclude;
172
209
  const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug);
210
+ const bySlug = new Map(roster.map((r) => [r.slug, r]));
173
211
  const entries = [];
212
+ const unreadable = [];
174
213
  for (const hit of outcome.hits) {
175
214
  if (exclude?.has(hit.slug))
176
215
  continue;
177
216
  const doc = this.readDocSync(hit.slug);
178
- if (doc !== undefined)
179
- entries.push({ slug: hit.slug, doc, hit });
217
+ if (doc === undefined) {
218
+ // The hit has no readable doc — without this accounting it used to
219
+ // vanish from `included` AND `dropped` (v4 design §2 疑点).
220
+ unreadable.push(hit.slug);
221
+ continue;
222
+ }
223
+ entries.push({ slug: hit.slug, doc, hit });
224
+ }
225
+ // ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
226
+ const slowPointers = [];
227
+ const slowDelivered = [];
228
+ const shadow = [];
229
+ let slowIncluded = [];
230
+ const consumedAt = new Date().toISOString();
231
+ if (slow !== undefined) {
232
+ const queryTokens = new Set(tokenize(query));
233
+ for (const item of slow.items) {
234
+ if (exclude?.has(item.slug)) {
235
+ if (!deduped.includes(item.slug))
236
+ deduped.push(item.slug);
237
+ continue;
238
+ }
239
+ const meta = bySlug.get(item.slug);
240
+ if (meta === undefined)
241
+ continue; // vanished between production and consumption
242
+ const verdict = scoreTopic(queryTokens, meta, {
243
+ threshold: cfg.matchThreshold,
244
+ topK: cfg.topK,
245
+ tagBoost: cfg.tagBoost,
246
+ graphDepth: cfg.graphDepth,
247
+ recencyWindowDays: cfg.recencyWindowDays,
248
+ conflicts,
249
+ structuralGate: true,
250
+ });
251
+ shadow.push({
252
+ slug: item.slug,
253
+ pass: passesGate(verdict),
254
+ why: verdict.strong ? `strong:${verdict.reasons[0] ?? 'field'}` : `body-hits:${verdict.bodyHits}`,
255
+ });
256
+ slowPointers.push({
257
+ slug: item.slug,
258
+ title: meta.title,
259
+ status: meta.status,
260
+ description: meta.description,
261
+ why: item.why,
262
+ });
263
+ }
264
+ }
265
+ const pointerMode = cfg.injectMode !== 'digest';
266
+ // Pointer budget: per-entry fixed at the design's 80, total clamped to the
267
+ // fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
268
+ const injection = pointerMode
269
+ ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
270
+ : assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers);
271
+ slowIncluded = injection.slowIncluded ?? [];
272
+ for (const pointer of slowPointers) {
273
+ if (slowIncluded.includes(pointer.slug))
274
+ slowDelivered.push({ slug: pointer.slug, why: pointer.why });
180
275
  }
181
- const injection = assembleInjection(entries, {
182
- perTopicBudget: cfg.perTopicBudget,
183
- totalBudget: cfg.totalBudget,
184
- });
185
276
  const record = {
186
277
  at: new Date().toISOString(),
187
278
  queryTokenCount: tokenize(query).length,
188
279
  querySample: querySample(query),
189
280
  rosterSize: outcome.rosterSize,
190
- hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
191
- nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
281
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
282
+ // Reasons ride along: the gate-blocked marker is the replay evidence
283
+ // the P3 threshold decision needs (ADR 0014).
284
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons })),
192
285
  injected: injection.text !== '',
193
286
  usedTokens: injection.usedTokens,
194
287
  };
@@ -196,14 +289,73 @@ export class TopicsService {
196
289
  record.sessionId = sessionId;
197
290
  if (deduped.length > 0)
198
291
  record.deduped = deduped;
199
- if (injection.text === '' && outcome.hits.length > 0) {
292
+ if (unreadable.length > 0) {
293
+ record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))];
294
+ }
295
+ if (slow !== undefined) {
296
+ // Any round whose pending was consumed is a slow-participating round —
297
+ // lane never stays undefined when the lane family is present (a
298
+ // delivered pick with fast pointers is 'mixed'; a consumed-but-empty
299
+ // round beside fast pointers stays 'fast'; everything else 'slow').
300
+ record.lane = slowDelivered.length > 0 ? (injection.included.length > 0 ? 'mixed' : 'slow') : injection.included.length > 0 ? 'fast' : 'slow';
301
+ record.computedAt = slow.computedAt;
302
+ record.consumedAt = consumedAt;
303
+ if (shadow.length > 0)
304
+ record.shadowVerdict = shadow;
305
+ record.queryBuild = slow.queryBuild;
306
+ record.slowModel = slow.model;
307
+ record.slowMs = slow.ms;
308
+ record.slow = slowDelivered;
309
+ }
310
+ if (slowExpired !== undefined) {
311
+ // Expiry is recorded INDEPENDENT of whether the fast lane injected —
312
+ // the 赶上率 denominator must not lose rounds to a coincidental hit.
313
+ record.slowExpired = slowExpired;
314
+ if (record.lane === undefined)
315
+ record.lane = injection.text !== '' ? 'fast' : 'slow';
316
+ }
317
+ if (injection.text === '' && (outcome.hits.length > 0 || slowPointers.length > 0)) {
200
318
  // All hits deduped is a different story than a budget squeeze — say so.
201
- record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped';
319
+ record.why = deduped.length === outcome.hits.length && outcome.hits.length > 0 ? 'dedup' : 'below-budget-or-dropped';
320
+ }
321
+ else if (injection.text === '' && slowExpired !== undefined) {
322
+ // The pending died at its hard bounds (TTL / turn-lag) without ever
323
+ // being delivered — the denominator the 赶上率 needs (v4 B4).
324
+ record.why = `slow-expired-${slowExpired}`;
202
325
  }
326
+ if (slowExpired !== undefined && injection.text === '')
327
+ record.lane = 'slow';
203
328
  if (injection.dropped.length > 0)
204
- record.dropped = injection.dropped;
329
+ record.dropped = [...(record.dropped ?? []), ...injection.dropped];
205
330
  void this.store.appendInjectionRecord(record).catch(() => undefined);
206
- return { text: injection.text, outcome, deduped, included: injection.included };
331
+ return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded };
332
+ }
333
+ /**
334
+ * topic_open tool path (v4 §4.1): full conclusion / open questions /
335
+ * recommendations plus the staleness notice timestamp (fm.generated.at —
336
+ * the store stamps it on every write, so it IS the last-updated instant).
337
+ * Logs an open record for the pointer-open-rate stat.
338
+ */
339
+ async openTopic(slug, sessionId) {
340
+ const doc = await this.store.readTopic(slug).catch(() => undefined);
341
+ if (doc === undefined)
342
+ return { found: false, slug };
343
+ // Awaited (tool path, not hot): a settled write makes the pointer-open
344
+ // stat reliable; a failed log write must not fail the tool.
345
+ await this.store
346
+ .appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
347
+ .catch(() => undefined);
348
+ return {
349
+ found: true,
350
+ slug,
351
+ title: doc.fm.title,
352
+ status: doc.fm.status,
353
+ updatedAt: doc.fm.generated.at,
354
+ description: doc.fm.description,
355
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
356
+ openQuestions: doc.fm.open_questions,
357
+ recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
358
+ };
207
359
  }
208
360
  /** Sync roster read (same mtime cache as roster()). */
209
361
  rosterSync() {
@@ -248,6 +400,7 @@ export class TopicsService {
248
400
  description: doc.fm.description,
249
401
  status: doc.fm.status,
250
402
  tags: doc.fm.tags,
403
+ triggers: doc.fm.triggers,
251
404
  depends: doc.fm.depends,
252
405
  links: okf.bodyLinkSlugs(doc.body),
253
406
  generatedAt: doc.fm.generated.at,
@@ -301,6 +454,11 @@ export class TopicsService {
301
454
  };
302
455
  if (input.description !== undefined && input.description !== '')
303
456
  fm.description = input.description;
457
+ // Triggers pass through on create; on update the caller's value wins, else
458
+ // the stored list survives (same preserve-on-update semantics as tags).
459
+ const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? []);
460
+ if (triggers.length > 0)
461
+ fm.triggers = triggers;
304
462
  if (existing !== undefined && existing.fm.verified !== undefined)
305
463
  fm.verified = existing.fm.verified;
306
464
  const result = await this.store.saveTopic({ slug, doc: { fm, body } }, { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined });
@@ -319,6 +477,9 @@ export class TopicsService {
319
477
  tagBoost: cfg.tagBoost,
320
478
  graphDepth: cfg.graphDepth,
321
479
  recencyWindowDays: cfg.recencyWindowDays,
480
+ // Explicit tool: the model asked for recall, so the injection-only
481
+ // structural gate must not hide results.
482
+ structuralGate: false,
322
483
  conflicts: await this.store.getConflicts(),
323
484
  });
324
485
  }
@@ -373,3 +534,15 @@ function dedupeLower(tags) {
373
534
  }
374
535
  return out;
375
536
  }
537
+ function sanitizeTriggers(triggers) {
538
+ const out = [];
539
+ for (const t of triggers) {
540
+ if (typeof t !== 'string')
541
+ continue;
542
+ const key = t.trim();
543
+ if (key === '')
544
+ continue;
545
+ out.push(key);
546
+ }
547
+ return out;
548
+ }
package/lib/store.d.ts CHANGED
@@ -135,6 +135,19 @@ export declare class BundleStore {
135
135
  appendInjectionRecord(record: unknown): Promise<void>;
136
136
  private compactInjectionsIfNeeded;
137
137
  readInjectionRecords(limit?: number): Promise<InjectionRecord[]>;
138
+ private opensPath;
139
+ appendOpenRecord(record: {
140
+ slug: string;
141
+ at: string;
142
+ sessionId?: string;
143
+ }): Promise<void>;
144
+ private compactOpensIfNeeded;
145
+ /** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
146
+ readOpenRecords(limit?: number): Promise<{
147
+ slug: string;
148
+ at: string;
149
+ sessionId?: string;
150
+ }[]>;
138
151
  getConflicts(): Promise<Set<string>>;
139
152
  /** Sync variant for the injection hot path. */
140
153
  getConflictsSync(): Set<string>;
package/lib/store.js CHANGED
@@ -425,6 +425,60 @@ export class BundleStore {
425
425
  return out.slice(-limit);
426
426
  }
427
427
  // ------------------------------------------------------------------
428
+ // Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
429
+ // 「指针打开率」 stat. Features only (slug/time), never conversation text.
430
+ // ------------------------------------------------------------------
431
+ opensPath() {
432
+ return join(this.metaDir(), 'opens.jsonl');
433
+ }
434
+ async appendOpenRecord(record) {
435
+ await mkdir(this.metaDir(), { recursive: true });
436
+ await appendFile(this.opensPath(), `${JSON.stringify(record)}\n`, 'utf8');
437
+ await this.compactOpensIfNeeded();
438
+ }
439
+ async compactOpensIfNeeded() {
440
+ const file = this.opensPath();
441
+ let size = 0;
442
+ try {
443
+ size = (await readFile(file, 'utf8')).length;
444
+ }
445
+ catch {
446
+ return;
447
+ }
448
+ if (size <= 512 * 1024)
449
+ return;
450
+ const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '');
451
+ await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''));
452
+ }
453
+ /** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
454
+ async readOpenRecords(limit = 2000) {
455
+ let raw;
456
+ try {
457
+ raw = await readFile(this.opensPath(), 'utf8');
458
+ }
459
+ catch {
460
+ return [];
461
+ }
462
+ const out = [];
463
+ for (const line of raw.split('\n')) {
464
+ if (line.trim() === '')
465
+ continue;
466
+ try {
467
+ const parsed = JSON.parse(line);
468
+ if (typeof parsed.slug === 'string' && typeof parsed.at === 'string') {
469
+ const rec = { slug: parsed.slug, at: parsed.at };
470
+ if (typeof parsed.sessionId === 'string')
471
+ rec.sessionId = parsed.sessionId;
472
+ out.push(rec);
473
+ }
474
+ }
475
+ catch {
476
+ // Torn tail line tolerated.
477
+ }
478
+ }
479
+ return out.slice(-limit);
480
+ }
481
+ // ------------------------------------------------------------------
428
482
  // Conflicted topics (ADR 0003) — retrieval demotes these
429
483
  // ------------------------------------------------------------------
430
484
  async getConflicts() {
package/lib/tools.js CHANGED
@@ -22,6 +22,7 @@ export function buildTopicTools(service) {
22
22
  conclusion: { type: 'string', required: true, description: 'The current best conclusion, self-contained markdown prose' },
23
23
  description: { type: 'string', description: 'One-line summary for indexes and snippets' },
24
24
  tags: { ...STRING_ARRAY, description: 'Cross-cutting tags (project, domain, component); lowercased on save' },
25
+ triggers: { ...STRING_ARRAY, description: 'Short recall phrases that should bring this topic back (specific nouns/terms, no generic words)' },
25
26
  depends: { ...STRING_ARRAY, description: 'Slugs of prerequisite topics (topics this one builds on)' },
26
27
  open_questions: { ...STRING_ARRAY, description: 'Unresolved questions this topic still carries' },
27
28
  impact: { ...STRING_ARRAY, description: 'What this conclusion affects: topics, projects, decisions' },
@@ -53,6 +54,7 @@ export function buildTopicTools(service) {
53
54
  conclusion: args.conclusion,
54
55
  description: args.description,
55
56
  tags: args.tags,
57
+ triggers: args.triggers,
56
58
  depends: args.depends,
57
59
  openQuestions: args.open_questions,
58
60
  impact: args.impact,
@@ -64,6 +66,52 @@ export function buildTopicTools(service) {
64
66
  return { slug: result.slug, path: result.path, created: result.created, committed: result.committed };
65
67
  },
66
68
  });
69
+ const topicOpen = defineTool({
70
+ name: 'topic_open',
71
+ description: 'Open one topic from the long-term memory in full: conclusion, open questions, recommendations, ' +
72
+ 'plus a staleness notice. Use when a <topic-memory> pointer (or a search hit) is actually relevant — ' +
73
+ 'pointers are one-line hints; this fetches the real content.',
74
+ parameters: {
75
+ slug: { type: 'string', required: true, description: 'Topic slug (from a pointer or topic_search)' },
76
+ },
77
+ output: {
78
+ schema: {
79
+ type: 'object',
80
+ additionalProperties: false,
81
+ properties: {
82
+ found: { type: 'boolean', required: true },
83
+ slug: { type: 'string', required: true },
84
+ title: { type: 'string' },
85
+ status: { type: 'string' },
86
+ updatedAt: { type: 'string' },
87
+ description: { type: 'string' },
88
+ conclusion: { type: 'string' },
89
+ openQuestions: { type: 'array', items: { type: 'string' } },
90
+ recommendations: { type: 'string' },
91
+ },
92
+ },
93
+ render: (_args, value) => [
94
+ {
95
+ type: 'text',
96
+ text: !value.found
97
+ ? `Topic \`${value.slug}\` 不存在(可能已合并或改名;用 topic_search 找)。`
98
+ : [
99
+ `快照于 ${value.updatedAt},代码事实以源码为准。`,
100
+ `### ${value.title} [${value.status}] (topics:${value.slug})`,
101
+ value.description ?? '',
102
+ value.conclusion !== '' ? `# Conclusion\n\n${value.conclusion}` : '',
103
+ value.openQuestions !== undefined && value.openQuestions.length > 0 ? `待决: ${value.openQuestions.join(';')}` : '',
104
+ value.recommendations !== '' ? `# Recommendations\n\n${value.recommendations}` : '',
105
+ ]
106
+ .filter((l) => l !== '')
107
+ .join('\n\n'),
108
+ },
109
+ ],
110
+ },
111
+ async execute(args) {
112
+ return await service.openTopic(args.slug);
113
+ },
114
+ });
67
115
  const topicSearch = defineTool({
68
116
  name: 'topic_search',
69
117
  description: 'Search the long-term topic memory by keywords (hot path, no LLM). ' +
@@ -199,7 +247,7 @@ export function buildTopicTools(service) {
199
247
  return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion })) };
200
248
  },
201
249
  });
202
- return [topicSave, topicSearch, topicObserve, topicHistory];
250
+ return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory];
203
251
  }
204
252
  function extractConclusion(body) {
205
253
  // Local copy to avoid importing okf section helpers into the hot render path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "dsh-plugin: OKF v0.2 topic memory for dsh - local-first git-tracked bundle, hot-path LLM-free injection, two-stage observer with background distill",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -38,16 +38,16 @@
38
38
  "build": "tsc -p tsconfig.json",
39
39
  "check": "tsc --noEmit -p tsconfig.json",
40
40
  "pretest": "tsc -p tsconfig.json",
41
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs"
41
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs"
42
42
  },
43
43
  "dependencies": {},
44
44
  "peerDependencies": {
45
45
  "@deepseek-ai/cordis": "^4.0.2",
46
- "@deepseek-ai/dsh-tools": ">=0.1.2-alpha.4",
47
- "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.4",
48
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.4",
49
- "@deepseek-ai/dsh-commands": ">=0.1.2-alpha.4",
50
- "@deepseek-ai/dsh-util-values": ">=0.1.2-alpha.4",
46
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1",
47
+ "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1",
48
+ "@deepseek-ai/dsh-settings": ">=0.1.2-rc.1",
49
+ "@deepseek-ai/dsh-commands": ">=0.1.2-rc.1",
50
+ "@deepseek-ai/dsh-util-values": ">=0.1.2-rc.1",
51
51
  "@deepseek-ai/schemastery": "^3.18.2"
52
52
  },
53
53
  "peerDependenciesMeta": {
@@ -75,21 +75,21 @@
75
75
  },
76
76
  "devDependencies": {
77
77
  "@deepseek-ai/cordis": "4.0.2",
78
- "@deepseek-ai/dsh-agent": "0.1.2-alpha.4",
79
- "@deepseek-ai/dsh-attachment": "0.1.2-alpha.4",
80
- "@deepseek-ai/dsh-brand": "0.1.2-alpha.4",
81
- "@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.4",
82
- "@deepseek-ai/dsh-commands": "0.1.2-alpha.4",
83
- "@deepseek-ai/dsh-invariants": "0.1.2-alpha.4",
84
- "@deepseek-ai/dsh-llm": "0.1.2-alpha.4",
85
- "@deepseek-ai/dsh-scope": "0.1.2-alpha.4",
86
- "@deepseek-ai/dsh-session": "0.1.2-alpha.4",
87
- "@deepseek-ai/dsh-settings": "0.1.2-alpha.4",
88
- "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.4",
89
- "@deepseek-ai/dsh-timeout": "0.1.2-alpha.4",
90
- "@deepseek-ai/dsh-tools": "0.1.2-alpha.4",
91
- "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.4",
92
- "@deepseek-ai/dsh-util-values": "0.1.2-alpha.4",
78
+ "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
79
+ "@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
80
+ "@deepseek-ai/dsh-brand": "0.1.2-rc.1",
81
+ "@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
82
+ "@deepseek-ai/dsh-commands": "0.1.2-rc.1",
83
+ "@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
84
+ "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
85
+ "@deepseek-ai/dsh-scope": "0.1.2-rc.1",
86
+ "@deepseek-ai/dsh-session": "0.1.2-rc.1",
87
+ "@deepseek-ai/dsh-settings": "0.1.2-rc.1",
88
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
89
+ "@deepseek-ai/dsh-timeout": "0.1.2-rc.1",
90
+ "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
91
+ "@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
92
+ "@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
93
93
  "@deepseek-ai/schemastery": "3.18.2",
94
94
  "@types/node": "^22.19.0",
95
95
  "typescript": "^5.9.2"
package/src/commands.ts CHANGED
@@ -122,8 +122,9 @@ async function renderStatus(service: TopicsService): Promise<string> {
122
122
  ` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
123
123
  ` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
124
124
  ` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
125
- ` 注入:${cfg.autoInject ? `开(topK ${cfg.topK},预算 ${cfg.totalBudget} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
125
+ ` 注入:${cfg.autoInject ? `${cfg.injectMode === 'digest' ? 'digest' : 'pointer'} 模式(topK ${cfg.topK},预算 ${cfg.injectMode === 'digest' ? cfg.totalBudget : Math.min(cfg.totalBudget, 600)} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
126
126
  ` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
127
+ ` 慢道:${cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`}`,
127
128
  ` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
128
129
  ]
129
130
  // Last lane outcome (distill-state summary) — what "checkable via
@@ -181,12 +182,35 @@ async function renderStats(service: TopicsService): Promise<string> {
181
182
  ` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
182
183
  ` 平均预算占用:${stats.avgBudgetUtilization} tok`,
183
184
  ]
185
+ // v4 lane split — how much of the injection traffic each lane carries.
186
+ const slowRounds = records.filter((r) => r.lane === 'slow' || r.lane === 'mixed').length
187
+ if (slowRounds > 0) {
188
+ const consumed = records.filter((r) => r.consumedAt !== undefined && r.computedAt !== undefined)
189
+ let medianLag = ''
190
+ if (consumed.length > 0) {
191
+ const lags = consumed
192
+ .map((r) => Math.max(0, Date.parse(r.consumedAt as string) - Date.parse(r.computedAt as string)))
193
+ .sort((a, b) => a - b)
194
+ medianLag = `,赶上中位时延 ${(lags[Math.floor(lags.length / 2)] / 1000).toFixed(1)}s`
195
+ }
196
+ lines.push(` 慢道参与轮:${slowRounds}(快 ${(records.length - slowRounds)} / 慢或混合 ${slowRounds}${medianLag})`)
197
+ }
198
+ // Pointer open rate (v4 §4.3): topic_open calls vs pointer entries injected.
199
+ const opens = await service.store.readOpenRecords()
200
+ if (opens.length > 0 || records.some((r) => r.lane !== undefined)) {
201
+ const entries = records.reduce(
202
+ (acc, r) => acc + r.hits.filter((h) => !(r.deduped ?? []).includes(h.slug)).length + (r.slow?.length ?? 0),
203
+ 0,
204
+ )
205
+ const rate = entries === 0 ? 0 : Math.min(1, opens.length / entries)
206
+ lines.push(` 指针打开率:${(rate * 100).toFixed(1)}%(${opens.length} 次 topic_open / ${entries} 条注入指针)`)
207
+ }
184
208
  if (stats.topTopics.length > 0) {
185
209
  lines.push(' Top-N 被注入 Topic:')
186
210
  for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
187
211
  }
188
212
  if (stats.nearMissHistogram.length > 0) {
189
- lines.push(' Near-miss 分布(低于阈值被挡):')
213
+ lines.push(' Near-miss 分布(低于阈值或被结构门挡下):')
190
214
  for (const b of stats.nearMissHistogram) lines.push(` ${b.bucket}: ${b.count}`)
191
215
  const hint = tuningHint(stats, service.cfg.matchThreshold)
192
216
  if (hint !== undefined) lines.push(` 💡 ${hint}`)
package/src/config.ts CHANGED
@@ -22,16 +22,22 @@ export const TopicsConfig = z.object({
22
22
  totalBudget: z.number().default(1500),
23
23
  /** Retrieval score threshold — tune via /topics stats near-miss evidence. */
24
24
  matchThreshold: z.number().default(0.3),
25
- /** Additive boost per tag hit. */
25
+ /** Additive boost per tag hit (v4: total cap = this value, was ×3). */
26
26
  tagBoost: z.number().default(0.15),
27
+ /** Injection shape: pointer (default, ≤600 tok) keeps the legacy digest view. */
28
+ injectMode: z.string().default('pointer'),
29
+ /** Slow quality lane (v4 §4.2): off | sampled (1/3 of turns) | always. */
30
+ qualityLane: z.string().default('sampled'),
27
31
  /** depends-graph expansion depth (0 disables). */
28
32
  graphDepth: z.number().default(2),
29
33
  /** Days within which a topic counts as recent (+0.2). */
30
34
  recencyWindowDays: z.number().default(7),
31
35
  /** Capture each turn's user/assistant text as raw observations (M2). */
32
36
  autoObserve: z.boolean().default(true),
33
- /** Whether injection and observation also engage delegated subagent sessions (ADR 0011). */
34
- includeSubagents: z.boolean().default(true),
37
+ /** Whether injection and observation also engage delegated subagent sessions (ADR 0011).
38
+ * v4 default flipped to false: one-shot subagent turns diluted the pool.
39
+ * The slow quality lane never runs for subagents either way (hard guard). */
40
+ includeSubagents: z.boolean().default(false),
35
41
  /** Max auto-captured chars per side (user/assistant) per turn. */
36
42
  observationMaxChars: z.number().default(2000),
37
43
  /** Background distill cadence: every N turns of a long session. */
@@ -60,6 +66,8 @@ export type TopicsConfigValue = {
60
66
  totalBudget: number
61
67
  matchThreshold: number
62
68
  tagBoost: number
69
+ injectMode: string
70
+ qualityLane: string
63
71
  graphDepth: number
64
72
  recencyWindowDays: number
65
73
  autoObserve: boolean
@@ -83,6 +91,8 @@ export const CONFIG_KEYS = [
83
91
  'totalBudget',
84
92
  'matchThreshold',
85
93
  'tagBoost',
94
+ 'injectMode',
95
+ 'qualityLane',
86
96
  'graphDepth',
87
97
  'recencyWindowDays',
88
98
  'autoObserve',
@@ -136,6 +146,14 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
136
146
  if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
137
147
  return n
138
148
  }
149
+ case 'injectMode': {
150
+ if (raw === 'pointer' || raw === 'digest') return raw
151
+ return { error: 'inject-mode 取值 pointer|digest' }
152
+ }
153
+ case 'qualityLane': {
154
+ if (raw === 'off' || raw === 'sampled' || raw === 'always') return raw
155
+ return { error: 'quality-lane 取值 off|sampled|always' }
156
+ }
139
157
  case 'matchThreshold':
140
158
  case 'tagBoost': {
141
159
  const n = Number(raw)