@aiwayds/dsh-topics-memory 0.13.1 → 0.14.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/index.js CHANGED
@@ -30,6 +30,7 @@ import { buildTopicTools } from "./tools.js";
30
30
  import { buildTopicsCommand } from "./commands.js";
31
31
  import { Observer, textOf } from "./observer.js";
32
32
  import { Distiller, defaultModelCaller } from "./distill.js";
33
+ import { Consolidator, dropExpiredDeprecated } from "./consolidate.js";
33
34
  import { SlowLane } from "./quality.js";
34
35
  import { CONFIG_KEYS, TopicsConfig } from "./config.js";
35
36
  import { isDelegated } from "./delegation.js";
@@ -136,7 +137,7 @@ const SKILL_RESOURCE_BASE = {
136
137
  };
137
138
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
138
139
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
139
- 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。';
140
+ 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。';
140
141
  const SKILL_CANDIDATE = {
141
142
  name: SKILL_PROVIDER_NAME,
142
143
  description: SKILL_DESCRIPTION,
@@ -410,6 +411,10 @@ export function apply(ctx) {
410
411
  // The settle hook re-runs the release check because a disposal that landed
411
412
  // mid-pipeline deferred to THIS pipeline and never fires again.
412
413
  const slowLane = new SlowLane(service, caller, releaseSessionLlm);
414
+ // Consolidation lane (整理): same caller/route as distill, global single
415
+ // flight inside. Reads the pool and merges/promotes/deprecates/refreshes;
416
+ // cadence-gated at session start, manual via /topics consolidate.
417
+ const consolidator = new Consolidator(service, caller);
413
418
  // ---- Observer (M2) ----
414
419
  // Trigger runs are fire-and-forget everywhere now — including the exit
415
420
  // one. Observations are write-through on disk, so a run the process exits
@@ -586,6 +591,39 @@ export function apply(ctx) {
586
591
  void run.catch(() => undefined);
587
592
  })
588
593
  .catch(() => undefined);
594
+ // Consolidation cadence check — after the pull (freshest pool),
595
+ // fully fire-and-forget: cadence/single-flight gates live inside
596
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
597
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
598
+ // test showed a dead distill route would otherwise hide here with
599
+ // zero observable trace (the state file only advances on success).
600
+ try {
601
+ captureFromAgent(agents()?.get(session.id));
602
+ }
603
+ catch {
604
+ // contained — the run below fails with a readable no-model detail
605
+ }
606
+ void consolidator
607
+ .maybeRun({ sessionId: String(session.id) })
608
+ .then((r) => {
609
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
610
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
611
+ }
612
+ })
613
+ .catch(() => undefined);
614
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
615
+ // the distill route is unconfigured. Drops are logged: deletion is
616
+ // the one housekeeping action the user should always see happened.
617
+ const ttl = cfgNow().deprecatedTtlDays;
618
+ if (ttl > 0) {
619
+ void dropExpiredDeprecated(service, ttl)
620
+ .then((dropped) => {
621
+ if (dropped.length > 0) {
622
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
623
+ }
624
+ })
625
+ .catch(() => undefined);
626
+ }
589
627
  });
590
628
  }
591
629
  }));
@@ -684,7 +722,22 @@ export function apply(ctx) {
684
722
  }
685
723
  return run.finally(() => releaseSessionLlm(sessionId));
686
724
  };
687
- cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill)), 'topics: /topics');
725
+ // Manual /topics consolidate trigger: same lane as the cadence path, same
726
+ // trigger-time llm capture and in-flight guard (the command runs inside a
727
+ // live session, so its agent's scoped instance is the freshest adapter
728
+ // holder). Unlike the cadence path there is no releaseSessionLlm hook —
729
+ // the consolidator holds no per-session capture of its own.
730
+ const manualConsolidate = async (invocation) => {
731
+ try {
732
+ captureFromAgent(invocation.agent);
733
+ }
734
+ catch {
735
+ // contained — the candidate chain still walks whatever was captured
736
+ }
737
+ const sessionId = String(invocation.agent?.id ?? 'manual');
738
+ return consolidator.run(sessionId);
739
+ };
740
+ cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics');
688
741
  });
689
742
  }
690
743
  /** Config defaults for harnesses that skip schemastery's default application. */
