@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.
- package/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- 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
|
+
}
|
package/src/retrieval.ts
ADDED
|
@@ -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
|
+
}
|