@aiwayds/dsh-topics-memory 0.6.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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. package/src/yaml.ts +322 -0
package/src/service.ts ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * TopicsService — the facade shared by tools, commands, the injection seam,
3
+ * and the distill lane. Owns the roster cache (mtime-keyed, pi-topic-memory
4
+ * lesson #3: never re-parse unchanged files on the hot path).
5
+ *
6
+ * @module service
7
+ */
8
+
9
+ import { readFile, stat } from 'node:fs/promises'
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import * as okf from './okf.ts'
13
+ import { searchTopics, tokenize, type RetrievableTopic, type SearchOutcome } from './retrieval.ts'
14
+ import { assembleInjection, type AssembleResult, type DigestInput } from './digest.ts'
15
+ import type { BundleStore, Observation, SaveResult } from './store.ts'
16
+ import type { TopicsConfigValue } from './config.ts'
17
+ import { aggregateStats, querySample, type AggregateStats, type InjectionRecord } from './ilog.ts'
18
+ import { fileHistory, fileAtRev } from './git.ts'
19
+ import type { Sync } from './sync.ts'
20
+
21
+ interface CacheEntry {
22
+ mtimeMs: number
23
+ size: number
24
+ doc: okf.TopicDoc
25
+ slug: string
26
+ }
27
+
28
+ export interface RetrieveOutcome {
29
+ outcome: SearchOutcome
30
+ injection: AssembleResult
31
+ /** Roster was empty or injection disabled — no record written for pure no-ops. */
32
+ recorded: boolean
33
+ }
34
+
35
+ export class TopicsService {
36
+ private cache = new Map<string, CacheEntry>()
37
+ readonly store: BundleStore
38
+ private readonly getConfig: () => TopicsConfigValue
39
+ readonly sync?: Sync
40
+
41
+ constructor(store: BundleStore, getConfig: () => TopicsConfigValue, sync?: Sync) {
42
+ this.store = store
43
+ this.getConfig = getConfig
44
+ this.sync = sync
45
+ }
46
+
47
+ get cfg(): TopicsConfigValue {
48
+ return this.getConfig()
49
+ }
50
+
51
+ get githubMode(): boolean {
52
+ return this.cfg.repo !== ''
53
+ }
54
+
55
+ /** All retrievable topics with conclusion text, mtime-cached. */
56
+ async roster(): Promise<RetrievableTopic[]> {
57
+ let files: string[]
58
+ try {
59
+ files = (await this.store.listTopics()).map((m) => `${m.slug}.md`)
60
+ } catch {
61
+ return []
62
+ }
63
+ const out: RetrievableTopic[] = []
64
+ for (const file of files) {
65
+ const path = join(this.store.topicsDir(), file)
66
+ let st
67
+ try {
68
+ st = await stat(path)
69
+ } catch {
70
+ continue
71
+ }
72
+ const cached = this.cache.get(file)
73
+ let entry = cached
74
+ if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
75
+ try {
76
+ const doc = okf.parseTopicDoc(await readFile(path, 'utf8'))
77
+ entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) }
78
+ this.cache.set(file, entry)
79
+ } catch {
80
+ this.cache.delete(file)
81
+ continue
82
+ }
83
+ }
84
+ if (entry === undefined) continue
85
+ const doc = entry.doc
86
+ out.push({
87
+ slug: entry.slug,
88
+ title: doc.fm.title,
89
+ description: doc.fm.description,
90
+ status: doc.fm.status,
91
+ tags: doc.fm.tags,
92
+ depends: doc.fm.depends,
93
+ links: okf.bodyLinkSlugs(doc.body),
94
+ generatedAt: doc.fm.generated.at,
95
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
96
+ })
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Drop the roster cache (tests, bulk external edits). */
102
+ invalidate(): void {
103
+ this.cache.clear()
104
+ }
105
+
106
+ /**
107
+ * Full retrieval round: search → assemble → log (ADR 0006/0007). The
108
+ * injection record is written even for zero-hit rounds — the hit-rate
109
+ * denominator must count every round (near-miss evidence).
110
+ */
111
+ async retrieve(query: string, sessionId?: string): Promise<RetrieveOutcome> {
112
+ const cfg = this.cfg
113
+ const roster = await this.roster()
114
+ if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
115
+ return {
116
+ outcome: { hits: [], nearMisses: [], rosterSize: roster.length },
117
+ injection: { text: '', usedTokens: 0, included: [], dropped: [] },
118
+ recorded: false,
119
+ }
120
+ }
121
+ const conflicts = await this.store.getConflicts()
122
+ const outcome = searchTopics(query, roster, {
123
+ threshold: cfg.matchThreshold,
124
+ topK: cfg.topK,
125
+ tagBoost: cfg.tagBoost,
126
+ graphDepth: cfg.graphDepth,
127
+ recencyWindowDays: cfg.recencyWindowDays,
128
+ conflicts,
129
+ })
130
+ const bySlug = new Map(roster.map((r) => [r.slug, r]))
131
+ const entries: DigestInput[] = []
132
+ for (const hit of outcome.hits) {
133
+ const topic = bySlug.get(hit.slug)
134
+ if (topic === undefined) continue
135
+ const doc = await this.readDoc(hit.slug)
136
+ if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
137
+ }
138
+ const injection = assembleInjection(entries, {
139
+ perTopicBudget: cfg.perTopicBudget,
140
+ totalBudget: cfg.totalBudget,
141
+ })
142
+ const record: InjectionRecord = {
143
+ at: new Date().toISOString(),
144
+ queryTokenCount: tokenize(query).length,
145
+ querySample: querySample(query),
146
+ rosterSize: outcome.rosterSize,
147
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
148
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
149
+ injected: injection.text !== '',
150
+ usedTokens: injection.usedTokens,
151
+ }
152
+ if (sessionId !== undefined) record.sessionId = sessionId
153
+ if (injection.text === '' && outcome.hits.length > 0) record.why = 'below-budget-or-dropped'
154
+ if (inclusionDropped(injection)) record.dropped = injection.dropped
155
+ await this.store.appendInjectionRecord(record)
156
+ return { outcome, injection, recorded: true }
157
+ }
158
+
159
+ /**
160
+ * SYNCHRONOUS hot path for same-turn injection (the chancelu lesson):
161
+ * `agent/inbox/spliced` dispatches before prompt assembly and the context
162
+ * provider reads the assembled text synchronously — an async retrieval
163
+ * would always lose the race and inject nothing. Reads files with sync fs
164
+ * behind the mtime cache; the log record is written fire-and-forget.
165
+ *
166
+ * `dedup.exclude` (session-level injection dedup) filters hits AFTER
167
+ * retrieval and BEFORE assembly: excluded slugs are reported back as
168
+ * `deduped` (and logged as `record.deduped`), never packed. `included`
169
+ * mirrors what actually entered the context — only these may be marked
170
+ * as injected by the caller; budget-dropped slugs stay injectable later.
171
+ */
172
+ retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[] } {
173
+ const cfg = this.cfg
174
+ const roster = this.rosterSync()
175
+ const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
176
+ if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
177
+ return { text: '', outcome: empty, deduped: [], included: [] }
178
+ }
179
+ const conflicts = this.store.getConflictsSync()
180
+ const outcome = searchTopics(query, roster, {
181
+ threshold: cfg.matchThreshold,
182
+ topK: cfg.topK,
183
+ tagBoost: cfg.tagBoost,
184
+ graphDepth: cfg.graphDepth,
185
+ recencyWindowDays: cfg.recencyWindowDays,
186
+ conflicts,
187
+ })
188
+ const exclude = dedup?.exclude
189
+ const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
190
+ const entries: DigestInput[] = []
191
+ for (const hit of outcome.hits) {
192
+ if (exclude?.has(hit.slug)) continue
193
+ const doc = this.readDocSync(hit.slug)
194
+ if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
195
+ }
196
+ const injection = assembleInjection(entries, {
197
+ perTopicBudget: cfg.perTopicBudget,
198
+ totalBudget: cfg.totalBudget,
199
+ })
200
+ const record: InjectionRecord = {
201
+ at: new Date().toISOString(),
202
+ queryTokenCount: tokenize(query).length,
203
+ querySample: querySample(query),
204
+ rosterSize: outcome.rosterSize,
205
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
206
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
207
+ injected: injection.text !== '',
208
+ usedTokens: injection.usedTokens,
209
+ }
210
+ if (sessionId !== undefined) record.sessionId = sessionId
211
+ if (deduped.length > 0) record.deduped = deduped
212
+ if (injection.text === '' && outcome.hits.length > 0) {
213
+ // All hits deduped is a different story than a budget squeeze — say so.
214
+ record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped'
215
+ }
216
+ if (injection.dropped.length > 0) record.dropped = injection.dropped
217
+ void this.store.appendInjectionRecord(record).catch(() => undefined)
218
+ return { text: injection.text, outcome, deduped, included: injection.included }
219
+ }
220
+
221
+ /** Sync roster read (same mtime cache as roster()). */
222
+ rosterSync(): RetrievableTopic[] {
223
+ let files: string[]
224
+ try {
225
+ files = readdirSync(this.store.topicsDir())
226
+ } catch {
227
+ return []
228
+ }
229
+ const out: RetrievableTopic[] = []
230
+ for (const file of files) {
231
+ if (!file.endsWith('.md') || file === 'index.md') continue
232
+ const path = join(this.store.topicsDir(), file)
233
+ let st
234
+ try {
235
+ st = statSync(path)
236
+ } catch {
237
+ continue
238
+ }
239
+ const cached = this.cache.get(file)
240
+ let entry = cached
241
+ if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
242
+ try {
243
+ const doc = okf.parseTopicDoc(readFileSync(path, 'utf8'))
244
+ entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) }
245
+ this.cache.set(file, entry)
246
+ } catch {
247
+ this.cache.delete(file)
248
+ continue
249
+ }
250
+ }
251
+ if (entry === undefined) continue
252
+ const doc = entry.doc
253
+ out.push({
254
+ slug: entry.slug,
255
+ title: doc.fm.title,
256
+ description: doc.fm.description,
257
+ status: doc.fm.status,
258
+ tags: doc.fm.tags,
259
+ depends: doc.fm.depends,
260
+ links: okf.bodyLinkSlugs(doc.body),
261
+ generatedAt: doc.fm.generated.at,
262
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
263
+ })
264
+ }
265
+ return out
266
+ }
267
+
268
+ private readDocSync(slug: string): okf.TopicDoc | undefined {
269
+ try {
270
+ return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'))
271
+ } catch {
272
+ return undefined
273
+ }
274
+ }
275
+
276
+ private async readDoc(slug: string): Promise<okf.TopicDoc | undefined> {
277
+ try {
278
+ return await this.store.readTopic(slug)
279
+ } catch {
280
+ return undefined
281
+ }
282
+ }
283
+
284
+ /** topic_save tool path: create or update with actor stamping. */
285
+ async saveTopic(input: {
286
+ title: string
287
+ description?: string
288
+ tags?: string[]
289
+ depends?: string[]
290
+ openQuestions?: string[]
291
+ impact?: string[]
292
+ status?: okf.TopicStatus
293
+ conclusion?: string
294
+ recommendations?: string
295
+ source?: 'model' | 'distill'
296
+ slug?: string
297
+ }): Promise<SaveResult & { slug: string }> {
298
+ const now = new Date().toISOString()
299
+ const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined
300
+ let body = existing?.body ?? ''
301
+ if (input.conclusion !== undefined && input.conclusion !== '') {
302
+ body = okf.setSection(body, okf.CONCLUSION_HEADING, input.conclusion)
303
+ } else if (existing === undefined) {
304
+ body = okf.setSection(body, okf.CONCLUSION_HEADING, input.description ?? input.title)
305
+ }
306
+ if (input.recommendations !== undefined && input.recommendations !== '') {
307
+ body = okf.setSection(body, okf.RECOMMENDATIONS_HEADING, input.recommendations)
308
+ }
309
+ const baseSlug = input.slug ?? okf.slugify(input.title)
310
+ const slug = existing === undefined ? await this.store.uniqueSlug(baseSlug) : okf.slugify(input.slug ?? baseSlug)
311
+ // store.saveTopic stamps generated {by, at} itself (write-through actor).
312
+ const fm: okf.TopicFrontmatter = {
313
+ type: 'Topic',
314
+ title: input.title,
315
+ tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
316
+ depends: (input.depends ?? existing?.fm.depends ?? []).map(okf.slugToPath),
317
+ open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
318
+ impact: input.impact ?? existing?.fm.impact ?? [],
319
+ status: input.status ?? existing?.fm.status ?? 'draft',
320
+ generated: existing?.fm.generated ?? { by: 'pending', at: now },
321
+ }
322
+ if (input.description !== undefined && input.description !== '') fm.description = input.description
323
+ if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
324
+ const result = await this.store.saveTopic(
325
+ { slug, doc: { fm, body } },
326
+ { message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined },
327
+ )
328
+ this.invalidate()
329
+ void this.sync?.schedulePush()
330
+ return { ...result, slug: result.slug }
331
+ }
332
+
333
+ async observe(input: { kind: Observation['kind']; text: string; sessionId?: string; source?: 'model' | 'auto' }): Promise<Observation> {
334
+ return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId })
335
+ }
336
+
337
+ async search(query: string, topK?: number): Promise<SearchOutcome> {
338
+ const cfg = this.cfg
339
+ return searchTopics(query, await this.roster(), {
340
+ threshold: cfg.matchThreshold,
341
+ topK: topK ?? Math.max(cfg.topK, 8),
342
+ tagBoost: cfg.tagBoost,
343
+ graphDepth: cfg.graphDepth,
344
+ recencyWindowDays: cfg.recencyWindowDays,
345
+ conflicts: await this.store.getConflicts(),
346
+ })
347
+ }
348
+
349
+ async history(slug: string, limit = 20): Promise<{ entries: { hash: string; date: string; message: string; conclusion?: string }[] }> {
350
+ if (!(await this.store.hasGit())) return { entries: [] }
351
+ const path = `topics/${okf.slugify(slug)}.md`
352
+ const log = await fileHistory(this.store.root, path, limit)
353
+ const entries: { hash: string; date: string; message: string; conclusion?: string }[] = []
354
+ for (const e of log) {
355
+ const raw = await fileAtRev(this.store.root, path, e.hash)
356
+ let conclusion: string | undefined
357
+ if (raw !== undefined) {
358
+ try {
359
+ conclusion = firstLine(okf.sectionOf(okf.parseTopicDoc(raw).body, okf.CONCLUSION_HEADING) ?? '')
360
+ } catch {
361
+ conclusion = undefined
362
+ }
363
+ }
364
+ entries.push({ hash: e.hash.slice(0, 10), date: e.date, message: e.message, conclusion })
365
+ }
366
+ return { entries }
367
+ }
368
+
369
+ async stats(): Promise<AggregateStats> {
370
+ return aggregateStats(await this.store.readInjectionRecords())
371
+ }
372
+ }
373
+
374
+ function inclusionDropped(injection: AssembleResult): boolean {
375
+ return injection.dropped.length > 0
376
+ }
377
+
378
+ function firstLine(text: string): string {
379
+ return firstParagraphOf(text)
380
+ }
381
+
382
+ function firstParagraphOf(text: string): string {
383
+ for (const line of text.split('\n')) {
384
+ const t = line.trim()
385
+ if (t !== '') return t
386
+ }
387
+ return ''
388
+ }
389
+
390
+ function dedupeLower(tags: string[]): string[] {
391
+ const seen = new Set<string>()
392
+ const out: string[] = []
393
+ for (const t of tags) {
394
+ const key = t.trim().toLowerCase()
395
+ if (key === '' || seen.has(key)) continue
396
+ seen.add(key)
397
+ out.push(key)
398
+ }
399
+ return out
400
+ }