@@ -711,5 +764,8 @@ const DEFAULTS = {
711
764
  distillModel: '',
712
765
  distillBatchSize: 40,
713
766
  distillMaxModelCalls: 8,
767
+ consolidateCadence: 'daily',
768
+ deprecatedTtlDays: 15,
769
+ usageBoost: 0.15,
714
770
  pushDebounceSeconds: 45,
715
771
  };
@@ -36,7 +36,22 @@ export interface RetrievalConfig {
36
36
  * self-cross. Off for the explicit topic_search tool (the model asked).
37
37
  */
38
38
  structuralGate: boolean;
39
+ /**
40
+ * UsageBoost (ADR 0015): behavioral bonus for topics used within the
41
+ * rolling window. Gate-scoped but double-locked — capped below the
42
+ * threshold and never granted to a zero-lexical candidate; the structural
43
+ * gate still requires lexical evidence. 0 = off.
44
+ */
45
+ usageBoost: number;
46
+ /** Usage signals per slug (hits/opens in the window); absent map = no boost. */
47
+ usage?: ReadonlyMap<string, {
48
+ hits: number;
49
+ opens: number;
50
+ }>;
39
51
  }
52
+ /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
53
+ * carry a candidate across the gate on its own (ADR 0015). */
54
+ export declare const USAGE_BOOST_CAP = 0.2;
40
55
  export declare const DEFAULT_RETRIEVAL: RetrievalConfig;
41
56
  /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
42
57
  export declare const TRIGGER_WEIGHT = 5;
@@ -70,7 +85,8 @@ export interface ScoredTopic {
70
85
  strong: boolean;
71
86
  /** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
72
87
  bodyHits: number;
73
- /** Score with the recency tiebreaker removed — the only score the gate looks at. */
88
+ /** Score with the recency tiebreaker removed — the score the numeric gate
89
+ * looks at (lexical + tags + usageBoost; recency excluded, ADR 0015). */
74
90
  gateScore: number;
75
91
  }
76
92
  /** Score one topic against the query tokens. */
package/lib/retrieval.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * @module retrieval
13
13
  */
14
14
  import { dependsSlugs } from "./okf.js";
