@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
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Observer (M2, ADR 0004) — session-event watcher that feeds the two-stage
3
+ * pipeline:
4
+ *
5
+ * - per turn/end: capture user + assistant text as ONE raw auto-observation
6
+ * (cheap, no LLM, size-capped) so the distill lane has material even when
7
+ * the model never calls topic_observe;
8
+ * - every N turns: request a distill run (budget-friendly cadence);
9
+ * - session end (`agent/disposed` / `session/disposed` cordis events, wired
10
+ * in index.ts): final distill request. `session/end-seed` is the
11
+ * restore/resume boundary, NOT a session end — it resets the end-cycle
12
+ * marker instead of triggering.
13
+ *
14
+ * Never throws into the session loop — every hook failure is contained.
15
+ *
16
+ * @module observer
17
+ */
18
+
19
+ import type { TopicsService } from './service.ts'
20
+ import type { TopicsConfigValue } from './config.ts'
21
+
22
+ interface SessionState {
23
+ turnCount: number
24
+ userText: string
25
+ assistantText: string
26
+ capturing: boolean
27
+ }
28
+
29
+ export interface UserMessageLike {
30
+ source?: { kind?: string }
31
+ content: readonly { type: string; text?: string }[]
32
+ }
33
+
34
+ export function textOf(message: UserMessageLike): string {
35
+ // Deliberately text-only and explicit: since dsh 0.1.2-alpha.3 sub-agent
36
+ // follow-up messages may carry image (and other non-text) content blocks —
37
+ // they are skipped here, never dereferenced for `.text`.
38
+ return message.content
39
+ .filter((c): c is { type: 'text'; text: string } => c.type === 'text' && typeof c.text === 'string')
40
+ .map((c) => c.text)
41
+ .join('\n')
42
+ }
43
+
44
+ const MAX_CAPTURE = 4000
45
+
46
+ export class Observer {
47
+ private sessions = new Map<string, SessionState>()
48
+ /** Sessions whose end trigger already fired; cleared on the resume boundary. */
49
+ private ended = new Set<string>()
50
+ private readonly service: TopicsService
51
+ private readonly onRequestDistill: (sessionId: string, reason: 'every-n' | 'session-end') => void
52
+
53
+ constructor(service: TopicsService, onRequestDistill: (sessionId: string, reason: 'every-n' | 'session-end') => void) {
54
+ this.service = service
55
+ this.onRequestDistill = onRequestDistill
56
+ }
57
+
58
+ private stateFor(sessionId: string): SessionState {
59
+ let s = this.sessions.get(sessionId)
60
+ if (s === undefined) {
61
+ s = { turnCount: 0, userText: '', assistantText: '', capturing: false }
62
+ this.sessions.set(sessionId, s)
63
+ }
64
+ return s
65
+ }
66
+
67
+ /** Entry point wired to ctx.on('session/event', …). */
68
+ onSessionEvent(sessionId: string, eventType: string, data: unknown): void {
69
+ try {
70
+ this.handle(sessionId, eventType, data)
71
+ } catch {
72
+ // Contained by design: observation must never break the session loop.
73
+ }
74
+ }
75
+
76
+ private async handleAsync(sessionId: string, eventType: string, data: unknown): Promise<void> {
77
+ const cfg = this.service.cfg
78
+ const state = this.stateFor(sessionId)
79
+ switch (eventType) {
80
+ case 'user/message': {
81
+ const msg = data as UserMessageLike
82
+ if (msg?.source?.kind !== 'user') return
83
+ state.userText = textOf(msg)
84
+ state.assistantText = ''
85
+ return
86
+ }
87
+ case 'assistant/chunk': {
88
+ const chunk = (data as { chunk?: { type?: string; text?: string } }).chunk
89
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') state.assistantText += chunk.text
90
+ return
91
+ }
92
+ case 'turn/end': {
93
+ state.turnCount += 1
94
+ const user = state.userText.trim()
95
+ const assistant = state.assistantText.trim()
96
+ if (cfg.autoObserve && user !== '' && assistant !== '') {
97
+ await this.service.store.appendObservation({
98
+ kind: 'turn',
99
+ source: 'auto',
100
+ sessionId,
101
+ text: truncate([
102
+ `用户: ${truncateLine(user, cfg.observationMaxChars)}`,
103
+ `助手: ${truncateLine(assistant, cfg.observationMaxChars)}`,
104
+ ].join('\n'), MAX_CAPTURE),
105
+ })
106
+ }
107
+ state.userText = ''
108
+ state.assistantText = ''
109
+ if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
110
+ this.onRequestDistill(sessionId, 'every-n')
111
+ }
112
+ return
113
+ }
114
+ // Real session teardown: both cordis events fire at teardown (agent
115
+ // unregister then session store detach) — the trigger is single-fire per
116
+ // session so a double dispatch cannot clobber the first run's outcome.
117
+ case 'agent/disposed':
118
+ case 'session/disposed': {
119
+ if (cfg.distillOnSessionEnd && !this.ended.has(sessionId)) {
120
+ this.ended.add(sessionId)
121
+ this.onRequestDistill(sessionId, 'session-end')
122
+ }
123
+ this.sessions.delete(sessionId)
124
+ return
125
+ }
126
+ // Restore/resume boundary — a resumed session begins a fresh end cycle.
127
+ case 'session/end-seed': {
128
+ this.ended.delete(sessionId)
129
+ this.sessions.delete(sessionId)
130
+ return
131
+ }
132
+ default:
133
+ return
134
+ }
135
+ }
136
+
137
+ private handle(sessionId: string, eventType: string, data: unknown): void {
138
+ void this.handleAsync(sessionId, eventType, data).catch(() => undefined)
139
+ }
140
+ }
141
+
142
+ function truncateLine(text: string, max: number): string {
143
+ const one = text.replace(/\s+/g, ' ').trim()
144
+ const cap = Math.max(200, max)
145
+ return one.length <= cap ? one : `${one.slice(0, cap)}…`
146
+ }
147
+
148
+ function truncate(text: string, max: number): string {
149
+ return text.length <= max ? text : `${text.slice(0, max)}…`
150
+ }
package/src/okf.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * OKF v0.2 concept-document model for the Topic profile (ADR 0001).
3
+ *
4
+ * A Topic is a markdown file with YAML frontmatter plus a body. The Topic
5
+ * profile fixes the extension keys (`depends`, `open_questions`, `impact`)
6
+ * and the conventional body headings (`# Conclusion`, `# Recommendations`);
7
+ * the OKF provenance/trust/lifecycle families pass through untouched.
8
+ *
9
+ * `depends` entries are bundle-relative paths (`topics/<slug>.md`) so the
10
+ * file stays OKF-conformant and cross-linkable; graph helpers convert
11
+ * between paths and slugs (ADR 0005).
12
+ *
13
+ * @module okf
14
+ */
15
+
16
+ import { parseYaml, stringifyYaml, YamlError } from './yaml.ts'
17
+
18
+ export type TopicStatus = 'draft' | 'stable' | 'deprecated'
19
+
20
+ export interface SourceEntry {
21
+ id?: string
22
+ resource: string
23
+ title?: string
24
+ author?: string
25
+ usage_count?: number
26
+ last_modified?: string
27
+ }
28
+
29
+ export interface TrustStamp {
30
+ by: string
31
+ at: string
32
+ }
33
+
34
+ export interface TopicFrontmatter {
35
+ type: string
36
+ title: string
37
+ description?: string
38
+ tags: string[]
39
+ /** Bundle-relative paths: `topics/<slug>.md`. */
40
+ depends: string[]
41
+ open_questions: string[]
42
+ impact: string[]
43
+ status: TopicStatus
44
+ generated: TrustStamp
45
+ verified?: TrustStamp
46
+ stale_after?: string
47
+ sources?: SourceEntry[]
48
+ /** Producer-defined extras — preserved on round-trip per OKF §4.1. */
49
+ [extra: string]: unknown
50
+ }
51
+
52
+ export interface TopicDoc {
53
+ fm: TopicFrontmatter
54
+ body: string
55
+ }
56
+
57
+ export class OkfError extends Error {
58
+ override name = 'OkfError'
59
+ }
60
+
61
+ /** Filenames with defined meaning in a bundle; never usable as topic slugs. */
62
+ export const RESERVED_FILES: ReadonlySet<string> = new Set(['index.md', 'spec.md'])
63
+
64
+ const PROFILE_HEADINGS = ['conclusion', 'recommendations'] as const
65
+
66
+ function asStringArray(v: unknown): string[] {
67
+ if (v === undefined || v === null) return []
68
+ if (!Array.isArray(v)) throw new OkfError('expected a list')
69
+ return v.map((x) => {
70
+ if (typeof x !== 'string') throw new OkfError('list entries must be strings')
71
+ return x
72
+ })
73
+ }
74
+
75
+ function asTrust(v: unknown, field: string): TrustStamp {
76
+ if (v === null || v === undefined || typeof v !== 'object') {
77
+ throw new OkfError(`frontmatter.${field} must be an object {by, at}`)
78
+ }
79
+ const o = v as Record<string, unknown>
80
+ if (typeof o.by !== 'string' || o.by === '' || typeof o.at !== 'string' || o.at === '') {
81
+ throw new OkfError(`frontmatter.${field} requires string "by" and "at"`)
82
+ }
83
+ return { by: o.by, at: o.at }
84
+ }
85
+
86
+ function asStatus(v: unknown): TopicStatus {
87
+ if (v === undefined || v === null || v === '') return 'draft'
88
+ if (v === 'draft' || v === 'stable' || v === 'deprecated') return v
89
+ throw new OkfError(`frontmatter.status must be draft|stable|deprecated, got "${String(v)}"`)
90
+ }
91
+
92
+ /**
93
+ * Parse a Topic document. Throws OkfError on structural violations
94
+ * (missing frontmatter, non-mapping, missing type/title).
95
+ */
96
+ export function parseTopicDoc(raw: string): TopicDoc {
97
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw)
98
+ if (m === null) throw new OkfError('document must start with a --- frontmatter block')
99
+ let fmRaw: Record<string, unknown>
100
+ try {
101
+ fmRaw = parseYaml(m[1])
102
+ } catch (e) {
103
+ if (e instanceof YamlError) throw new OkfError(`frontmatter: ${e.message}`)
104
+ throw e
105
+ }
106
+ if (typeof fmRaw.type !== 'string' || fmRaw.type === '') {
107
+ throw new OkfError('frontmatter.type is required (OKF §4.1)')
108
+ }
109
+ const title = typeof fmRaw.title === 'string' && fmRaw.title !== '' ? fmRaw.title : fmRaw.type
110
+ const tags = asStringArray(fmRaw.tags)
111
+ const depends = asStringArray(fmRaw.depends)
112
+ const open_questions = asStringArray(fmRaw.open_questions)
113
+ const impact = asStringArray(fmRaw.impact)
114
+ const generated = 'generated' in fmRaw ? asTrust(fmRaw.generated, 'generated') : { by: 'unknown', at: new Date(0).toISOString() }
115
+ const fm: TopicFrontmatter = {
116
+ type: fmRaw.type,
117
+ title,
118
+ tags,
119
+ depends,
120
+ open_questions,
121
+ impact,
122
+ status: asStatus(fmRaw.status),
123
+ generated,
124
+ }
125
+ if (typeof fmRaw.description === 'string' && fmRaw.description !== '') fm.description = fmRaw.description
126
+ if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined) fm.verified = asTrust(fmRaw.verified, 'verified')
127
+ if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '') fm.stale_after = fmRaw.stale_after
128
+ if (Array.isArray(fmRaw.sources)) {
129
+ fm.sources = fmRaw.sources.map((s) => {
130
+ if (s === null || typeof s !== 'object' || typeof (s as Record<string, unknown>).resource !== 'string') {
131
+ throw new OkfError('frontmatter.sources entries require a string "resource" (OKF §5.1)')
132
+ }
133
+ return s as SourceEntry
134
+ })
135
+ }
136
+ // Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
137
+ const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
138
+ for (const [k, v] of Object.entries(fmRaw)) {
139
+ if (!known.has(k)) fm[k] = v
140
+ }
141
+ return { fm, body: m[2] }
142
+ }
143
+
144
+ /** Serialize a Topic document with a deterministic frontmatter field order. */
145
+ export function serializeTopicDoc(doc: TopicDoc): string {
146
+ const fm = doc.fm
147
+ const out: Record<string, unknown> = {
148
+ type: fm.type,
149
+ title: fm.title,
150
+ }
151
+ if (fm.description !== undefined) out.description = fm.description
152
+ out.tags = fm.tags
153
+ out.depends = fm.depends
154
+ out.open_questions = fm.open_questions
155
+ out.impact = fm.impact
156
+ out.status = fm.status
157
+ out.generated = { by: fm.generated.by, at: fm.generated.at }
158
+ if (fm.verified !== undefined) out.verified = { by: fm.verified.by, at: fm.verified.at }
159
+ if (fm.stale_after !== undefined) out.stale_after = fm.stale_after
160
+ if (fm.sources !== undefined && fm.sources.length > 0) out.sources = fm.sources
161
+ for (const [k, v] of Object.entries(fm)) {
162
+ if (!(k in out)) out[k] = v
163
+ }
164
+ return `---\n${stringifyYaml(out)}---\n${doc.body}`
165
+ }
166
+
167
+ /** Split a body into `# Heading` sections: [{heading, text}] in order. */
168
+ export function splitSections(body: string): { heading: string; text: string }[] {
169
+ const sections: { heading: string; text: string }[] = []
170
+ let current: { heading: string; text: string[] } | undefined
171
+ for (const line of body.split('\n')) {
172
+ const m = /^# (.+)$/.exec(line)
173
+ if (m !== null) {
174
+ if (current !== undefined) sections.push({ heading: current.heading, text: current.text.join('\n').trim() })
175
+ current = { heading: m[1].trim(), text: [] }
176
+ } else if (current !== undefined) {
177
+ current.text.push(line)
178
+ }
179
+ }
180
+ if (current !== undefined) sections.push({ heading: current.heading, text: current.text.join('\n').trim() })
181
+ return sections
182
+ }
183
+
184
+ /** The text under a given `# Heading`, or undefined when absent. */
185
+ export function sectionOf(body: string, heading: string): string | undefined {
186
+ const hit = splitSections(body).find((s) => s.heading.toLowerCase() === heading.toLowerCase())
187
+ return hit === undefined ? undefined : hit.text
188
+ }
189
+
190
+ /** First non-empty line of a text block — the 结论精句 used in digests. */
191
+ export function firstParagraph(text: string): string {
192
+ for (const line of text.split('\n')) {
193
+ const t = line.trim()
194
+ if (t !== '') return t
195
+ }
196
+ return ''
197
+ }
198
+
199
+ /** Replace or append a `# Heading` section, preserving other content and order. */
200
+ export function setSection(body: string, heading: string, text: string): string {
201
+ const sections = splitSections(body)
202
+ const idx = sections.findIndex((s) => s.heading.toLowerCase() === heading.toLowerCase())
203
+ if (idx >= 0) {
204
+ sections[idx] = { heading: sections[idx].heading, text }
205
+ } else {
206
+ sections.push({ heading, text })
207
+ }
208
+ return sections.map((s) => `# ${s.heading}\n\n${s.text}`).join('\n\n') + '\n'
209
+ }
210
+
211
+ /**
212
+ * Slug from a title: latin lowercased, CJK preserved, spaces/punctuation
213
+ * collapse to dashes. Never empty (falls back to `topic`) and never a
214
+ * reserved filename.
215
+ */
216
+ export function slugify(title: string): string {
217
+ const slug = title
218
+ .toLowerCase()
219
+ .normalize('NFKC')
220
+ .replace(/[\s_]+/g, '-')
221
+ .replace(/[^\p{Script=Latin}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Cyrillic}\p{Nd}-]/gu, '')
222
+ .replace(/-{2,}/g, '-')
223
+ .replace(/^-+|-+$/g, '')
224
+ .slice(0, 64)
225
+ .replace(/-+$/g, '')
226
+ if (slug === '' || RESERVED_FILES.has(`${slug}.md`)) return 'topic'
227
+ return slug
228
+ }
229
+
230
+ /** `topics/foo.md` → `foo`; anything else unchanged. */
231
+ export function pathToSlug(path: string): string {
232
+ const m = /^topics\/(.+)\.md$/.exec(path)
233
+ return m === null ? path : m[1]
234
+ }
235
+
236
+ /** `foo` → `topics/foo.md`. */
237
+ export function slugToPath(slug: string): string {
238
+ return `topics/${slug}.md`
239
+ }
240
+
241
+ /** Bundle-relative paths of `depends` entries that point at topics. */
242
+ export function dependsSlugs(fm: { depends: readonly string[] }): string[] {
243
+ return fm.depends.map(pathToSlug)
244
+ }
245
+
246
+ export interface IndexEntry {
247
+ slug: string
248
+ title: string
249
+ description?: string
250
+ status: TopicStatus
251
+ tags: string[]
252
+ }
253
+
254
+ /** Render the auto-generated OKF `index.md` (progressive disclosure, §Bundle). */
255
+ export function renderIndex(entries: IndexEntry[]): string {
256
+ const lines = [
257
+ '# Topic Index',
258
+ '',
259
+ `Auto-generated by dsh-topics-memory — do not edit. ${entries.length} topic${entries.length === 1 ? '' : 's'}.`,
260
+ '',
261
+ ]
262
+ for (const e of entries.sort((a, b) => a.slug.localeCompare(b.slug))) {
263
+ const desc = e.description !== undefined && e.description !== '' ? ` — ${e.description}` : ''
264
+ lines.push(`- [${e.title}](topics/${e.slug}.md) \`${e.status}\`${desc}`)
265
+ }
266
+ return lines.join('\n') + '\n'
267
+ }
268
+
269
+ /** Loose ISO-8601 instant check (OKF requires explicit UTC offsets in spec, kept tolerant). */
270
+ export function isIsoInstant(s: string): boolean {
271
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)
272
+ }
273
+
274
+ const WIKILINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g
275
+ const MD_LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
276
+
277
+ /**
278
+ * Normalize one link target to a topic slug, or undefined when the target is
279
+ * not a bundle-internal topic reference. Accepts `slug`, `slug.md`,
280
+ * `topics/slug`, `[[slug#heading]]`, `[[slug|alias]]`.
281
+ */
282
+ export function normalizeLinkTarget(target: string): string | undefined {
283
+ let t = target.trim()
284
+ if (t === '' || t.includes('://') || t.startsWith('#') || t.startsWith('mailto:')) return undefined
285
+ t = t.split('#')[0].trim()
286
+ if (t === '') return undefined
287
+ if (t.endsWith('.md')) t = t.slice(0, -3)
288
+ if (t.startsWith('./')) t = t.slice(2)
289
+ if (t.startsWith('topics/')) t = t.slice('topics/'.length)
290
+ if (t === '' || t.includes('/') || t.includes(':')) return undefined
291
+ if (RESERVED_FILES.has(`${t}.md`)) return undefined
292
+ return t
293
+ }
294
+
295
+ /**
296
+ * Topic slugs referenced from a body: `[[wikilinks]]` (with optional
297
+ * `#heading` / `|alias`) plus markdown links pointing at bundle paths.
298
+ * These are the human-authored edges alongside `depends` — both feed the
299
+ * retrieval graph walk and the backlinks index.
300
+ */
301
+ export function bodyLinkSlugs(body: string): string[] {
302
+ const out = new Set<string>()
303
+ for (const m of body.matchAll(WIKILINK)) {
304
+ const slug = normalizeLinkTarget(m[1])
305
+ if (slug !== undefined) out.add(slug)
306
+ }
307
+ for (const m of body.matchAll(MD_LINK)) {
308
+ const slug = normalizeLinkTarget(m[1])
309
+ if (slug !== undefined) out.add(slug)
310
+ }
311
+ out.delete('')
312
+ return [...out]
313
+ }
314
+
315
+ /** The conventional profile headings, for validators and tooling. */
316
+ export const CONCLUSION_HEADING = 'Conclusion'
317
+ export const RECOMMENDATIONS_HEADING = 'Recommendations'