@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/README.md +5 -1
- package/README.zh.md +5 -1
- package/lib/commands.d.ts +8 -1
- package/lib/commands.js +75 -5
- package/lib/config.d.ts +32 -1
- package/lib/config.js +25 -1
- package/lib/consolidate.d.ts +138 -0
- package/lib/consolidate.js +571 -0
- package/lib/ilog.d.ts +18 -0
- package/lib/ilog.js +47 -0
- package/lib/index.js +58 -2
- package/lib/observer.js +6 -4
- package/lib/retrieval.d.ts +17 -1
- package/lib/retrieval.js +16 -0
- package/lib/service.d.ts +10 -1
- package/lib/service.js +78 -1
- package/lib/store.d.ts +11 -0
- package/lib/store.js +35 -1
- package/package.json +24 -24
- package/skills/dsh-topics-memory-config/SKILL.md +12 -3
- package/src/commands.ts +84 -5
- package/src/config.ts +27 -1
- package/src/consolidate.ts +639 -0
- package/src/ilog.ts +53 -0
- package/src/index.ts +56 -2
- package/src/observer.ts +6 -3
- package/src/retrieval.ts +28 -1
- package/src/service.ts +86 -1
- package/src/store.ts +38 -2
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
|
|
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
|
-
|
|
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/
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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,
|