@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/paths.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Path resolution for the local bundle (ADR 0008): the Cache lives at
3
+ * `~/.dsh/topics/` (override with $DSH_TOPICS_HOME for tests), laid out as
4
+ * an OKF bundle:
5
+ *
6
+ * <root>/topics/<slug>.md concept documents
7
+ * <root>/index.md auto-generated bundle index
8
+ * <root>/meta/observations.jsonl undistilled observations (M2)
9
+ * <root>/meta/injections.jsonl injection log (ADR 0007)
10
+ * <root>/meta/conflicts.json rebase-conflicted topics (ADR 0003)
11
+ *
12
+ * @module paths
13
+ */
14
+
15
+ import { existsSync, renameSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { hostname as osHostname } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ export function resolveDshHome(): string {
21
+ const env = process.env.DSH_HOME?.trim()
22
+ if (env !== undefined && env !== '') return env
23
+ return join(homedir(), '.dsh')
24
+ }
25
+
26
+ export function resolveBundleRoot(): string {
27
+ const env = process.env.DSH_TOPICS_HOME?.trim()
28
+ if (env !== undefined && env !== '') return env
29
+ const dshHome = resolveDshHome()
30
+ return migrateLegacyBundleRoot(join(dshHome, 'topics'), join(dshHome, 'llmwiki'))
31
+ }
32
+
33
+ /**
34
+ * Minimal fs surface, injectable so tests can hit the rename-race catch
35
+ * deterministically (a real-fs test cannot mutate the world between the
36
+ * guard checks and the rename). Defaults to the real node:fs functions —
37
+ * production callers never pass it.
38
+ */
39
+ interface FsLike {
40
+ existsSync(path: string): boolean
41
+ renameSync(from: string, to: string): void
42
+ }
43
+
44
+ /**
45
+ * One-time migration from the pre-rename data location (`~/.dsh/llmwiki` to
46
+ * `~/.dsh/topics`, ADR 0013). Constraints:
47
+ * - one-time: fires only while the new directory is absent and the legacy one
48
+ * exists; after the rename (or a fresh bundle on the new path) the check is
49
+ * a no-op;
50
+ * - fail-open: any rename error falls back to the legacy path so the plugin
51
+ * keeps serving the old bundle instead of booting an empty new one;
52
+ * - bypassed entirely by an explicit $DSH_TOPICS_HOME (test/CI isolation).
53
+ */
54
+ export function migrateLegacyBundleRoot(next: string, legacy: string, fs: FsLike = { existsSync, renameSync }): string {
55
+ if (fs.existsSync(next) || !fs.existsSync(legacy)) return next
56
+ try {
57
+ fs.renameSync(legacy, next)
58
+ return next
59
+ } catch {
60
+ // A concurrent boot may have won the rename: re-check the live filesystem
61
+ // instead of trusting the stale pre-rename view — falling back to `legacy`
62
+ // after it was moved away would strand this session on a dead path (empty
63
+ // bundle, writes into an orphan directory nothing will ever read).
64
+ return fs.existsSync(next) ? next : legacy
65
+ }
66
+ }
67
+
68
+ export function topicsDir(root: string): string {
69
+ return join(root, 'topics')
70
+ }
71
+
72
+ export function metaDir(root: string): string {
73
+ return join(root, 'meta')
74
+ }
75
+
76
+ export function topicFile(root: string, slug: string): string {
77
+ return join(root, 'topics', `${slug}.md`)
78
+ }
79
+
80
+ export function indexFile(root: string): string {
81
+ return join(root, 'index.md')
82
+ }
83
+
84
+ export function observationsFile(root: string): string {
85
+ return join(metaDir(root), 'observations.jsonl')
86
+ }
87
+
88
+ export function injectionsFile(root: string): string {
89
+ return join(metaDir(root), 'injections.jsonl')
90
+ }
91
+
92
+ export function conflictsFile(root: string): string {
93
+ return join(metaDir(root), 'conflicts.json')
94
+ }
95
+
96
+ /** Sanitized host name recorded in `generated.by` for multi-machine provenance. */
97
+ export function hostId(): string {
98
+ return osHostname().replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '') || 'unknown-host'
99
+ }
100
+
101
+ /** The actor string stamped into `generated.by` for machine-written topics. */
102
+ export function actorFor(): string {
103
+ return `agent:dsh-topics-memory@${hostId()}`
104
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Hot-path retrieval — LLM-free, millisecond-scale (ADR 0004).
3
+ *
4
+ * Lineage: pi-topic-memory's tokenizer + Dice matching, adapted to the Topic
5
+ * profile. Query tokens are matched against title/tags/slug/description/
6
+ * conclusion via containment (|query ∩ field| / |query|), so long topics are
7
+ * not penalized the way plain Dice dilutes them. Tag hits add a bounded
8
+ * boost, recent topics a small recency bonus, and the `depends` graph expands
9
+ * seeds to adjacent topics with geometric decay. Conflicted topics are
10
+ * demoted, never dropped (ADR 0003).
11
+ *
12
+ * @module retrieval
13
+ */
14
+
15
+ import type { TopicMeta } from './store.ts'
16
+ import { dependsSlugs } from './okf.ts'
17
+
18
+ export interface RetrievableTopic extends TopicMeta {
19
+ /** `# Conclusion` section text (may be empty). */
20
+ conclusion: string
21
+ /** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
22
+ links?: string[]
23
+ }
24
+
25
+ export interface RetrievalConfig {
26
+ threshold: number
27
+ topK: number
28
+ tagBoost: number
29
+ graphDepth: number
30
+ recencyWindowDays: number
31
+ now?: Date
32
+ /** Slugs currently conflicted — demoted, not dropped. */
33
+ conflicts?: ReadonlySet<string>
34
+ }
35
+
36
+ export const DEFAULT_RETRIEVAL: RetrievalConfig = {
37
+ threshold: 0.3,
38
+ topK: 4,
39
+ tagBoost: 0.15,
40
+ graphDepth: 2,
41
+ recencyWindowDays: 7,
42
+ }
43
+
44
+ export interface SearchHit {
45
+ slug: string
46
+ score: number
47
+ reasons: string[]
48
+ viaGraph: boolean
49
+ }
50
+
51
+ export interface SearchOutcome {
52
+ hits: SearchHit[]
53
+ /** Below threshold but close — the tuning evidence for /topics stats (ADR 0007). */
54
+ nearMisses: SearchHit[]
55
+ rosterSize: number
56
+ }
57
+
58
+ const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu
59
+ const WORD_RUN = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}][\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}+#.-]*/gu
60
+
61
+ /**
62
+ * Tokenize for matching: latin/digit words (≥2 chars) plus CJK character
63
+ * bigrams (single char kept when the run has length 1).
64
+ */
65
+ export function tokenize(text: string): string[] {
66
+ const out = new Set<string>()
67
+ for (const w of text.toLowerCase().match(WORD_RUN) ?? []) {
68
+ if (w.length >= 2) out.add(w)
69
+ }
70
+ for (const run of text.match(CJK_RUN) ?? []) {
71
+ if (run.length === 1) {
72
+ out.add(run)
73
+ continue
74
+ }
75
+ for (let i = 0; i < run.length - 1; i += 1) out.add(run.slice(i, i + 2))
76
+ }
77
+ return [...out]
78
+ }
79
+
80
+ function containment(query: ReadonlySet<string>, field: ReadonlySet<string>): number {
81
+ if (query.size === 0 || field.size === 0) return 0
82
+ let hits = 0
83
+ for (const t of query) if (field.has(t)) hits += 1
84
+ return hits / query.size
85
+ }
86
+
87
+ function overlap(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
88
+ for (const t of a) if (b.has(t)) return true
89
+ return false
90
+ }
91
+
92
+ export interface ScoredTopic {
93
+ slug: string
94
+ score: number
95
+ reasons: string[]
96
+ }
97
+
98
+ /** Score one topic against the query tokens. */
99
+ export function scoreTopic(
100
+ queryTokens: ReadonlySet<string>,
101
+ topic: RetrievableTopic,
102
+ cfg: RetrievalConfig,
103
+ ): ScoredTopic {
104
+ const title = new Set(tokenize(topic.title))
105
+ const slug = new Set(tokenize(topic.slug))
106
+ const tags = new Set(topic.tags.flatMap((t) => tokenize(t)))
107
+ const description = new Set(tokenize(topic.description ?? ''))
108
+ const conclusion = new Set(tokenize(topic.conclusion))
109
+ const reasons: string[] = []
110
+ let score = 0
111
+
112
+ const cTitle = containment(queryTokens, title)
113
+ if (cTitle > 0) {
114
+ score += 3 * cTitle
115
+ reasons.push(`title:${cTitle.toFixed(2)}`)
116
+ }
117
+ const cTag = containment(queryTokens, new Set([...tags, ...slug]))
118
+ if (cTag > 0) {
119
+ score += 2 * cTag
120
+ reasons.push(`tags:${cTag.toFixed(2)}`)
121
+ }
122
+ const cDesc = containment(queryTokens, description)
123
+ if (cDesc > 0) {
124
+ score += 1.2 * cDesc
125
+ reasons.push(`description:${cDesc.toFixed(2)}`)
126
+ }
127
+ const cConc = containment(queryTokens, conclusion)
128
+ if (cConc > 0) {
129
+ score += 0.8 * cConc
130
+ reasons.push(`conclusion:${cConc.toFixed(2)}`)
131
+ }
132
+ // Exact tag hit boost, additive and capped (pi-topic-memory lesson: add,
133
+ // never fold into the token set — dilution breaks the guard).
134
+ let tagHits = 0
135
+ for (const t of tags) {
136
+ if (overlap(queryTokens, new Set([...tokenize(t)]))) tagHits += 1
137
+ }
138
+ if (tagHits > 0) {
139
+ const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost * 3)
140
+ score += boost
141
+ reasons.push(`tag-boost:+${boost.toFixed(2)}`)
142
+ }
143
+ // Recency is a tiebreaker among relevant topics, never a free pass: only
144
+ // fires when the query already matched some content above.
145
+ if (score > 0 && cfg.recencyWindowDays > 0) {
146
+ const generated = Date.parse(topic.generatedAt)
147
+ if (!Number.isNaN(generated)) {
148
+ const ageDays = ((cfg.now?.getTime() ?? Date.now()) - generated) / 86_400_000
149
+ if (ageDays >= 0 && ageDays <= cfg.recencyWindowDays) {
150
+ score += 0.2
151
+ reasons.push('recency')
152
+ }
153
+ }
154
+ }
155
+ if (cfg.conflicts?.has(topic.slug) === true) {
156
+ score *= 0.3
157
+ reasons.push('conflicted-demoted')
158
+ }
159
+ return { slug: topic.slug, score: Math.round(score * 1000) / 1000, reasons }
160
+ }
161
+
162
+ /**
163
+ * Search the roster: score every topic, split hits from near-misses, then
164
+ * expand seeds through the `depends` graph (both directions, geometric decay,
165
+ * depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
166
+ */
167
+ export function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg: Partial<RetrievalConfig> = {}): SearchOutcome {
168
+ const c: RetrievalConfig = { ...DEFAULT_RETRIEVAL, ...cfg }
169
+ const queryTokens = new Set(tokenize(query))
170
+ if (queryTokens.size === 0 || roster.length === 0) {
171
+ return { hits: [], nearMisses: [], rosterSize: roster.length }
172
+ }
173
+ const scored = roster.map((t) => scoreTopic(queryTokens, t, c)).sort((a, b) => b.score - a.score)
174
+ const direct = scored.filter((s) => s.score >= c.threshold).slice(0, c.topK)
175
+ const near = scored.filter((s) => s.score < c.threshold && s.score >= c.threshold * 0.5).slice(0, 8)
176
+
177
+ const hits: SearchHit[] = direct.map((s) => ({ ...s, viaGraph: false }))
178
+ // Graph expansion (ADR 0005 graph-shaped bundle): walk depends + body
179
+ // links, both directions, from the seeds; include up to topK+2 slots at
180
+ // decaying score.
181
+ if (c.graphDepth > 0 && hits.length > 0) {
182
+ const bySlug = new Map(roster.map((r) => [r.slug, r]))
183
+ const neighborsOf = (slug: string): string[] => {
184
+ const topic = bySlug.get(slug)
185
+ if (topic === undefined) return []
186
+ const out = new Set<string>(dependsSlugs({ depends: topic.depends }))
187
+ for (const l of topic.links ?? []) out.add(l)
188
+ return [...out]
189
+ }
190
+ const dependents = new Map<string, string[]>()
191
+ for (const r of roster) {
192
+ for (const dep of new Set([...dependsSlugs({ depends: r.depends }), ...(r.links ?? [])])) {
193
+ const list = dependents.get(dep) ?? []
194
+ list.push(r.slug)
195
+ dependents.set(dep, list)
196
+ }
197
+ }
198
+ const seen = new Set(hits.map((h) => h.slug))
199
+ let frontier = hits.map((h) => ({ slug: h.slug, score: h.score }))
200
+ for (let depth = 1; depth <= c.graphDepth && frontier.length > 0; depth += 1) {
201
+ const next: { slug: string; score: number }[] = []
202
+ for (const node of frontier) {
203
+ const neighbors = new Set<string>([...neighborsOf(node.slug), ...(dependents.get(node.slug) ?? [])])
204
+ for (const neighbor of neighbors) {
205
+ if (seen.has(neighbor)) continue
206
+ seen.add(neighbor)
207
+ const decayed = node.score * 0.5 ** depth
208
+ if (decayed >= c.threshold && hits.length < c.topK + 2) {
209
+ const already = hits.find((h) => h.slug === neighbor)
210
+ if (already === undefined) hits.push({ slug: neighbor, score: Math.round(decayed * 1000) / 1000, reasons: [`depends:d${depth}`], viaGraph: true })
211
+ next.push({ slug: neighbor, score: decayed })
212
+ }
213
+ }
214
+ }
215
+ frontier = next
216
+ }
217
+ }
218
+ return { hits, nearMisses: near.map((s) => ({ ...s, viaGraph: false })), rosterSize: roster.length }
219
+ }