@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/lib/okf.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
export type TopicStatus = 'draft' | 'stable' | 'deprecated';
|
|
16
|
+
export interface SourceEntry {
|
|
17
|
+
id?: string;
|
|
18
|
+
resource: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
author?: string;
|
|
21
|
+
usage_count?: number;
|
|
22
|
+
last_modified?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface TrustStamp {
|
|
25
|
+
by: string;
|
|
26
|
+
at: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TopicFrontmatter {
|
|
29
|
+
type: string;
|
|
30
|
+
title: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
tags: string[];
|
|
33
|
+
/** Bundle-relative paths: `topics/<slug>.md`. */
|
|
34
|
+
depends: string[];
|
|
35
|
+
open_questions: string[];
|
|
36
|
+
impact: string[];
|
|
37
|
+
status: TopicStatus;
|
|
38
|
+
generated: TrustStamp;
|
|
39
|
+
verified?: TrustStamp;
|
|
40
|
+
stale_after?: string;
|
|
41
|
+
sources?: SourceEntry[];
|
|
42
|
+
/** Producer-defined extras — preserved on round-trip per OKF §4.1. */
|
|
43
|
+
[extra: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface TopicDoc {
|
|
46
|
+
fm: TopicFrontmatter;
|
|
47
|
+
body: string;
|
|
48
|
+
}
|
|
49
|
+
export declare class OkfError extends Error {
|
|
50
|
+
name: string;
|
|
51
|
+
}
|
|
52
|
+
/** Filenames with defined meaning in a bundle; never usable as topic slugs. */
|
|
53
|
+
export declare const RESERVED_FILES: ReadonlySet<string>;
|
|
54
|
+
/**
|
|
55
|
+
* Parse a Topic document. Throws OkfError on structural violations
|
|
56
|
+
* (missing frontmatter, non-mapping, missing type/title).
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseTopicDoc(raw: string): TopicDoc;
|
|
59
|
+
/** Serialize a Topic document with a deterministic frontmatter field order. */
|
|
60
|
+
export declare function serializeTopicDoc(doc: TopicDoc): string;
|
|
61
|
+
/** Split a body into `# Heading` sections: [{heading, text}] in order. */
|
|
62
|
+
export declare function splitSections(body: string): {
|
|
63
|
+
heading: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}[];
|
|
66
|
+
/** The text under a given `# Heading`, or undefined when absent. */
|
|
67
|
+
export declare function sectionOf(body: string, heading: string): string | undefined;
|
|
68
|
+
/** First non-empty line of a text block — the 结论精句 used in digests. */
|
|
69
|
+
export declare function firstParagraph(text: string): string;
|
|
70
|
+
/** Replace or append a `# Heading` section, preserving other content and order. */
|
|
71
|
+
export declare function setSection(body: string, heading: string, text: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Slug from a title: latin lowercased, CJK preserved, spaces/punctuation
|
|
74
|
+
* collapse to dashes. Never empty (falls back to `topic`) and never a
|
|
75
|
+
* reserved filename.
|
|
76
|
+
*/
|
|
77
|
+
export declare function slugify(title: string): string;
|
|
78
|
+
/** `topics/foo.md` → `foo`; anything else unchanged. */
|
|
79
|
+
export declare function pathToSlug(path: string): string;
|
|
80
|
+
/** `foo` → `topics/foo.md`. */
|
|
81
|
+
export declare function slugToPath(slug: string): string;
|
|
82
|
+
/** Bundle-relative paths of `depends` entries that point at topics. */
|
|
83
|
+
export declare function dependsSlugs(fm: {
|
|
84
|
+
depends: readonly string[];
|
|
85
|
+
}): string[];
|
|
86
|
+
export interface IndexEntry {
|
|
87
|
+
slug: string;
|
|
88
|
+
title: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
status: TopicStatus;
|
|
91
|
+
tags: string[];
|
|
92
|
+
}
|
|
93
|
+
/** Render the auto-generated OKF `index.md` (progressive disclosure, §Bundle). */
|
|
94
|
+
export declare function renderIndex(entries: IndexEntry[]): string;
|
|
95
|
+
/** Loose ISO-8601 instant check (OKF requires explicit UTC offsets in spec, kept tolerant). */
|
|
96
|
+
export declare function isIsoInstant(s: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Normalize one link target to a topic slug, or undefined when the target is
|
|
99
|
+
* not a bundle-internal topic reference. Accepts `slug`, `slug.md`,
|
|
100
|
+
* `topics/slug`, `[[slug#heading]]`, `[[slug|alias]]`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function normalizeLinkTarget(target: string): string | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Topic slugs referenced from a body: `[[wikilinks]]` (with optional
|
|
105
|
+
* `#heading` / `|alias`) plus markdown links pointing at bundle paths.
|
|
106
|
+
* These are the human-authored edges alongside `depends` — both feed the
|
|
107
|
+
* retrieval graph walk and the backlinks index.
|
|
108
|
+
*/
|
|
109
|
+
export declare function bodyLinkSlugs(body: string): string[];
|
|
110
|
+
/** The conventional profile headings, for validators and tooling. */
|
|
111
|
+
export declare const CONCLUSION_HEADING = "Conclusion";
|
|
112
|
+
export declare const RECOMMENDATIONS_HEADING = "Recommendations";
|
package/lib/okf.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
import { parseYaml, stringifyYaml, YamlError } from "./yaml.js";
|
|
16
|
+
export class OkfError extends Error {
|
|
17
|
+
name = 'OkfError';
|
|
18
|
+
}
|
|
19
|
+
/** Filenames with defined meaning in a bundle; never usable as topic slugs. */
|
|
20
|
+
export const RESERVED_FILES = new Set(['index.md', 'spec.md']);
|
|
21
|
+
const PROFILE_HEADINGS = ['conclusion', 'recommendations'];
|
|
22
|
+
function asStringArray(v) {
|
|
23
|
+
if (v === undefined || v === null)
|
|
24
|
+
return [];
|
|
25
|
+
if (!Array.isArray(v))
|
|
26
|
+
throw new OkfError('expected a list');
|
|
27
|
+
return v.map((x) => {
|
|
28
|
+
if (typeof x !== 'string')
|
|
29
|
+
throw new OkfError('list entries must be strings');
|
|
30
|
+
return x;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function asTrust(v, field) {
|
|
34
|
+
if (v === null || v === undefined || typeof v !== 'object') {
|
|
35
|
+
throw new OkfError(`frontmatter.${field} must be an object {by, at}`);
|
|
36
|
+
}
|
|
37
|
+
const o = v;
|
|
38
|
+
if (typeof o.by !== 'string' || o.by === '' || typeof o.at !== 'string' || o.at === '') {
|
|
39
|
+
throw new OkfError(`frontmatter.${field} requires string "by" and "at"`);
|
|
40
|
+
}
|
|
41
|
+
return { by: o.by, at: o.at };
|
|
42
|
+
}
|
|
43
|
+
function asStatus(v) {
|
|
44
|
+
if (v === undefined || v === null || v === '')
|
|
45
|
+
return 'draft';
|
|
46
|
+
if (v === 'draft' || v === 'stable' || v === 'deprecated')
|
|
47
|
+
return v;
|
|
48
|
+
throw new OkfError(`frontmatter.status must be draft|stable|deprecated, got "${String(v)}"`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a Topic document. Throws OkfError on structural violations
|
|
52
|
+
* (missing frontmatter, non-mapping, missing type/title).
|
|
53
|
+
*/
|
|
54
|
+
export function parseTopicDoc(raw) {
|
|
55
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
56
|
+
if (m === null)
|
|
57
|
+
throw new OkfError('document must start with a --- frontmatter block');
|
|
58
|
+
let fmRaw;
|
|
59
|
+
try {
|
|
60
|
+
fmRaw = parseYaml(m[1]);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e instanceof YamlError)
|
|
64
|
+
throw new OkfError(`frontmatter: ${e.message}`);
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
if (typeof fmRaw.type !== 'string' || fmRaw.type === '') {
|
|
68
|
+
throw new OkfError('frontmatter.type is required (OKF §4.1)');
|
|
69
|
+
}
|
|
70
|
+
const title = typeof fmRaw.title === 'string' && fmRaw.title !== '' ? fmRaw.title : fmRaw.type;
|
|
71
|
+
const tags = asStringArray(fmRaw.tags);
|
|
72
|
+
const depends = asStringArray(fmRaw.depends);
|
|
73
|
+
const open_questions = asStringArray(fmRaw.open_questions);
|
|
74
|
+
const impact = asStringArray(fmRaw.impact);
|
|
75
|
+
const generated = 'generated' in fmRaw ? asTrust(fmRaw.generated, 'generated') : { by: 'unknown', at: new Date(0).toISOString() };
|
|
76
|
+
const fm = {
|
|
77
|
+
type: fmRaw.type,
|
|
78
|
+
title,
|
|
79
|
+
tags,
|
|
80
|
+
depends,
|
|
81
|
+
open_questions,
|
|
82
|
+
impact,
|
|
83
|
+
status: asStatus(fmRaw.status),
|
|
84
|
+
generated,
|
|
85
|
+
};
|
|
86
|
+
if (typeof fmRaw.description === 'string' && fmRaw.description !== '')
|
|
87
|
+
fm.description = fmRaw.description;
|
|
88
|
+
if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined)
|
|
89
|
+
fm.verified = asTrust(fmRaw.verified, 'verified');
|
|
90
|
+
if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '')
|
|
91
|
+
fm.stale_after = fmRaw.stale_after;
|
|
92
|
+
if (Array.isArray(fmRaw.sources)) {
|
|
93
|
+
fm.sources = fmRaw.sources.map((s) => {
|
|
94
|
+
if (s === null || typeof s !== 'object' || typeof s.resource !== 'string') {
|
|
95
|
+
throw new OkfError('frontmatter.sources entries require a string "resource" (OKF §5.1)');
|
|
96
|
+
}
|
|
97
|
+
return s;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
|
|
101
|
+
const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources']);
|
|
102
|
+
for (const [k, v] of Object.entries(fmRaw)) {
|
|
103
|
+
if (!known.has(k))
|
|
104
|
+
fm[k] = v;
|
|
105
|
+
}
|
|
106
|
+
return { fm, body: m[2] };
|
|
107
|
+
}
|
|
108
|
+
/** Serialize a Topic document with a deterministic frontmatter field order. */
|
|
109
|
+
export function serializeTopicDoc(doc) {
|
|
110
|
+
const fm = doc.fm;
|
|
111
|
+
const out = {
|
|
112
|
+
type: fm.type,
|
|
113
|
+
title: fm.title,
|
|
114
|
+
};
|
|
115
|
+
if (fm.description !== undefined)
|
|
116
|
+
out.description = fm.description;
|
|
117
|
+
out.tags = fm.tags;
|
|
118
|
+
out.depends = fm.depends;
|
|
119
|
+
out.open_questions = fm.open_questions;
|
|
120
|
+
out.impact = fm.impact;
|
|
121
|
+
out.status = fm.status;
|
|
122
|
+
out.generated = { by: fm.generated.by, at: fm.generated.at };
|
|
123
|
+
if (fm.verified !== undefined)
|
|
124
|
+
out.verified = { by: fm.verified.by, at: fm.verified.at };
|
|
125
|
+
if (fm.stale_after !== undefined)
|
|
126
|
+
out.stale_after = fm.stale_after;
|
|
127
|
+
if (fm.sources !== undefined && fm.sources.length > 0)
|
|
128
|
+
out.sources = fm.sources;
|
|
129
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
130
|
+
if (!(k in out))
|
|
131
|
+
out[k] = v;
|
|
132
|
+
}
|
|
133
|
+
return `---\n${stringifyYaml(out)}---\n${doc.body}`;
|
|
134
|
+
}
|
|
135
|
+
/** Split a body into `# Heading` sections: [{heading, text}] in order. */
|
|
136
|
+
export function splitSections(body) {
|
|
137
|
+
const sections = [];
|
|
138
|
+
let current;
|
|
139
|
+
for (const line of body.split('\n')) {
|
|
140
|
+
const m = /^# (.+)$/.exec(line);
|
|
141
|
+
if (m !== null) {
|
|
142
|
+
if (current !== undefined)
|
|
143
|
+
sections.push({ heading: current.heading, text: current.text.join('\n').trim() });
|
|
144
|
+
current = { heading: m[1].trim(), text: [] };
|
|
145
|
+
}
|
|
146
|
+
else if (current !== undefined) {
|
|
147
|
+
current.text.push(line);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (current !== undefined)
|
|
151
|
+
sections.push({ heading: current.heading, text: current.text.join('\n').trim() });
|
|
152
|
+
return sections;
|
|
153
|
+
}
|
|
154
|
+
/** The text under a given `# Heading`, or undefined when absent. */
|
|
155
|
+
export function sectionOf(body, heading) {
|
|
156
|
+
const hit = splitSections(body).find((s) => s.heading.toLowerCase() === heading.toLowerCase());
|
|
157
|
+
return hit === undefined ? undefined : hit.text;
|
|
158
|
+
}
|
|
159
|
+
/** First non-empty line of a text block — the 结论精句 used in digests. */
|
|
160
|
+
export function firstParagraph(text) {
|
|
161
|
+
for (const line of text.split('\n')) {
|
|
162
|
+
const t = line.trim();
|
|
163
|
+
if (t !== '')
|
|
164
|
+
return t;
|
|
165
|
+
}
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
/** Replace or append a `# Heading` section, preserving other content and order. */
|
|
169
|
+
export function setSection(body, heading, text) {
|
|
170
|
+
const sections = splitSections(body);
|
|
171
|
+
const idx = sections.findIndex((s) => s.heading.toLowerCase() === heading.toLowerCase());
|
|
172
|
+
if (idx >= 0) {
|
|
173
|
+
sections[idx] = { heading: sections[idx].heading, text };
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
sections.push({ heading, text });
|
|
177
|
+
}
|
|
178
|
+
return sections.map((s) => `# ${s.heading}\n\n${s.text}`).join('\n\n') + '\n';
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Slug from a title: latin lowercased, CJK preserved, spaces/punctuation
|
|
182
|
+
* collapse to dashes. Never empty (falls back to `topic`) and never a
|
|
183
|
+
* reserved filename.
|
|
184
|
+
*/
|
|
185
|
+
export function slugify(title) {
|
|
186
|
+
const slug = title
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize('NFKC')
|
|
189
|
+
.replace(/[\s_]+/g, '-')
|
|
190
|
+
.replace(/[^\p{Script=Latin}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Cyrillic}\p{Nd}-]/gu, '')
|
|
191
|
+
.replace(/-{2,}/g, '-')
|
|
192
|
+
.replace(/^-+|-+$/g, '')
|
|
193
|
+
.slice(0, 64)
|
|
194
|
+
.replace(/-+$/g, '');
|
|
195
|
+
if (slug === '' || RESERVED_FILES.has(`${slug}.md`))
|
|
196
|
+
return 'topic';
|
|
197
|
+
return slug;
|
|
198
|
+
}
|
|
199
|
+
/** `topics/foo.md` → `foo`; anything else unchanged. */
|
|
200
|
+
export function pathToSlug(path) {
|
|
201
|
+
const m = /^topics\/(.+)\.md$/.exec(path);
|
|
202
|
+
return m === null ? path : m[1];
|
|
203
|
+
}
|
|
204
|
+
/** `foo` → `topics/foo.md`. */
|
|
205
|
+
export function slugToPath(slug) {
|
|
206
|
+
return `topics/${slug}.md`;
|
|
207
|
+
}
|
|
208
|
+
/** Bundle-relative paths of `depends` entries that point at topics. */
|
|
209
|
+
export function dependsSlugs(fm) {
|
|
210
|
+
return fm.depends.map(pathToSlug);
|
|
211
|
+
}
|
|
212
|
+
/** Render the auto-generated OKF `index.md` (progressive disclosure, §Bundle). */
|
|
213
|
+
export function renderIndex(entries) {
|
|
214
|
+
const lines = [
|
|
215
|
+
'# Topic Index',
|
|
216
|
+
'',
|
|
217
|
+
`Auto-generated by dsh-topics-memory — do not edit. ${entries.length} topic${entries.length === 1 ? '' : 's'}.`,
|
|
218
|
+
'',
|
|
219
|
+
];
|
|
220
|
+
for (const e of entries.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
221
|
+
const desc = e.description !== undefined && e.description !== '' ? ` — ${e.description}` : '';
|
|
222
|
+
lines.push(`- [${e.title}](topics/${e.slug}.md) \`${e.status}\`${desc}`);
|
|
223
|
+
}
|
|
224
|
+
return lines.join('\n') + '\n';
|
|
225
|
+
}
|
|
226
|
+
/** Loose ISO-8601 instant check (OKF requires explicit UTC offsets in spec, kept tolerant). */
|
|
227
|
+
export function isIsoInstant(s) {
|
|
228
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s);
|
|
229
|
+
}
|
|
230
|
+
const WIKILINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
231
|
+
const MD_LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
232
|
+
/**
|
|
233
|
+
* Normalize one link target to a topic slug, or undefined when the target is
|
|
234
|
+
* not a bundle-internal topic reference. Accepts `slug`, `slug.md`,
|
|
235
|
+
* `topics/slug`, `[[slug#heading]]`, `[[slug|alias]]`.
|
|
236
|
+
*/
|
|
237
|
+
export function normalizeLinkTarget(target) {
|
|
238
|
+
let t = target.trim();
|
|
239
|
+
if (t === '' || t.includes('://') || t.startsWith('#') || t.startsWith('mailto:'))
|
|
240
|
+
return undefined;
|
|
241
|
+
t = t.split('#')[0].trim();
|
|
242
|
+
if (t === '')
|
|
243
|
+
return undefined;
|
|
244
|
+
if (t.endsWith('.md'))
|
|
245
|
+
t = t.slice(0, -3);
|
|
246
|
+
if (t.startsWith('./'))
|
|
247
|
+
t = t.slice(2);
|
|
248
|
+
if (t.startsWith('topics/'))
|
|
249
|
+
t = t.slice('topics/'.length);
|
|
250
|
+
if (t === '' || t.includes('/') || t.includes(':'))
|
|
251
|
+
return undefined;
|
|
252
|
+
if (RESERVED_FILES.has(`${t}.md`))
|
|
253
|
+
return undefined;
|
|
254
|
+
return t;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Topic slugs referenced from a body: `[[wikilinks]]` (with optional
|
|
258
|
+
* `#heading` / `|alias`) plus markdown links pointing at bundle paths.
|
|
259
|
+
* These are the human-authored edges alongside `depends` — both feed the
|
|
260
|
+
* retrieval graph walk and the backlinks index.
|
|
261
|
+
*/
|
|
262
|
+
export function bodyLinkSlugs(body) {
|
|
263
|
+
const out = new Set();
|
|
264
|
+
for (const m of body.matchAll(WIKILINK)) {
|
|
265
|
+
const slug = normalizeLinkTarget(m[1]);
|
|
266
|
+
if (slug !== undefined)
|
|
267
|
+
out.add(slug);
|
|
268
|
+
}
|
|
269
|
+
for (const m of body.matchAll(MD_LINK)) {
|
|
270
|
+
const slug = normalizeLinkTarget(m[1]);
|
|
271
|
+
if (slug !== undefined)
|
|
272
|
+
out.add(slug);
|
|
273
|
+
}
|
|
274
|
+
out.delete('');
|
|
275
|
+
return [...out];
|
|
276
|
+
}
|
|
277
|
+
/** The conventional profile headings, for validators and tooling. */
|
|
278
|
+
export const CONCLUSION_HEADING = 'Conclusion';
|
|
279
|
+
export const RECOMMENDATIONS_HEADING = 'Recommendations';
|
package/lib/onboard.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/topics onboard` — an a/b/c configuration wizard that walks a new user
|
|
3
|
+
* through the five decisions that matter (storage mode, GitHub repo, distill
|
|
4
|
+
* model, injection budget, auto-observe) one command at a time.
|
|
5
|
+
*
|
|
6
|
+
* Design (ADR 0009):
|
|
7
|
+
* - one invocation = one step: the wizard renders the current question with
|
|
8
|
+
* lettered options; the answer arrives as the next invocation's argument
|
|
9
|
+
* (`/topics onboard b`). Works unchanged on every surface (tui, web, feishu,
|
|
10
|
+
* headless one-shots) because it never needs intra-turn interactivity.
|
|
11
|
+
* - answers accumulate in a pending batch; only the final confirm step
|
|
12
|
+
* writes settings — quitting midway leaves the config untouched.
|
|
13
|
+
* - the step machine is pure (`applyAnswer` / `renderStep`), so tests drive
|
|
14
|
+
* it without spawning `gh`; the handler injects the login detector.
|
|
15
|
+
*
|
|
16
|
+
* @module onboard
|
|
17
|
+
*/
|
|
18
|
+
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands';
|
|
19
|
+
import type { TopicsService } from './service.ts';
|
|
20
|
+
import { type TopicsConfigValue } from './config.ts';
|
|
21
|
+
/** CamelCase → dash-display, shared with /topics config rendering. */
|
|
22
|
+
export declare function displayKey(key: string): string;
|
|
23
|
+
export type StepId = 'mode' | 'repo' | 'distill' | 'inject' | 'observe' | 'confirm' | 'done';
|
|
24
|
+
export interface OnboardState {
|
|
25
|
+
step: StepId;
|
|
26
|
+
/** Answers so far; only written to settings at the confirm step. */
|
|
27
|
+
pending: Partial<TopicsConfigValue>;
|
|
28
|
+
/** Whether the user picked GitHub mode (adds the repo step). */
|
|
29
|
+
pathHasRepo: boolean;
|
|
30
|
+
}
|
|
31
|
+
export type MutateFn = (ops: readonly {
|
|
32
|
+
op: 'set';
|
|
33
|
+
path: string[];
|
|
34
|
+
value: unknown;
|
|
35
|
+
}[]) => Promise<void>;
|
|
36
|
+
export type GithubLoginDetector = () => Promise<string | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* Suggested remote repo name. Deliberately NOT `dsh-topics-memory`: that is
|
|
39
|
+
* the plugin's own source repository, and the default data repo must never
|
|
40
|
+
* collide with it (ADR 0002/0009).
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEFAULT_TOPICS_REPO = "dsh-topics-data";
|
|
43
|
+
export declare function freshState(): OnboardState;
|
|
44
|
+
/** Render the current step against live config overlaid with pending answers. */
|
|
45
|
+
export declare function renderStep(state: OnboardState, cfg: TopicsConfigValue): string;
|
|
46
|
+
/**
|
|
47
|
+
* Pure step machine: consume one answer, return the next state. The caller
|
|
48
|
+
* intercepts control answers (quit) and confirm-write before calling this.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyAnswer(state: OnboardState, answer: string, cfg: TopicsConfigValue): {
|
|
51
|
+
state: OnboardState;
|
|
52
|
+
error?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Settings ops for the pending batch, in stable CONFIG_KEYS order. */
|
|
55
|
+
export declare function confirmOps(pending: Partial<TopicsConfigValue>): {
|
|
56
|
+
op: 'set';
|
|
57
|
+
path: string[];
|
|
58
|
+
value: unknown;
|
|
59
|
+
}[];
|
|
60
|
+
/**
|
|
61
|
+
* `gh api user --jq .login` with a hard timeout; undefined on any failure
|
|
62
|
+
* (gh missing, not logged in, slow). Never throws.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectGithubLogin(timeoutMs?: number): Promise<string | undefined>;
|
|
65
|
+
export interface AskOptionShape {
|
|
66
|
+
label: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface AskItemShape {
|
|
70
|
+
id: string;
|
|
71
|
+
question: string;
|
|
72
|
+
detail?: string;
|
|
73
|
+
header?: string;
|
|
74
|
+
options?: AskOptionShape[];
|
|
75
|
+
}
|
|
76
|
+
export interface AskAnswerItemShape {
|
|
77
|
+
id: string;
|
|
78
|
+
selected: string[];
|
|
79
|
+
custom?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface AskServiceShape {
|
|
82
|
+
ask(request: {
|
|
83
|
+
questions: AskItemShape[];
|
|
84
|
+
agent?: unknown;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}): Promise<{
|
|
87
|
+
answers: AskAnswerItemShape[];
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
export type AskServiceResolver = () => AskServiceShape | undefined;
|
|
91
|
+
/** Structural view of the dsh llm service used for distill-route pickers. */
|
|
92
|
+
export interface LlmDirectoryShape {
|
|
93
|
+
/** Live routes only (providers with a registered adapter), in registration order. */
|
|
94
|
+
listProviders(): readonly {
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
}[];
|
|
98
|
+
/** Advisory catalog; an unlisted model id may still be usable. */
|
|
99
|
+
listModels(provider: string): Promise<readonly {
|
|
100
|
+
provider: string;
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
}[]>;
|
|
105
|
+
/** Pure in-memory exact-route check; rejects with coded errors (e.g. NO_ADAPTER). */
|
|
106
|
+
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<unknown>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Ordered llm candidates for the distill pickers: session-scoped capture
|
|
110
|
+
* first (the instance this session's own loop streams through), apply-time
|
|
111
|
+
* root second. Consumers pick with `pickLlmDirectory` — the first candidate
|
|
112
|
+
* whose `listProviders()` is non-empty wins — so neither source's emptiness
|
|
113
|
+
* shadows the other.
|
|
114
|
+
*/
|
|
115
|
+
export type LlmDirectoryResolver = () => readonly (LlmDirectoryShape | undefined)[];
|
|
116
|
+
/** Where the candidate walk for the distill pickers landed. */
|
|
117
|
+
export type LlmDirectoryPick = {
|
|
118
|
+
kind: 'ok';
|
|
119
|
+
llm: LlmDirectoryShape;
|
|
120
|
+
} | {
|
|
121
|
+
kind: 'empty-directory';
|
|
122
|
+
} | {
|
|
123
|
+
kind: 'no-instance';
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* First candidate with a non-empty live route table wins. Distinguishes
|
|
127
|
+
* 「有实例但列表空」(service present, zero enabled providers) from
|
|
128
|
+
* 「均不可用」(nothing reachable) so each gets its own user hint.
|
|
129
|
+
*/
|
|
130
|
+
export declare function pickLlmDirectory(candidates: readonly (LlmDirectoryShape | undefined)[]): LlmDirectoryPick;
|
|
131
|
+
/** One-line why-not for the distill pick panels; undefined when usable. */
|
|
132
|
+
export declare function llmDirectoryNotice(pick: LlmDirectoryPick): string | undefined;
|
|
133
|
+
/** Build one ask() closure that rides the invocation's agent/signal context. */
|
|
134
|
+
export declare function makePanel(ask: AskServiceShape, agent: unknown, signal: AbortSignal | undefined): (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]>;
|
|
135
|
+
interface InvocationAgentShape {
|
|
136
|
+
id?: unknown;
|
|
137
|
+
session?: {
|
|
138
|
+
id?: unknown;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** provider model(空格)或 provider/model(斜杠);model 本身可含斜杠。 */
|
|
142
|
+
export declare function parseDistillInput(raw: string): {
|
|
143
|
+
provider: string;
|
|
144
|
+
model: string;
|
|
145
|
+
} | {
|
|
146
|
+
error: string;
|
|
147
|
+
};
|
|
148
|
+
/** Blank item = the UI's "skipped" encoding: keep the current value. */
|
|
149
|
+
export declare function isAnswered(a: AskAnswerItemShape | undefined): a is AskAnswerItemShape;
|
|
150
|
+
/**
|
|
151
|
+
* Two dependent panels — provider first, then that provider's models — so the
|
|
152
|
+
* model list can come from the chosen provider's catalog. Answers run through
|
|
153
|
+
* `resolveModelInfo` before entering the pending batch: NO_ADAPTER blocks and
|
|
154
|
+
* re-asks (bounded), any other rejection (model outside the advisory catalog,
|
|
155
|
+
* possibly still usable) warns but allows. Returns undefined when the user
|
|
156
|
+
* skips or blanks the panel (keep current).
|
|
157
|
+
*/
|
|
158
|
+
export declare function askDistillRoute(llm: LlmDirectoryShape, panel: (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]>, cfg: {
|
|
159
|
+
distillProvider: string;
|
|
160
|
+
distillModel: string;
|
|
161
|
+
}, maxAttempts?: number): Promise<{
|
|
162
|
+
provider: string;
|
|
163
|
+
model: string;
|
|
164
|
+
unknownModel: boolean;
|
|
165
|
+
} | undefined>;
|
|
166
|
+
/**
|
|
167
|
+
* Single-panel variant behind `/topics set distill-provider|distill-model`:
|
|
168
|
+
* one question, current value shown, blank answer = keep. Validation mirrors
|
|
169
|
+
* the wizard (`askDistillRoute`): a provider pick must sit in the live route
|
|
170
|
+
* table (the NO_ADAPTER equivalent — blocked, bounded re-ask); a model pick
|
|
171
|
+
* runs through `resolveModelInfo` — NO_ADAPTER blocks, any other failure
|
|
172
|
+
* warns but allows (model outside the advisory catalog, possibly still
|
|
173
|
+
* usable). Returns the picked value (plus the warning when a failed check was
|
|
174
|
+
* allowed through), or undefined on skip/blank.
|
|
175
|
+
*/
|
|
176
|
+
export declare function askDistillSingle(llm: LlmDirectoryShape, panel: (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]>, key: 'distill-provider' | 'distill-model', cfg: {
|
|
177
|
+
distillProvider: string;
|
|
178
|
+
distillModel: string;
|
|
179
|
+
}, maxAttempts?: number): Promise<{
|
|
180
|
+
value: string;
|
|
181
|
+
warning?: string;
|
|
182
|
+
} | undefined>;
|
|
183
|
+
/**
|
|
184
|
+
* The interactive flow: storage mode, then the distill route as two dependent
|
|
185
|
+
* panels (provider → that provider's models), then a batched panel (repo when
|
|
186
|
+
* GitHub mode, inject tier, observe), then a confirm panel whose detail lists
|
|
187
|
+
* exactly what will be written. A closed panel or a skipped question never
|
|
188
|
+
* writes. Hosts without the llm directory never enter here (the handler gates
|
|
189
|
+
* on it and falls back to the typed wizard).
|
|
190
|
+
*/
|
|
191
|
+
export declare function runInteractiveOnboard(service: TopicsService, mutate: MutateFn, ask: AskServiceShape, detectLogin: GithubLoginDetector, agent: InvocationAgentShape | undefined, signal: AbortSignal | undefined, llm: LlmDirectoryShape): Promise<CommandResult>;
|
|
192
|
+
/**
|
|
193
|
+
* The `/topics onboard <args>` handler. One instance per command registration —
|
|
194
|
+
* wizard state lives in the closure, so tests get a fresh wizard per
|
|
195
|
+
* buildTopicsCommand() and concurrent surfaces don't share progress.
|
|
196
|
+
*
|
|
197
|
+
* Primary path is the native ask-user seam when BOTH providers are present:
|
|
198
|
+
* `resolveAsk()` (TUI panel, web composer, feishu card — whichever surface
|
|
199
|
+
* owns the slot, with dsh-ask-router optionally fanning out) and a usable
|
|
200
|
+
* `resolveLlm()` candidate (the first with a non-empty live route table,
|
|
201
|
+
* feeding the distill provider/model panels). Either one missing — bare
|
|
202
|
+
* hosts, no UI, no model route — falls back to the typed wizard.
|
|
203
|
+
*/
|
|
204
|
+
export declare function createOnboardHandler(service: TopicsService, mutate: MutateFn, detectLogin?: GithubLoginDetector, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver): (args: string[], invocation: CommandInvocation) => Promise<CommandResult>;
|
|
205
|
+
export {};
|