@aiwayds/dsh-topics-memory 0.13.1 → 0.15.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/index.ts CHANGED
@@ -45,6 +45,7 @@ import { buildTopicTools } from './tools.ts'
45
45
  import { buildTopicsCommand } from './commands.ts'
46
46
  import { Observer, textOf, type UserMessageLike } from './observer.ts'
47
47
  import { Distiller, defaultModelCaller, type DistillResult, type LlmCandidateShape } from './distill.ts'
48
+ import { Consolidator, dropExpiredDeprecated, type ConsolidateResult } from './consolidate.ts'
48
49
  import { SlowLane } from './quality.ts'
49
50
  import type { SlowDelivery } from './service.ts'
50
51
  import { CONFIG_KEYS, TopicsConfig, type TopicsConfigValue } from './config.ts'
@@ -193,7 +194,7 @@ const SKILL_RESOURCE_BASE = {
193
194
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
194
195
 
195
196
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
196
- const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/观察/图游走等)、/topics 命令族(onboard/status/distill/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、autoInject、记忆库、llmwiki、include-subagents。'
197
+ const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics 段时先读本指南:settings.yaml 顶层 `topics:` 段全部键(repo/autoInject/topK/注入预算/蒸馏/整理/观察/图游走等)、/topics 命令族(onboard/status/distill/consolidate/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest、legacy llmwiki 段自动迁移。触发词:topics、记忆、topic、蒸馏、distill、整理、consolidate、合并重复、deprecatedTtl、usageBoost、使用加成、autoInject、记忆库、llmwiki、include-subagents。'
197
198
 
198
199
  const SKILL_CANDIDATE: SkillCandidate = {
199
200
  name: SKILL_PROVIDER_NAME,
@@ -471,6 +472,10 @@ export function apply(ctx: Context): void {
471
472
  // The settle hook re-runs the release check because a disposal that landed
472
473
  // mid-pipeline deferred to THIS pipeline and never fires again.
473
474
  const slowLane = new SlowLane(service, caller, releaseSessionLlm)
475
+ // Consolidation lane (整理): same caller/route as distill, global single
476
+ // flight inside. Reads the pool and merges/promotes/deprecates/refreshes;
477
+ // cadence-gated at session start, manual via /topics consolidate.
478
+ const consolidator = new Consolidator(service, caller)
474
479
 
475
480
  // ---- Observer (M2) ----
476
481
  // Trigger runs are fire-and-forget everywhere now — including the exit
@@ -642,6 +647,38 @@ export function apply(ctx: Context): void {
642
647
  if (run !== undefined) void run.catch(() => undefined)
643
648
  })
644
649
  .catch(() => undefined)
650
+ // Consolidation cadence check — after the pull (freshest pool),
651
+ // fully fire-and-forget: cadence/single-flight gates live inside
652
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
653
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
654
+ // test showed a dead distill route would otherwise hide here with
655
+ // zero observable trace (the state file only advances on success).
656
+ try {
657
+ captureFromAgent(agents()?.get(session.id) as unknown)
658
+ } catch {
659
+ // contained — the run below fails with a readable no-model detail
660
+ }
661
+ void consolidator
662
+ .maybeRun({ sessionId: String(session.id) })
663
+ .then((r) => {
664
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
665
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
666
+ }
667
+ })
668
+ .catch(() => undefined)
669
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
670
+ // the distill route is unconfigured. Drops are logged: deletion is
671
+ // the one housekeeping action the user should always see happened.
672
+ const ttl = cfgNow().deprecatedTtlDays
673
+ if (ttl > 0) {
674
+ void dropExpiredDeprecated(service, ttl)
675
+ .then((dropped) => {
676
+ if (dropped.length > 0) {
677
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
678
+ }
679
+ })
680
+ .catch(() => undefined)
681
+ }
645
682
  })
646
683
  }
647
684
  }) as never)
@@ -742,7 +779,21 @@ export function apply(ctx: Context): void {
742
779
  }
743
780
  return run.finally(() => releaseSessionLlm(sessionId))
744
781
  }
745
- cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate as never, resolveAsk, resolveLlm, manualDistill)), 'topics: /topics')
782
+ // Manual /topics consolidate trigger: same lane as the cadence path, same
783
+ // trigger-time llm capture and in-flight guard (the command runs inside a
784
+ // live session, so its agent's scoped instance is the freshest adapter
785
+ // holder). Unlike the cadence path there is no releaseSessionLlm hook —
786
+ // the consolidator holds no per-session capture of its own.
787
+ const manualConsolidate = async (invocation: unknown): Promise<ConsolidateResult> => {
788
+ try {
789
+ captureFromAgent((invocation as { agent?: unknown }).agent)
790
+ } catch {
791
+ // contained — the candidate chain still walks whatever was captured
792
+ }
793
+ const sessionId = String((invocation as { agent?: { id?: unknown } }).agent?.id ?? 'manual')
794
+ return consolidator.run(sessionId)
795
+ }
796
+ cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate as never, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics')
746
797
  })
