@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/observer.ts
ADDED
|
@@ -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'
|