@aiwayds/dsh-topics-memory 0.9.0 → 0.11.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/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 candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT)
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) => {
package/src/service.ts CHANGED
@@ -48,6 +48,8 @@ export interface SlowDelivery {
48
48
 
49
49
  export class TopicsService {
50
50
  private cache = new Map<string, CacheEntry>()
51
+ /** mtime-keyed parse of the observations log, grouped by session. */
52
+ private echoCache: { mtimeMs: number; size: number; bySession: Map<string, Set<string>> } | undefined
51
53
  readonly store: BundleStore
52
54
  private readonly getConfig: () => TopicsConfigValue
53
55
  readonly sync?: Sync
@@ -116,6 +118,55 @@ export class TopicsService {
116
118
  /** Drop the roster cache (tests, bulk external edits). */
117
119
  invalidate(): void {
118
120
  this.cache.clear()
121
+ this.echoCache = undefined
122
+ }
123
+
124
+ /**
125
+ * Slugs distilled from THIS session's own turns — the echo set (2026-09
126
+ * audit finding #3: a topic distilled from in-session knowledge injected
127
+ * back into the same session is a lagging echo of what the model already
128
+ * said, occasionally stale enough to contradict the newer in-session
129
+ * conclusion). Provenance is durable in the observations log: each line
130
+ * carries `sessionId` and, once distilled, `distilledInto`. Sync read on
131
+ * the injection hot path, mtime-cached like the roster.
132
+ */
133
+ echoSlugsSync(sessionId: string): Set<string> {
134
+ const file = join(this.store.metaDir(), 'observations.jsonl')
135
+ let st
136
+ try {
137
+ st = statSync(file)
138
+ } catch {
139
+ return new Set()
140
+ }
141
+ let bySession = this.echoCache
142
+ if (bySession === undefined || bySession.mtimeMs !== st.mtimeMs || bySession.size !== st.size) {
143
+ bySession = { mtimeMs: st.mtimeMs, size: st.size, bySession: new Map() }
144
+ try {
145
+ const lines = readFileSync(file, 'utf8').split('\n')
146
+ for (const line of lines) {
147
+ if (line.trim() === '') continue
148
+ let obs: { sessionId?: unknown; distilled?: unknown; distilledInto?: unknown }
149
+ try {
150
+ obs = JSON.parse(line)
151
+ } catch {
152
+ continue // torn tail line
153
+ }
154
+ if (obs.distilled !== true || typeof obs.sessionId !== 'string' || !Array.isArray(obs.distilledInto)) continue
155
+ let set = bySession.bySession.get(obs.sessionId)
156
+ if (set === undefined) {
157
+ set = new Set()
158
+ bySession.bySession.set(obs.sessionId, set)
159
+ }
160
+ for (const slug of obs.distilledInto) {
161
+ if (typeof slug === 'string' && slug !== '') set.add(okf.slugify(slug))
162
+ }
163
+ }
164
+ } catch {
165
+ return new Set() // unreadable log: fail open (no suppression)
166
+ }
167
+ this.echoCache = bySession
168
+ }
169
+ return bySession.bySession.get(sessionId) ?? new Set()
119
170
  }
120
171
 
121
172
  /**
@@ -183,17 +234,21 @@ export class TopicsService {
183
234
  *
184
235
  * `dedup.exclude` (session-level injection dedup) filters hits AND slow
185
236
  * picks before assembly; excluded slugs are reported back as `deduped`
186
- * (and logged as `record.deduped`), never packed. `included` mirrors the
187
- * fast pointers that entered the context and `slowIncluded` the slow ones
188
- * — only these may be marked as injected by the caller; budget-dropped
189
- * slugs stay injectable later.
237
+ * (and logged as `record.deduped`), never packed. `dedup.echo` (蒸馏回声,
238
+ * v4 audit) filters the same way but reports separately as `echoed` /
239
+ * `record.echoed`: slugs distilled from THIS session's own turns.
240
+ * `included` mirrors the fast pointers that entered the context and
241
+ * `slowIncluded` the slow ones — only these may be marked as injected by
242
+ * the caller; budget-dropped slugs stay injectable later.
190
243
  *
191
244
  * `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
192
245
  * packed after the fast pointers under the same budget, take log-only
193
246
  * shadow re-gate verdicts against the CURRENT query (B3: record, never
194
- * block), and fill the record's lane field family.
247
+ * block), and fill the record's lane field family. A pick the fast lane
248
+ * already delivers THIS round is counted as slow-delivered (it entered the
249
+ * context) but not packed twice.
195
250
  */
196
- retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[]; slowIncluded: string[] } {
251
+ retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string>; echo?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; echoed: string[]; included: string[]; slowIncluded: string[] } {
197
252
  const cfg = this.cfg
198
253
  const roster = this.rosterSync()
199
254
  const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
@@ -224,7 +279,7 @@ export class TopicsService {
224
279
  }
225
280
  void this.store.appendInjectionRecord(trace).catch(() => undefined)
226
281
  }
227
- return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] }
282
+ return { text: '', outcome: empty, deduped: [], echoed: [], included: [], slowIncluded: [] }
228
283
  }