747
798
  }
748
799
 
@@ -770,5 +821,8 @@ const DEFAULTS: TopicsConfigValue = {
770
821
  distillModel: '',
771
822
  distillBatchSize: 40,
772
823
  distillMaxModelCalls: 8,
824
+ consolidateCadence: 'daily',
825
+ deprecatedTtlDays: 15,
826
+ usageBoost: 0.15,
773
827
  pushDebounceSeconds: 45,
774
828
  }
package/src/observer.ts CHANGED
@@ -111,9 +111,12 @@ export class Observer {
111
111
  state.assistantText = ''
112
112
  return
113
113
  }
114
- case 'assistant/chunk': {
115
- const chunk = (data as { chunk?: { type?: string; text?: string } }).chunk
116
- if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') state.assistantText += chunk.text
114
+ case 'assistant/message': {
115
+ // dsh 0.1.5-rc.1: streaming `assistant/chunk` events are gone — the
116
+ // settlement carries the committed message. Accumulate each round's
117
+ // text; turn/end reads the whole turn's transcript. Failed attempts
118
+ // (`assistant/attempt`) never reached the user, so they are skipped.
119
+ state.assistantText += textOf(data as UserMessageLike)
117
120
  return
118
121
  }
119
122
  case 'turn/end': {
package/src/retrieval.ts CHANGED
@@ -40,8 +40,21 @@ export interface RetrievalConfig {
40
40
  * self-cross. Off for the explicit topic_search tool (the model asked).
41
41
  */
42
42
  structuralGate: boolean
43
+ /**
44
+ * UsageBoost (ADR 0015): behavioral bonus for topics used within the
45
+ * rolling window. Gate-scoped but double-locked — capped below the
46
+ * threshold and never granted to a zero-lexical candidate; the structural
47
+ * gate still requires lexical evidence. 0 = off.
48
+ */
49
+ usageBoost: number
50
+ /** Usage signals per slug (hits/opens in the window); absent map = no boost. */
51
+ usage?: ReadonlyMap<string, { hits: number; opens: number }>
43
52
  }
44
53
 
54
+ /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
55
+ * carry a candidate across the gate on its own (ADR 0015). */
56
+ export const USAGE_BOOST_CAP = 0.2
57
+
45
58
  export const DEFAULT_RETRIEVAL: RetrievalConfig = {
46
59
  threshold: 0.3,
47
60
  topK: 4,
@@ -49,6 +62,7 @@ export const DEFAULT_RETRIEVAL: RetrievalConfig = {
49
62
  graphDepth: 2,
50
63
  recencyWindowDays: 7,
51
64
  structuralGate: true,
65
+ usageBoost: 0.15,
52
66
  }
53
67
 
54
68
  /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
@@ -115,7 +129,8 @@ export interface ScoredTopic {
115
129
  strong: boolean
116
130
  /** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
117
131
  bodyHits: number
118
- /** Score with the recency tiebreaker removed — the only score the gate looks at. */
132
+ /** Score with the recency tiebreaker removed — the score the numeric gate
133
+ * looks at (lexical + tags + usageBoost; recency excluded, ADR 0015). */
119
134
  gateScore: number
120
135
  }
121
136
 
@@ -179,6 +194,18 @@ export function scoreTopic(
179
194
  score += boost
180
195
  reasons.push(`tag-boost:+${boost.toFixed(2)}`)
181
196
  }
197
+ // UsageBoost (ADR 0015) — gate-scoped behavioral bonus, unlike recency it
198
+ // IS part of gateScore. Double-locked: capped below the threshold, and a
199
+ // zero-lexical candidate (score === 0 here) never qualifies. Presence of a
200
+ // signal is the trigger, not its magnitude — votes never scale the bonus.
201
+ if (cfg.usageBoost > 0 && score > 0 && cfg.usage !== undefined) {
202
+ const u = cfg.usage.get(topic.slug)
203
+ if (u !== undefined && (u.hits > 0 || u.opens > 0)) {
204
+ const boost = Math.min(cfg.usageBoost, USAGE_BOOST_CAP)
205
+ score += boost
206
+ reasons.push(`usage:+${boost.toFixed(2)}`)
207
+ }
208
+ }
182
209
  // Recency is a RANKING tiebreaker only (v4): it pads `score` but is kept
183
210
  // out of `gateScore`, so it can never carry a candidate across the numeric
184
211
  // threshold. Conflict demotion applies to the pre-tiebreaker score.
package/src/service.ts CHANGED
@@ -14,7 +14,7 @@ import { searchTopics, scoreTopic, passesGate, tokenize, type RetrievableTopic,
14
14
  import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL, type AssembleResult, type DigestInput, type SlowPointerInput } from './digest.ts'
15
15
  import type { BundleStore, Observation, SaveResult } from './store.ts'
16
16
  import type { TopicsConfigValue } from './config.ts'
17
- import { aggregateStats, querySample, type AggregateStats, type InjectionRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem } from './ilog.ts'
17
+ import { aggregateStats, querySample, aggregateUsage, USAGE_WINDOW_DAYS, type AggregateStats, type InjectionRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem, type UsageSignal } from './ilog.ts'
18
18
  import { fileHistory, fileAtRev } from './git.ts'
19
19
  import type { Sync } from './sync.ts'
20
20
 
@@ -50,6 +50,13 @@ export class TopicsService {
50
50
  private cache = new Map<string, CacheEntry>()
51
51
  /** mtime-keyed parse of the observations log, grouped by session. */
52
52
  private echoCache: { mtimeMs: number; size: number; bySession: Map<string, Set<string>> } | undefined
53
+ private usageCache: {
54
+ injMs: number
55
+ injSize: number
56
+ opensMs: number
57
+ opensSize: number
58
+ signals: Map<string, UsageSignal>
59
+ } | undefined
53
60
  readonly store: BundleStore
54
61
  private readonly getConfig: () => TopicsConfigValue
55
62
  readonly sync?: Sync
@@ -99,6 +106,11 @@ export class TopicsService {
99
106
  }
100
107
  if (entry === undefined) continue
101
108
  const doc = entry.doc
109
+ // Deprecated topics are retired knowledge (consolidation merges and
110
+ // manual deprecations) — they must not rank, inject, or feed the
111
+ // depends-graph walk, or a merged-away entry would keep competing
112
+ // with its survivor for the injection budget.
113
+ if (doc.fm.status === 'deprecated') continue
102
114
  out.push({
103
115
  slug: entry.slug,
104
116
  title: doc.fm.title,
@@ -119,6 +131,7 @@ export class TopicsService {
119
131
  invalidate(): void {
120
132
  this.cache.clear()
121
133
  this.echoCache = undefined
134
+ this.usageCache = undefined
122
135
  }
123
136
 
124
137
  /**
@@ -169,6 +182,68 @@ export class TopicsService {
169
182
  return bySession.bySession.get(sessionId) ?? new Set()
170
183
  }
171
184
 
185
+ /**
186
+ * Usage signals (ADR 0015): mtime-cached aggregate over the injection log
187
+ * and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
188
+ * echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
189
+ * a fresh cache costs two stat() calls. Unreadable logs fail open to an
190
+ * empty map (no boost), never an error.
191
+ */
192
+ usageSignalsSync(): Map<string, UsageSignal> {
193
+ const injFile = join(this.store.metaDir(), 'injections.jsonl')
194
+ const opensFile = join(this.store.metaDir(), 'opens.jsonl')
195
+ const statOf = (file: string): { mtimeMs: number; size: number } => {
196
+ try {
197
+ const st = statSync(file)
198
+ return { mtimeMs: st.mtimeMs, size: st.size }
199
+ } catch {
200
+ return { mtimeMs: 0, size: 0 }
201
+ }
202
+ }
203
+ const injSt = statOf(injFile)
204
+ const opensSt = statOf(opensFile)
205
+ const cached = this.usageCache
206
+ if (
207
+ cached !== undefined &&
208
+ cached.injMs === injSt.mtimeMs &&
209
+ cached.injSize === injSt.size &&
210
+ cached.opensMs === opensSt.mtimeMs &&
211
+ cached.opensSize === opensSt.size
212
+ ) {
213
+ return cached.signals
214
+ }
215
+ const parseLines = <T>(file: string): T[] => {
216
+ const out: T[] = []
217
+ try {
218
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
219
+ if (line.trim() === '') continue
220
+ try {
221
+ out.push(JSON.parse(line) as T)
222
+ } catch {
223
+ continue // torn tail line
224
+ }
225
+ }
226
+ } catch {
227
+ // unreadable log: contribute nothing
228
+ }
229
+ return out
230
+ }
231
+ const signals = aggregateUsage(
232
+ parseLines(injFile),
233
+ parseLines(opensFile),
234
+ USAGE_WINDOW_DAYS,
235
+ Date.now(),
236
+ )
237
+ this.usageCache = {
238
+ injMs: injSt.mtimeMs,
239
+ injSize: injSt.size,
240
+ opensMs: opensSt.mtimeMs,
241
+ opensSize: opensSt.size,
242
+ signals,
243
+ }
244
+ return signals
245
+ }
246
+
172
247
  /**
173
248
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
174
249
  * injection record is written even for zero-hit rounds — the hit-rate
@@ -192,6 +267,8 @@ export class TopicsService {
192
267
  graphDepth: cfg.graphDepth,
193
268
  recencyWindowDays: cfg.recencyWindowDays,
194
269
  conflicts,
270
+ usageBoost: cfg.usageBoost,
271
+ usage: this.usageSignalsSync(),
195
272
  })
196
273
  const bySlug = new Map(roster.map((r) => [r.slug, r]))
197
274
  const entries: DigestInput[] = []
@@ -289,6 +366,8 @@ export class TopicsService {
289
366
  graphDepth: cfg.graphDepth,
290
367
  recencyWindowDays: cfg.recencyWindowDays,
291
368
  conflicts,
369
+ usageBoost: cfg.usageBoost,
370
+ usage: this.usageSignalsSync(),
292
371
  })
293
372
  const exclude = dedup?.exclude
294
373
  const echo = dedup?.echo
@@ -346,6 +425,8 @@ export class TopicsService {
346
425
  recencyWindowDays: cfg.recencyWindowDays,
347
426
  conflicts,
348
427
  structuralGate: true,
428
+ usageBoost: cfg.usageBoost,
429
+ usage: this.usageSignalsSync(),
349
430
  })
350
431
  shadow.push({
351
432
  slug: item.slug,
@@ -515,6 +596,8 @@ export class TopicsService {
515
596
  }
516
597
  if (entry === undefined) continue
517
598
  const doc = entry.doc
599
+ // Same retirement rule as roster(): deprecated knowledge never injects.
600
+ if (doc.fm.status === 'deprecated') continue
518
601
  out.push({
519
602
  slug: entry.slug,
520
603
  title: doc.fm.title,
@@ -621,6 +704,8 @@ export class TopicsService {
621
704
  // structural gate must not hide results.
622
705
  structuralGate: false,
623
706
  conflicts: await this.store.getConflicts(),
707
+ usageBoost: cfg.usageBoost,
708
+ usage: this.usageSignalsSync(),
624
709
  })
625
710
  }
626
711
 
package/src/store.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  mkdir,
18
18
  appendFile,
19
19
  access,
20
+ rm,
20
21
  } from 'node:fs/promises'
21
22
  import { readFileSync } from 'node:fs'
22
23
  import { constants as FsConstants } from 'node:fs'
@@ -161,6 +162,24 @@ export class BundleStore {
161
162
  }
162
163
  }
163
164
 
165
+ private consolidateStatePath(): string {
166
+ return join(this.metaDir(), 'consolidate-state.json')
167
+ }
168
+
169
+ /** Last consolidation-lane run outcome (cadence stamp + /topics status). */
170
+ async writeConsolidateState(state: unknown): Promise<void> {
171
+ await mkdir(this.metaDir(), { recursive: true })
172
+ await atomicWrite(this.consolidateStatePath(), `${JSON.stringify(state, null, 2)}\n`)
173
+ }
174
+
175
+ async readConsolidateState(): Promise<Record<string, unknown> | undefined> {
176
+ try {
177
+ return JSON.parse(await readFile(this.consolidateStatePath(), 'utf8')) as Record<string, unknown>
178
+ } catch {
179
+ return undefined
180
+ }
181
+ }
182
+
164
183
  /** Create the directory skeleton, git repo, initial index, and repair
165
184
  * wrapped depends entries left by older save paths (idempotent). */
166
185
  async ensure(): Promise<void> {
@@ -326,8 +345,25 @@ export class BundleStore {
326
345
  })
327
346
  }
328
347
 
329
- private async regenerateIndex(): Promise<void> {
330
- const metas = await this.listTopics()
348
+ /**
349
+ * Remove a topic file and regenerate the index — one commit, so the rm is
350
+ * as traceable and revertible as every other bundle write. Callers own the
351
+ * policy (TTL housekeeping, future manual ops); the store only supplies the
352
+ * safe write path. Returns false when the slug doesn't exist (idempotent).
353
+ */
354
+ async deleteTopic(slug: string, message: string): Promise<boolean> {
355
+ return this.enqueue(async () => {
356
+ const clean = okf.slugify(slug)
357
+ const file = this.topicPath(clean)
358
+ if (!(await existsSync(file))) return false
359
+ await rm(file)
360
+ await this.regenerateIndex()
361
+ await this.commit([`topics/${clean}.md`, 'index.md'], message)
362
+ return true
363
+ })
364
+ }
365
+
366
+ private async regenerateIndex(): Promise<void> { const metas = await this.listTopics()
331
367
  const entries: okf.IndexEntry[] = metas.map((m) => ({
332
368
  slug: m.slug,
333
369
  title: m.title,