15
+ /** Hard cap for the usage bonus — below threshold 0.3 so usage can never
16
+ * carry a candidate across the gate on its own (ADR 0015). */
17
+ export const USAGE_BOOST_CAP = 0.2;
15
18
  export const DEFAULT_RETRIEVAL = {
16
19
  threshold: 0.3,
17
20
  topK: 4,
@@ -19,6 +22,7 @@ export const DEFAULT_RETRIEVAL = {
19
22
  graphDepth: 2,
20
23
  recencyWindowDays: 7,
21
24
  structuralGate: true,
25
+ usageBoost: 0.15,
22
26
  };
23
27
  /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
24
28
  export const TRIGGER_WEIGHT = 5;
@@ -115,6 +119,18 @@ export function scoreTopic(queryTokens, topic, cfg) {
115
119
  score += boost;
116
120
  reasons.push(`tag-boost:+${boost.toFixed(2)}`);
117
121
  }
122
+ // UsageBoost (ADR 0015) — gate-scoped behavioral bonus, unlike recency it
123
+ // IS part of gateScore. Double-locked: capped below the threshold, and a
124
+ // zero-lexical candidate (score === 0 here) never qualifies. Presence of a
125
+ // signal is the trigger, not its magnitude — votes never scale the bonus.
126
+ if (cfg.usageBoost > 0 && score > 0 && cfg.usage !== undefined) {
127
+ const u = cfg.usage.get(topic.slug);
128
+ if (u !== undefined && (u.hits > 0 || u.opens > 0)) {
129
+ const boost = Math.min(cfg.usageBoost, USAGE_BOOST_CAP);
130
+ score += boost;
131
+ reasons.push(`usage:+${boost.toFixed(2)}`);
132
+ }
133
+ }
118
134
  // Recency is a RANKING tiebreaker only (v4): it pads `score` but is kept
119
135
  // out of `gateScore`, so it can never carry a candidate across the numeric
120
136
  // threshold. Conflict demotion applies to the pre-tiebreaker score.
package/lib/service.d.ts CHANGED
@@ -10,7 +10,7 @@ import { type RetrievableTopic, type SearchOutcome } from './retrieval.ts';
10
10
  import { type AssembleResult } from './digest.ts';
11
11
  import type { BundleStore, Observation, SaveResult } from './store.ts';
12
12
  import type { TopicsConfigValue } from './config.ts';
13
- import { type AggregateStats, type QueryBuildShape } from './ilog.ts';
13
+ import { type AggregateStats, type QueryBuildShape, type UsageSignal } from './ilog.ts';
14
14
  import type { Sync } from './sync.ts';
15
15
  export interface RetrieveOutcome {
16
16
  outcome: SearchOutcome;
@@ -38,6 +38,7 @@ export declare class TopicsService {
38
38
  private cache;
39
39
  /** mtime-keyed parse of the observations log, grouped by session. */
40
40
  private echoCache;
41
+ private usageCache;
41
42
  readonly store: BundleStore;
42
43
  private readonly getConfig;
43
44
  readonly sync?: Sync;
@@ -58,6 +59,14 @@ export declare class TopicsService {
58
59
  * the injection hot path, mtime-cached like the roster.
59
60
  */
60
61
  echoSlugsSync(sessionId: string): Set<string>;
62
+ /**
63
+ * Usage signals (ADR 0015): mtime-cached aggregate over the injection log
64
+ * and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
65
+ * echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
66
+ * a fresh cache costs two stat() calls. Unreadable logs fail open to an
67
+ * empty map (no boost), never an error.
68
+ */
69
+ usageSignalsSync(): Map<string, UsageSignal>;
61
70
  /**
62
71
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
63
72
  * injection record is written even for zero-hit rounds — the hit-rate
package/lib/service.js CHANGED
@@ -11,12 +11,13 @@ import { join } from 'node:path';
11
11
  import * as okf from "./okf.js";
12
12
  import { searchTopics, scoreTopic, passesGate, tokenize } from "./retrieval.js";
13
13
  import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL } from "./digest.js";
14
- import { aggregateStats, querySample } from "./ilog.js";
14
+ import { aggregateStats, querySample, aggregateUsage, USAGE_WINDOW_DAYS } from "./ilog.js";
15
15
  import { fileHistory, fileAtRev } from "./git.js";
16
16
  export class TopicsService {
17
17
  cache = new Map();
18
18
  /** mtime-keyed parse of the observations log, grouped by session. */
19
19
  echoCache;
20
+ usageCache;
20
21
  store;
21
22
  getConfig;
22
23
  sync;
@@ -66,6 +67,12 @@ export class TopicsService {
66
67
  if (entry === undefined)
67
68
  continue;
68
69
  const doc = entry.doc;
70
+ // Deprecated topics are retired knowledge (consolidation merges and
71
+ // manual deprecations) — they must not rank, inject, or feed the
72
+ // depends-graph walk, or a merged-away entry would keep competing
73
+ // with its survivor for the injection budget.
74
+ if (doc.fm.status === 'deprecated')
75
+ continue;
69
76
  out.push({
70
77
  slug: entry.slug,
71
78
  title: doc.fm.title,
@@ -85,6 +92,7 @@ export class TopicsService {
85
92
  invalidate() {
86
93
  this.cache.clear();
87
94
  this.echoCache = undefined;
95
+ this.usageCache = undefined;
88
96
  }
89
97
  /**
90
98
  * Slugs distilled from THIS session's own turns — the echo set (2026-09
@@ -139,6 +147,64 @@ export class TopicsService {
139
147
  }
140
148
  return bySession.bySession.get(sessionId) ?? new Set();
141
149
  }
150
+ /**
151
+ * Usage signals (ADR 0015): mtime-cached aggregate over the injection log
152
+ * and the opens log, rolling USAGE_WINDOW_DAYS. Synchronous like
153
+ * echoSlugsSync — the retrieval hot path is sync — and cheap when warm:
154
+ * a fresh cache costs two stat() calls. Unreadable logs fail open to an
155
+ * empty map (no boost), never an error.
156
+ */
157
+ usageSignalsSync() {
158
+ const injFile = join(this.store.metaDir(), 'injections.jsonl');
159
+ const opensFile = join(this.store.metaDir(), 'opens.jsonl');
160
+ const statOf = (file) => {
161
+ try {
162
+ const st = statSync(file);
163
+ return { mtimeMs: st.mtimeMs, size: st.size };
164
+ }
165
+ catch {
166
+ return { mtimeMs: 0, size: 0 };
167
+ }
168
+ };
169
+ const injSt = statOf(injFile);
170
+ const opensSt = statOf(opensFile);
171
+ const cached = this.usageCache;
172
+ if (cached !== undefined &&
173
+ cached.injMs === injSt.mtimeMs &&
174
+ cached.injSize === injSt.size &&
175
+ cached.opensMs === opensSt.mtimeMs &&
176
+ cached.opensSize === opensSt.size) {
177
+ return cached.signals;
178
+ }
179
+ const parseLines = (file) => {
180
+ const out = [];
181
+ try {
182
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
183
+ if (line.trim() === '')
184
+ continue;
185
+ try {
186
+ out.push(JSON.parse(line));
187
+ }
188
+ catch {
189
+ continue; // torn tail line
190
+ }
191
+ }
192
+ }
193
+ catch {
194
+ // unreadable log: contribute nothing
195
+ }
196
+ return out;
197
+ };
198
+ const signals = aggregateUsage(parseLines(injFile), parseLines(opensFile), USAGE_WINDOW_DAYS, Date.now());
199
+ this.usageCache = {
200
+ injMs: injSt.mtimeMs,
201
+ injSize: injSt.size,
202
+ opensMs: opensSt.mtimeMs,
203
+ opensSize: opensSt.size,
204
+ signals,
205
+ };
206
+ return signals;
207
+ }
142
208
  /**
143
209
  * Full retrieval round: search → assemble → log (ADR 0006/0007). The
144
210
  * injection record is written even for zero-hit rounds — the hit-rate
@@ -162,6 +228,8 @@ export class TopicsService {
162
228
  graphDepth: cfg.graphDepth,
163
229
  recencyWindowDays: cfg.recencyWindowDays,
164
230
  conflicts,
231
+ usageBoost: cfg.usageBoost,
232
+ usage: this.usageSignalsSync(),
165
233
  });
166
234
  const bySlug = new Map(roster.map((r) => [r.slug, r]));
167
235
  const entries = [];
@@ -264,6 +332,8 @@ export class TopicsService {
264
332
  graphDepth: cfg.graphDepth,
265
333
  recencyWindowDays: cfg.recencyWindowDays,
266
334
  conflicts,
335
+ usageBoost: cfg.usageBoost,
336
+ usage: this.usageSignalsSync(),
267
337
  });
268
338
  const exclude = dedup?.exclude;
269
339
  const echo = dedup?.echo;
@@ -325,6 +395,8 @@ export class TopicsService {
325
395
  recencyWindowDays: cfg.recencyWindowDays,
326
396
  conflicts,
327
397
  structuralGate: true,
398
+ usageBoost: cfg.usageBoost,
399
+ usage: this.usageSignalsSync(),
328
400
  });
329
401
  shadow.push({
330
402
  slug: item.slug,
@@ -488,6 +560,9 @@ export class TopicsService {
488
560
  if (entry === undefined)
489
561
  continue;
490
562
  const doc = entry.doc;
563
+ // Same retirement rule as roster(): deprecated knowledge never injects.
564
+ if (doc.fm.status === 'deprecated')
565
+ continue;
491
566
  out.push({
492
567
  slug: entry.slug,
493
568
  title: doc.fm.title,
@@ -579,6 +654,8 @@ export class TopicsService {
579
654
  // structural gate must not hide results.
580
655
  structuralGate: false,
581
656
  conflicts: await this.store.getConflicts(),
657
+ usageBoost: cfg.usageBoost,
658
+ usage: this.usageSignalsSync(),
582
659
  });
583
660
  }
584
661
  async history(slug, limit = 20) {
package/lib/store.d.ts CHANGED
@@ -66,6 +66,10 @@ export declare class BundleStore {
66
66
  /** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
67
67
  writeDistillState(state: unknown): Promise<void>;
68
68
  readDistillState(): Promise<Record<string, unknown> | undefined>;
69
+ private consolidateStatePath;
70
+ /** Last consolidation-lane run outcome (cadence stamp + /topics status). */
71
+ writeConsolidateState(state: unknown): Promise<void>;
72
+ readConsolidateState(): Promise<Record<string, unknown> | undefined>;
69
73
  /** Create the directory skeleton, git repo, initial index, and repair
70
74
  * wrapped depends entries left by older save paths (idempotent). */
71
75
  ensure(): Promise<void>;
@@ -102,6 +106,13 @@ export declare class BundleStore {
102
106
  actor?: string;
103
107
  created?: boolean;
104
108
  }): Promise<SaveResult>;
109
+ /**
110
+ * Remove a topic file and regenerate the index — one commit, so the rm is
111
+ * as traceable and revertible as every other bundle write. Callers own the
112
+ * policy (TTL housekeeping, future manual ops); the store only supplies the
113
+ * safe write path. Returns false when the slug doesn't exist (idempotent).
114
+ */
115
+ deleteTopic(slug: string, message: string): Promise<boolean>;
105
116
  private regenerateIndex;
106
117
  /**
107
118
  * Reverse-reference index: slug → who references it and how (`depends`
package/lib/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module store
10
10
  */
11
- import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, } from 'node:fs/promises';
11
+ import { readdir, readFile, writeFile, rename, mkdir, appendFile, access, rm, } from 'node:fs/promises';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { constants as FsConstants } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
@@ -93,6 +93,22 @@ export class BundleStore {
93
93
  return undefined;
94
94
  }
95
95
  }
96
+ consolidateStatePath() {
97
+ return join(this.metaDir(), 'consolidate-state.json');
98
+ }
99
+ /** Last consolidation-lane run outcome (cadence stamp + /topics status). */
100
+ async writeConsolidateState(state) {
101
+ await mkdir(this.metaDir(), { recursive: true });
102
+ await atomicWrite(this.consolidateStatePath(), `${JSON.stringify(state, null, 2)}\n`);
103
+ }
104
+ async readConsolidateState() {
105
+ try {
106
+ return JSON.parse(await readFile(this.consolidateStatePath(), 'utf8'));
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
96
112
  /** Create the directory skeleton, git repo, initial index, and repair
97
113
  * wrapped depends entries left by older save paths (idempotent). */
98
114
  async ensure() {
@@ -262,6 +278,24 @@ export class BundleStore {
262
278
  return { slug, path: `topics/${slug}.md`, committed, created };
263
279
  });
264
280
  }
281
+ /**
282
+ * Remove a topic file and regenerate the index — one commit, so the rm is
283
+ * as traceable and revertible as every other bundle write. Callers own the
284
+ * policy (TTL housekeeping, future manual ops); the store only supplies the
285
+ * safe write path. Returns false when the slug doesn't exist (idempotent).
286
+ */
287
+ async deleteTopic(slug, message) {
288
+ return this.enqueue(async () => {
289
+ const clean = okf.slugify(slug);
290
+ const file = this.topicPath(clean);
291
+ if (!(await existsSync(file)))
292
+ return false;
293
+ await rm(file);
294
+ await this.regenerateIndex();
295
+ await this.commit([`topics/${clean}.md`, 'index.md'], message);
296
+ return true;
297
+ });
298
+ }
265
299
  async regenerateIndex() {
266
300
  const metas = await this.listTopics();
267
301
  const entries = metas.map((m) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.13.1",
3
+ "version": "0.14.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",
@@ -39,7 +39,7 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "check": "tsc --noEmit -p tsconfig.json",
41
41
  "pretest": "tsc -p tsconfig.json",
42
- "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 test/depends-repair.test.mjs test/skill.test.mjs",
42
+ "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/consolidate.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 test/depends-repair.test.mjs test/skill.test.mjs",
43
43
  "smoke": "node scripts/smoke-boot.mjs"
44
44
  },
45
45
  "dependencies": {},
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dsh-topics-memory-config
3
- 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。"
3
+ 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。"
4
4
  ---
5
5
 
6
6
  # dsh-topics-memory 使用指南(工作记忆 / 蒸馏 / 注入)
@@ -36,6 +36,9 @@ description: "dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指
36
36
  | `distillProvider` / `distillModel` | 空(蒸馏关闭) | 蒸馏模型路由,两者都非空才启用 |
37
37
  | `distillBatchSize` | `40` | 每次蒸馏模型调用携带的观察条数(输出上限失败自动减半,下限 5) |
38
38
  | `distillMaxModelCalls` | `8` | 单次蒸馏 run 的模型调用预算(预算耗尽即停,已成功批次保留标记) |
39
+ | `consolidateCadence` | `daily` | 整理 lane 节拍:daily/3d/7d/off。会话启动时检查上次整理时间,到期即在后台跑 LLM 园丁(复用蒸馏模型路由):合并重复、晋升 stable、废弃过时、刷新元数据;off 关闭 |
40
+ | `deprecatedTtlDays` | `15` | deprecated 条目超过 N 天在会话启动时自动删除(本地规则不依赖模型,逐条 git commit 可回溯找回);0 关闭清扫 |
41
+ | `usageBoost` | `0.15` | 使用加成(ADR 0015):近 30 天被注入命中/点开过的 Topic 检索加分(计入门槛分,帽 0.2,零词面相关不加,结构门不豁免);`0` 关闭 |
39
42
  | `pushDebounceSeconds` | `45` | GitHub 模式去抖推送间隔 |
40
43
 
41
44
  - 全部键都可用 `/topics set <键> <值>` 运行时写回同段(即时校验:boolean 用 on|off、
@@ -67,6 +70,7 @@ GitHub 模式凭据走 `$GITHUB_TOKEN` 或已登录的 gh CLI(`gh auth status`
67
70
  | `/topics onboard` | 交互式配置向导(ask-user 面板,末步确认才写入) |
68
71
  | `/topics status` | bundle 健康:topic 数、观察积压、冲突、最近蒸馏、同步状态 |
69
72
  | `/topics distill` | 手动触发一次蒸馏 run(复用现有 lane 与 in-flight 守卫) |
73
+ | `/topics consolidate` | 手动触发一次整理 run:LLM 园丁合并重复/晋升/废弃/刷新元数据,逐条 git commit 可回滚 |
70
74
  | `/topics stats` | 注入统计:hit rate、Top-N、near-miss 分布与调参建议 |
71
75
  | `/topics list` | 表格列出全部 Topic(带序号、最新优先、100 条封顶) |
72
76
  | `/topics show <slug>` | Topic 全文阅读页 + 反向引用(谁引用了我、怎么引用) |
@@ -83,9 +87,14 @@ GitHub 模式凭据走 `$GITHUB_TOKEN` 或已登录的 gh CLI(`gh auth status`
83
87
  2. **蒸馏没跑**:distill-provider 与 distill-model 必须**同时非空**(缺一即闲置);长会话看
84
88
  distill-every-turns 节拍、会话结束看 distill-on-session-end;`/topics distill` 手动触发
85
89
  会给可读原因(no-model / in-flight / no-observations)。
86
- 3. **GitHub 同步**:repo 须形如 owner/name;推送有 push-debounce-seconds 去抖;退出只做
90
+ 3. **整理 lane**:`consolidate-cadence`(daily/3d/7d,默认 daily,off 关闭)到期后在会话启动
91
+ 时后台跑,复用蒸馏模型路由(未配蒸馏模型则闲置);本地词面聚类只把「长得像」的 topic
92
+ 送模型,四类动作(merge/promote/deprecate/refresh)都逐条 git commit,`/topics consolidate`
93
+ 手动立即跑,`/topics status` 看最近整理,不满意 `git revert` 即回滚。被合并条目标
94
+ deprecated 且不再参与注入。
95
+ 4. **GitHub 同步**:repo 须形如 owner/name;推送有 push-debounce-seconds 去抖;退出只做
87
96
  本地 commit 不等网络,推迟的 push 由下次启动 pull 后补推;rebase 冲突的 topic 降权标记,
88
97
  `/topics status` 可查。
89
- 4. **旧版 llmwiki 段自动迁移**:0.5.x 的 `llmwiki:` 设置段与 `~/.dsh/llmwiki` 数据目录在
98
+ 5. **旧版 llmwiki 段自动迁移**:0.5.x 的 `llmwiki:` 设置段与 `~/.dsh/llmwiki` 数据目录在
90
99
  首次启动时自动迁移到 `topics` / `~/.dsh/topics`,无需手工;旧插件还在运行时会警告跳过
91
100
  (防双载脑裂),先从 profile 移除旧包。
package/src/commands.ts CHANGED
@@ -12,6 +12,7 @@ import { join } from 'node:path'
12
12
  import { spawn } from 'node:child_process'
13
13
  import type { TopicsService } from './service.ts'
14
14
  import type { DistillResult } from './distill.ts'
15
+ import type { ConsolidateResult } from './consolidate.ts'
15
16
  import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
16
17
  import { buildGraph, renderGraphHtml } from './viz.ts'
17
18
  import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
@@ -37,11 +38,19 @@ import {
37
38
  */
38
39
  export type ManualDistill = (invocation: CommandInvocation) => Promise<DistillResult>
39
40
 
41
+ /**
42
+ * Manual `/topics consolidate` trigger, wired by the host (index.ts owns the
43
+ * lane instance). Bypasses the cadence gate; the lane's own in-flight guard
44
+ * still applies.
45
+ */
46
+ export type ManualConsolidate = (invocation: CommandInvocation) => Promise<ConsolidateResult>
47
+
40
48
  export const HELP = [
41
49
  'dsh-topics-memory — OKF topic 记忆(本地 bundle,git 可追溯,可选 GitHub 同步)',
42
50
  ' /topics onboard 交互式配置向导(ask-user 面板逐项问答;无 UI 环境逐条输入)',
43
51
  ' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
44
52
  ' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
53
+ ' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
45
54
  ' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
46
55
  ' /topics list 列出全部 Topic',
47
56
  ' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
@@ -55,17 +64,17 @@ export const HELP = [
55
64
  '凭据:$GITHUB_TOKEN 或已登录的 gh CLI;登录不在本插件职责内。',
56
65
  ].join('\n')
57
66
 
58
- export function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk: AskServiceResolver = () => undefined, resolveLlm: LlmDirectoryResolver = () => [], distillNow?: ManualDistill): CommandDefinition {
67
+ export function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk: AskServiceResolver = () => undefined, resolveLlm: LlmDirectoryResolver = () => [], distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): CommandDefinition {
59
68
  const onboard = createOnboardHandler(service, mutate, undefined, resolveAsk, resolveLlm)
60
69
  return {
61
70
  name: 'topics',
62
- description: 'OKF topic 记忆:onboard | distill | status | stats | list | show | history | graph | sync | config | set',
63
- input: { hint: '[onboard | distill | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
64
- handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow),
71
+ description: 'OKF topic 记忆:onboard | distill | consolidate | status | stats | list | show | history | graph | sync | config | set',
72
+ input: { hint: '[onboard | distill | consolidate | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
73
+ handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow, consolidateNow),
65
74
  }
66
75
  }
67
76
 
68
- async function handle(invocation: CommandInvocation, service: TopicsService, mutate: MutateFn, onboard: (args: string[], invocation: CommandInvocation) => Promise<CommandResult>, resolveAsk: AskServiceResolver, resolveLlm: LlmDirectoryResolver, distillNow?: ManualDistill): Promise<CommandResult> {
77
+ async function handle(invocation: CommandInvocation, service: TopicsService, mutate: MutateFn, onboard: (args: string[], invocation: CommandInvocation) => Promise<CommandResult>, resolveAsk: AskServiceResolver, resolveLlm: LlmDirectoryResolver, distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): Promise<CommandResult> {
69
78
  const raw = invocation.rawInput.trim()
70
79
  const [action = '', ...rest] = raw.split(/\s+/)
71
80
  try {
@@ -78,6 +87,8 @@ async function handle(invocation: CommandInvocation, service: TopicsService, mut
78
87
  return ok(await renderStatus(service))
79
88
  case 'distill':
80
89
  return await doDistill(service, invocation, distillNow)
90
+ case 'consolidate':
91
+ return await doConsolidate(service, invocation, consolidateNow)
81
92
  case 'stats':
82
93
  return ok(await renderStats(service))
83
94
  case 'list':
@@ -125,6 +136,7 @@ async function renderStatus(service: TopicsService): Promise<string> {
125
136
  ['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
126
137
  ['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
127
138
  ['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
139
+ ['整理', cadenceLabel(cfg.consolidateCadence) + (cfg.consolidateCadence === 'off' ? '' : `(复用蒸馏模型,/topics consolidate 立即跑)`)],
128
140
  ]
129
141
  // Last lane outcome (distill-state summary) — what "checkable via
130
142
  // /topics status" promises; absent until the first run of this bundle.
@@ -137,6 +149,20 @@ async function renderStatus(service: TopicsService): Promise<string> {
137
149
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
138
150
  rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
139
151
  }
152
+ const lastConsolidate = await service.store.readConsolidateState()
153
+ if (lastConsolidate !== undefined) {
154
+ const count = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
155
+ const counts = [
156
+ `合并 ${count(lastConsolidate.merged)}`,
157
+ `晋升 ${count(lastConsolidate.promoted)}`,
158
+ `废弃 ${count(lastConsolidate.deprecated)}`,
159
+ `刷新 ${count(lastConsolidate.refreshed)}`,
160
+ ].join('/')
161
+ rows.push([
162
+ '最近整理',
163
+ `${counts} @ ${cell(String(lastConsolidate.at ?? '').slice(0, 19).replace('T', ' '))}${lastConsolidate.ok === false && lastConsolidate.detail ? `(${String(lastConsolidate.detail).slice(0, 80)})` : ''}`,
164
+ ])
165
+ }
140
166
  if (service.sync !== undefined) {
141
167
  rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))])
142
168
  if (service.sync.lastError !== '') rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')])
@@ -156,6 +182,59 @@ async function renderStatus(service: TopicsService): Promise<string> {
156
182
  * summary mirrors the distill state fields (ok / marked / created / updated /
157
183
  * gc dropped / reason) so what the user sees is what the state file records.
158
184
  */
185
+ /** Human-readable cadence for status/consolidate output. */
186
+ function cadenceLabel(value: string): string {
187
+ switch (value) {
188
+ case 'daily':
189
+ return '每天'
190
+ case '3d':
191
+ return '每 3 天'
192
+ case '7d':
193
+ return '每 7 天'
194
+ default:
195
+ return '关'
196
+ }
197
+ }
198
+
199
+ /**
200
+ * `/topics consolidate` — one manual run of the consolidation lane (LLM
201
+ * gardener over the existing pool). Bypasses the cadence gate; the lane's
202
+ * own in-flight guard dedups a concurrent automatic run. Output mirrors the
203
+ * lane's actions with per-op reasons so the user can eyeball (and git-revert)
204
+ * what the model decided.
205
+ */
206
+ async function doConsolidate(service: TopicsService, invocation: CommandInvocation, consolidateNow: ManualConsolidate | undefined): Promise<CommandResult> {
207
+ if (consolidateNow === undefined) return fail('整理 lane 未接线(宿主未提供手动整理触发器)')
208
+ const cfg = service.cfg
209
+ if (cfg.distillProvider === '' || cfg.distillModel === '') {
210
+ return fail('整理复用蒸馏模型路由:请先 /topics set distill-provider 与 distill-model。')
211
+ }
212
+ const result = await consolidateNow(invocation)
213
+ if (!result.ok) {
214
+ const reasonNote =
215
+ result.reason === 'no-clusters'
216
+ ? '没有找到可能相近的 topic 候选簇(词面上没有疑似重复/相关对)'
217
+ : result.reason === 'no-model'
218
+ ? '蒸馏模型未配置(/topics set distill-provider / distill-model)'
219
+ : result.reason === 'in-flight'
220
+ ? '已有整理在跑,稍后再试'
221
+ : (result.reason ?? 'unknown')
222
+ return fail(`整理未执行:${reasonNote}${result.detail !== undefined ? `\n ${result.detail}` : ''}`)
223
+ }
224
+ const lines: string[] = []
225
+ const actions = result.actions ?? []
226
+ const KIND_LABEL: Record<string, string> = { merge: '合并', promote: '晋升', deprecate: '废弃', refresh: '刷新' }
227
+ for (const a of actions) {
228
+ if (a.kind === 'merge' && a.merged !== undefined) {
229
+ lines.push(`- 合并 ${a.merged.map((m) => cell(m)).join('、')} → ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`)
230
+ } else {
231
+ lines.push(`- ${KIND_LABEL[a.kind] ?? a.kind} ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`)
232
+ }
233
+ }
234
+ const header = `✅ 整理完成:合并 ${result.merged.length} 组;晋升 ${result.promoted.length};废弃 ${result.deprecated.length};刷新 ${result.refreshed.length}${result.droppedOps ? `(丢弃 ${result.droppedOps} 个无效 op)` : ''}${result.detail !== undefined ? `\n ${result.detail}` : ''}`
235
+ return ok(lines.length === 0 ? header : `${header}\n${lines.join('\n')}\n全部变更已逐条 git commit,可 /topics history <slug> 追溯、git revert 回滚。`)
236
+ }
237
+
159
238
  async function doDistill(service: TopicsService, invocation: CommandInvocation, distillNow: ManualDistill | undefined): Promise<CommandResult> {
160
239
  if (distillNow === undefined) return fail('蒸馏 lane 未接线(宿主未提供手动蒸馏触发器)')
161
240
  const pending = await service.store.undistilledObservations(1)