229
284
  const conflicts = this.store.getConflictsSync()
230
285
  const outcome = searchTopics(query, roster, {
@@ -236,12 +291,18 @@ export class TopicsService {
236
291
  conflicts,
237
292
  })
238
293
  const exclude = dedup?.exclude
294
+ const echo = dedup?.echo
239
295
  const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
296
+ const echoed: string[] = []
240
297
  const bySlug = new Map(roster.map((r) => [r.slug, r]))
241
298
  const entries: DigestInput[] = []
242
299
  const unreadable: string[] = []
243
300
  for (const hit of outcome.hits) {
244
301
  if (exclude?.has(hit.slug)) continue
302
+ if (echo?.has(hit.slug)) {
303
+ echoed.push(hit.slug)
304
+ continue
305
+ }
245
306
  const doc = this.readDocSync(hit.slug)
246
307
  if (doc === undefined) {
247
308
  // The hit has no readable doc — without this accounting it used to
@@ -264,8 +325,19 @@ export class TopicsService {
264
325
  if (!deduped.includes(item.slug)) deduped.push(item.slug)
265
326
  continue
266
327
  }
328
+ if (echo?.has(item.slug)) {
329
+ if (!echoed.includes(item.slug)) echoed.push(item.slug)
330
+ continue
331
+ }
267
332
  const meta = bySlug.get(item.slug)
268
333
  if (meta === undefined) continue // vanished between production and consumption
334
+ if (entries.some((e) => e.slug === item.slug)) {
335
+ // The fast lane delivers the same slug THIS round — the content
336
+ // enters the context exactly once (never packed twice), and the
337
+ // pick still counts as delivered for the lane bookkeeping.
338
+ slowDelivered.push({ slug: item.slug, why: item.why })
339
+ continue
340
+ }
269
341
  const verdict = scoreTopic(queryTokens, meta, {
270
342
  threshold: cfg.matchThreshold,
271
343
  topK: cfg.topK,
@@ -313,6 +385,7 @@ export class TopicsService {
313
385
  }
314
386
  if (sessionId !== undefined) record.sessionId = sessionId
315
387
  if (deduped.length > 0) record.deduped = deduped
388
+ if (echoed.length > 0) record.echoed = echoed
316
389
  if (unreadable.length > 0) {
317
390
  record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
318
391
  }
@@ -347,7 +420,7 @@ export class TopicsService {
347
420
  if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
348
421
  if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
349
422
  void this.store.appendInjectionRecord(record).catch(() => undefined)
350
- return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded }
423
+ return { text: injection.text, outcome, deduped, echoed, included: injection.included, slowIncluded }
351
424
  }
352
425
 
353
426
  /**
@@ -356,7 +429,7 @@ export class TopicsService {
356
429
  * the store stamps it on every write, so it IS the last-updated instant).
357
430
  * Logs an open record for the pointer-open-rate stat.
358
431
  */
359
- async openTopic(slug: string, sessionId?: string): Promise<{
432
+ async openTopic(rawSlug: string, sessionId?: string): Promise<{
360
433
  found: boolean
361
434
  slug: string
362
435
  title?: string
@@ -367,24 +440,47 @@ export class TopicsService {
367
440
  openQuestions?: string[]
368
441
  recommendations?: string
369
442
  }> {
370
- const doc = await this.store.readTopic(slug).catch(() => undefined)
443
+ // Pointers render slugs as `(topics:<slug>)` and models copy that whole
444
+ // token back as the argument — unwrap every `topics:`/`topics/` wrap the
445
+ // same way the depends edges do (audit ⑨: the wrapped form read as a
446
+ // missing topic). Then CANONICALIZE with the save path's own slugify:
447
+ // unwrap alone is case-preserving, and on a case-sensitive filesystem
448
+ // (Linux bundles) an uppercase-carrying argument would miss the file the
449
+ // save path wrote under its folded slug (CI-caught, v0.11.0 round 1).
450
+ const slug = okf.slugify(okf.unwrapTopicRef(rawSlug))
451
+ const doc = slug === '' ? undefined : await this.store.readTopic(slug).catch(() => undefined)
371
452
  if (doc === undefined) return { found: false, slug }
372
453
  // Awaited (tool path, not hot): a settled write makes the pointer-open
373
454
  // stat reliable; a failed log write must not fail the tool.
374
455
  await this.store
375
456
  .appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
376
457
  .catch(() => undefined)
377
- return {
458
+ // Optional fields are OMITTED, never present-as-undefined: the host's
459
+ // tool-output validation rejects any object carrying an undefined-valued
460
+ // key as non-lossless JSON (INVALID_TOOL_OUTPUT; audit ②⑦ — topics
461
+ // without a description made every open of them fail).
462
+ const found: {
463
+ found: boolean
464
+ slug: string
465
+ title: string
466
+ status: okf.TopicStatus
467
+ updatedAt: string
468
+ description?: string
469
+ conclusion: string
470
+ openQuestions: string[]
471
+ recommendations: string
472
+ } = {
378
473
  found: true,
379
474
  slug,
380
475
  title: doc.fm.title,
381
476
  status: doc.fm.status,
382
477
  updatedAt: doc.fm.generated.at,
383
- description: doc.fm.description,
384
478
  conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
385
479
  openQuestions: doc.fm.open_questions,
386
480
  recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
387
481
  }
482
+ if (doc.fm.description !== undefined) found.description = doc.fm.description
483
+ return found
388
484
  }
389
485
 
390
486
  /** Sync roster read (same mtime cache as roster()). */
package/src/sync.ts CHANGED
@@ -51,7 +51,12 @@ export class Sync {
51
51
  return this.cfg().repo !== ''
52
52
  }
53
53
 
54
- /** Session start (or `/topics sync pull`): rebase onto remote, mark conflicts. */
54
+ /**
55
+ * Session start (or `/topics sync pull`): rebase onto remote, mark
56
+ * conflicts, then replay any push the previous session's local-only exit
57
+ * deferred. The replay is best-effort — a failure just marks lastError and
58
+ * the debounced flush (or the next boot's pull) retries.
59
+ */
55
60
  async pull(): Promise<{ ok: boolean; conflicted: string[]; message: string }> {
56
61
  if (!this.active) return { ok: true, conflicted: [], message: 'local-only 模式,无同步' }
57
62
  const token = await this.safeToken()
@@ -66,6 +71,21 @@ export class Sync {
66
71
  // A successful rebase clears previous conflict marks.
67
72
  const prior = await this.store.getConflicts()
68
73
  if (prior.size > 0) await this.store.setConflicts([])
74
+ // Deferred-push replay: the previous exit committed locally only, so a
75
+ // successful rebase here is the moment the backlog lands on the remote.
76
+ try {
77
+ if ((await gitmod.unpushedCount(this.store.root)) > 0) {
78
+ const pushed = await gitmod.push(this.store.root, token)
79
+ if (pushed.ok) {
80
+ this.lastPushAt = new Date().toISOString()
81
+ this.lastError = ''
82
+ } else {
83
+ this.lastError = pushed.output.slice(-500)
84
+ }
85
+ }
86
+ } catch {
87
+ // pull must stay best-effort for the session-start caller
88
+ }
69
89
  return { ok: true, conflicted: [], message: '已与远端同步' }
70
90
  }
71
91
  const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''))
@@ -73,7 +93,14 @@ export class Sync {
73
93
  await this.store.setConflicts(outcome.conflicted)
74
94
  }
75
95
  this.lastError = outcome.output.slice(-500)
76
- return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、') || '未知路径'}(已标记降权,待手工解决)` }
96
+ // A rebase can fail without producing conflicted paths (identity missing,
97
+ // fetch failure, corrupt state) — surface the git output tail instead of
98
+ // the misleading 「未知路径」 so a headless failure is diagnosable.
99
+ if (conflictedSlugs.length === 0) {
100
+ const tail = outcome.output.trim().split('\n').slice(-3).join(';').slice(-200)
101
+ return { ok: false, conflicted: [], message: `rebase 失败(无冲突文件):${tail !== '' ? tail : 'git 无输出'}` }
102
+ }
103
+ return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、')}(已标记降权,待手工解决)` }
77
104
  }
78
105
 
79
106
  /** Called after every write-through commit — schedules the debounced push. */
package/src/tools.ts CHANGED
@@ -261,7 +261,19 @@ export function buildTopicTools(service: TopicsService) {
261
261
  },
262
262
  async execute(args) {
263
263
  const { entries } = await service.history(args.slug, args.limit ?? 20)
264
- return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion as string | undefined })) }
264
+ // conclusion is omitted, never present-as-undefined — the host rejects
265
+ // undefined-valued keys as non-lossless JSON (INVALID_TOOL_OUTPUT).
266
+ return {
267
+ entries: entries.flatMap((e) => {
268
+ const entry: { hash: string; date: string; message: string; conclusion?: string } = {
269
+ hash: e.hash,
270
+ date: e.date,
271
+ message: e.message,
272
+ }
273
+ if (typeof e.conclusion === 'string') entry.conclusion = e.conclusion
274
+ return [entry]
275
+ }),
276
+ }
265
277
  },
266
278
  })
267
279