learn-anything-cli 0.3.0 → 0.4.0-beta.1
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/dist/core/init.d.ts +2 -0
- package/dist/core/init.js +28 -1
- package/dist/core/learn-protocol/index.d.ts +8 -0
- package/dist/core/learn-protocol/index.js +5 -0
- package/dist/core/learn-protocol/migrate.d.ts +52 -0
- package/dist/core/learn-protocol/migrate.js +259 -0
- package/dist/core/learn-protocol/parser.d.ts +33 -0
- package/dist/core/learn-protocol/parser.js +150 -0
- package/dist/core/learn-protocol/schema.d.ts +38 -0
- package/dist/core/learn-protocol/schema.js +43 -0
- package/dist/core/learn-protocol/slug.d.ts +13 -0
- package/dist/core/learn-protocol/slug.js +28 -0
- package/dist/core/learn-protocol/types.d.ts +63 -0
- package/dist/core/learn-protocol/types.js +2 -0
- package/dist/core/templates/workflows/learn-explain.js +56 -139
- package/dist/core/templates/workflows/learn-practice.js +88 -284
- package/dist/core/templates/workflows/learn-review.js +35 -93
- package/dist/core/templates/workflows/learn-status.js +26 -69
- package/dist/core/templates/workflows/learn-topic.js +73 -82
- package/dist/i18n/locales/en.js +1 -0
- package/dist/i18n/locales/zh-CN.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/scripts/render.d.mts +13 -0
- package/dist/scripts/render.mjs +112 -0
- package/dist/scripts/status.d.mts +31 -0
- package/dist/scripts/status.mjs +418 -0
- package/dist/scripts/utils.d.mts +43 -0
- package/dist/scripts/utils.mjs +124 -0
- package/package.json +4 -1
package/dist/core/init.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare class InitCommand {
|
|
|
16
16
|
private hasToolDir;
|
|
17
17
|
private interactiveSelect;
|
|
18
18
|
private generateSkillsForTool;
|
|
19
|
+
/** Read a compiled script from dist/scripts/ (bundled alongside this module). */
|
|
20
|
+
private readCompiledScript;
|
|
19
21
|
private generateCommandsForTool;
|
|
20
22
|
}
|
|
21
23
|
export {};
|
package/dist/core/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'path';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import { createRequire } from 'module';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
5
6
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
6
7
|
import { AI_TOOLS, LEARN_DIR } from './config.js';
|
|
7
8
|
import { isInteractive } from '../utils/interactive.js';
|
|
@@ -28,7 +29,14 @@ export class InitCommand {
|
|
|
28
29
|
await FileSystemUtils.ensureDir(resolvedPath);
|
|
29
30
|
// Create .learn/ directory in the target project
|
|
30
31
|
const learnDir = path.join(resolvedPath, LEARN_DIR);
|
|
31
|
-
|
|
32
|
+
const topicsDir = path.join(learnDir, 'topics');
|
|
33
|
+
await FileSystemUtils.ensureDir(topicsDir);
|
|
34
|
+
// Run v0→v1 migration for any existing learning data
|
|
35
|
+
const { migrateAll } = await import('./learn-protocol/index.js');
|
|
36
|
+
const report = await migrateAll(topicsDir);
|
|
37
|
+
if (report.migratedCount > 0) {
|
|
38
|
+
console.log(chalk.green(m.init.migrationComplete(report.migratedCount)));
|
|
39
|
+
}
|
|
32
40
|
console.log(chalk.bold(m.init.header));
|
|
33
41
|
// Detect available tools
|
|
34
42
|
const availableTools = await this.detectTools(resolvedPath);
|
|
@@ -119,8 +127,27 @@ export class InitCommand {
|
|
|
119
127
|
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
120
128
|
const content = generateSkillContent(entry.template, VERSION);
|
|
121
129
|
await FileSystemUtils.writeFile(skillFile, content);
|
|
130
|
+
const scriptsDir = path.join(skillDir, 'scripts');
|
|
131
|
+
// topic / explain / practice → utils.mjs + render.mjs
|
|
132
|
+
if (entry.dirName === 'learn-anything-topic' ||
|
|
133
|
+
entry.dirName === 'learn-anything-explain' ||
|
|
134
|
+
entry.dirName === 'learn-anything-practice') {
|
|
135
|
+
await FileSystemUtils.writeFile(path.join(scriptsDir, 'utils.mjs'), this.readCompiledScript('utils.mjs'));
|
|
136
|
+
await FileSystemUtils.writeFile(path.join(scriptsDir, 'render.mjs'), this.readCompiledScript('render.mjs'));
|
|
137
|
+
}
|
|
138
|
+
// status → utils.mjs + status.mjs
|
|
139
|
+
if (entry.dirName === 'learn-anything-status') {
|
|
140
|
+
await FileSystemUtils.writeFile(path.join(scriptsDir, 'utils.mjs'), this.readCompiledScript('utils.mjs'));
|
|
141
|
+
await FileSystemUtils.writeFile(path.join(scriptsDir, 'status.mjs'), this.readCompiledScript('status.mjs'));
|
|
142
|
+
}
|
|
143
|
+
// review → no scripts needed
|
|
122
144
|
}
|
|
123
145
|
}
|
|
146
|
+
/** Read a compiled script from dist/scripts/ (bundled alongside this module). */
|
|
147
|
+
readCompiledScript(filename) {
|
|
148
|
+
const scriptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', filename);
|
|
149
|
+
return fs.readFileSync(scriptPath, 'utf-8');
|
|
150
|
+
}
|
|
124
151
|
async generateCommandsForTool(resolvedPath, tool) {
|
|
125
152
|
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
126
153
|
if (!adapter)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { ConceptStatus, Concept, Domain, StateV1, Detail, V0Concept, V0State, ParsedConcept, ParsedDomain, ParsedKnowledgeMap, } from './types.js';
|
|
2
|
+
export { stateV1Schema, validateStateV1 } from './schema.js';
|
|
3
|
+
export type { StateV1Schema, ValidationResult } from './schema.js';
|
|
4
|
+
export { generateSlug } from './slug.js';
|
|
5
|
+
export { parseKnowledgeMap } from './parser.js';
|
|
6
|
+
export { isV0State, migrateV0ToV1, migrateAll } from './migrate.js';
|
|
7
|
+
export type { MigrationResult, MigrationReport } from './migrate.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0 -> v1 migration logic.
|
|
3
|
+
*
|
|
4
|
+
* Detects v0 state.yaml format and merges it with knowledge-map.md
|
|
5
|
+
* to produce a state.json v1 file. Migration is idempotent and
|
|
6
|
+
* creates .bak backups before writing.
|
|
7
|
+
*
|
|
8
|
+
* Migration chain:
|
|
9
|
+
* state.yaml (v0) + knowledge-map.md (v0)
|
|
10
|
+
* -> migrateV0ToV1()
|
|
11
|
+
* -> state.json (v1) + state.yaml.v0.bak + knowledge-map.md.v0.bak
|
|
12
|
+
*/
|
|
13
|
+
import type { V0State } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Check if a parsed YAML object matches the v0 state format.
|
|
16
|
+
*
|
|
17
|
+
* v0 is identified by having `topic` and `concepts` fields but
|
|
18
|
+
* NO `version` field. Returns `true` when the data matches v0.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isV0State(data: unknown): data is V0State;
|
|
21
|
+
export interface MigrationResult {
|
|
22
|
+
migrated: boolean;
|
|
23
|
+
topic: string;
|
|
24
|
+
reason?: 'already_migrated' | 'already_v1' | 'not_v0' | 'no_state_yaml' | 'error';
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MigrationReport {
|
|
28
|
+
migratedCount: number;
|
|
29
|
+
skippedCount: number;
|
|
30
|
+
results: MigrationResult[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Migrate a single topic directory from v0 to v1.
|
|
34
|
+
*
|
|
35
|
+
* Reads `state.yaml` and `knowledge-map.md`, merges them into `state.json`,
|
|
36
|
+
* and creates `.bak` backup files. Idempotent — skips if already migrated.
|
|
37
|
+
*
|
|
38
|
+
* @param topicDir — Absolute path to the topic directory (e.g. `.learn/topics/javascript`)
|
|
39
|
+
* @returns A MigrationResult describing what happened
|
|
40
|
+
*/
|
|
41
|
+
export declare function migrateV0ToV1(topicDir: string): Promise<MigrationResult>;
|
|
42
|
+
/**
|
|
43
|
+
* Migrate ALL topics under a base directory from v0 to v1.
|
|
44
|
+
*
|
|
45
|
+
* Scans `.learn/topics/` for topic subdirectories and runs
|
|
46
|
+
* migrateV0ToV1 on each one. Returns a summary report.
|
|
47
|
+
*
|
|
48
|
+
* @param baseDir — Path to the topics directory (e.g. `.learn/topics`)
|
|
49
|
+
* @returns MigrationReport with counts and per-topic results
|
|
50
|
+
*/
|
|
51
|
+
export declare function migrateAll(baseDir: string): Promise<MigrationReport>;
|
|
52
|
+
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0 -> v1 migration logic.
|
|
3
|
+
*
|
|
4
|
+
* Detects v0 state.yaml format and merges it with knowledge-map.md
|
|
5
|
+
* to produce a state.json v1 file. Migration is idempotent and
|
|
6
|
+
* creates .bak backups before writing.
|
|
7
|
+
*
|
|
8
|
+
* Migration chain:
|
|
9
|
+
* state.yaml (v0) + knowledge-map.md (v0)
|
|
10
|
+
* -> migrateV0ToV1()
|
|
11
|
+
* -> state.json (v1) + state.yaml.v0.bak + knowledge-map.md.v0.bak
|
|
12
|
+
*/
|
|
13
|
+
import { promises as fs } from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { parse as parseYaml } from 'yaml';
|
|
16
|
+
import { parseKnowledgeMap } from './parser.js';
|
|
17
|
+
import { generateSlug } from './slug.js';
|
|
18
|
+
import { stateV1Schema } from './schema.js';
|
|
19
|
+
import { render } from '../../scripts/render.mjs';
|
|
20
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
21
|
+
// ---- Public API ---------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Check if a parsed YAML object matches the v0 state format.
|
|
24
|
+
*
|
|
25
|
+
* v0 is identified by having `topic` and `concepts` fields but
|
|
26
|
+
* NO `version` field. Returns `true` when the data matches v0.
|
|
27
|
+
*/
|
|
28
|
+
export function isV0State(data) {
|
|
29
|
+
if (!data || typeof data !== 'object')
|
|
30
|
+
return false;
|
|
31
|
+
const obj = data;
|
|
32
|
+
// v0: has topic (string) and concepts (array), no version field
|
|
33
|
+
if (typeof obj.topic !== 'string' || !obj.topic)
|
|
34
|
+
return false;
|
|
35
|
+
if (!Array.isArray(obj.concepts))
|
|
36
|
+
return false;
|
|
37
|
+
if (obj.version !== undefined)
|
|
38
|
+
return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Migrate a single topic directory from v0 to v1.
|
|
43
|
+
*
|
|
44
|
+
* Reads `state.yaml` and `knowledge-map.md`, merges them into `state.json`,
|
|
45
|
+
* and creates `.bak` backup files. Idempotent — skips if already migrated.
|
|
46
|
+
*
|
|
47
|
+
* @param topicDir — Absolute path to the topic directory (e.g. `.learn/topics/javascript`)
|
|
48
|
+
* @returns A MigrationResult describing what happened
|
|
49
|
+
*/
|
|
50
|
+
export async function migrateV0ToV1(topicDir) {
|
|
51
|
+
const stateYamlPath = path.join(topicDir, 'state.yaml');
|
|
52
|
+
const knowledgeMapPath = path.join(topicDir, 'knowledge-map.md');
|
|
53
|
+
const stateJsonPath = path.join(topicDir, 'state.json');
|
|
54
|
+
const stateYamlBackup = path.join(topicDir, 'state.yaml.v0.bak');
|
|
55
|
+
const knowledgeMapBackup = path.join(topicDir, 'knowledge-map.md.v0.bak');
|
|
56
|
+
// 1. Skip if state.json already exists (already migrated)
|
|
57
|
+
if (await FileSystemUtils.fileExists(stateJsonPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const existing = JSON.parse(await fs.readFile(stateJsonPath, 'utf-8'));
|
|
60
|
+
return {
|
|
61
|
+
migrated: false,
|
|
62
|
+
topic: existing.topic || path.basename(topicDir),
|
|
63
|
+
reason: 'already_migrated',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {
|
|
68
|
+
migrated: false,
|
|
69
|
+
topic: path.basename(topicDir),
|
|
70
|
+
reason: 'already_migrated',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 2. Check state.yaml exists
|
|
75
|
+
if (!(await FileSystemUtils.fileExists(stateYamlPath))) {
|
|
76
|
+
return {
|
|
77
|
+
migrated: false,
|
|
78
|
+
topic: path.basename(topicDir),
|
|
79
|
+
reason: 'no_state_yaml',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// 3. Read and parse state.yaml
|
|
83
|
+
let v0Data;
|
|
84
|
+
try {
|
|
85
|
+
const yamlContent = await fs.readFile(stateYamlPath, 'utf-8');
|
|
86
|
+
v0Data = parseYaml(yamlContent);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return {
|
|
90
|
+
migrated: false,
|
|
91
|
+
topic: path.basename(topicDir),
|
|
92
|
+
reason: 'error',
|
|
93
|
+
error: `Failed to parse state.yaml: ${err.message}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// 4. Check v0 format (has topic + concepts, no version field)
|
|
97
|
+
if (!isV0State(v0Data)) {
|
|
98
|
+
return {
|
|
99
|
+
migrated: false,
|
|
100
|
+
topic: path.basename(topicDir),
|
|
101
|
+
reason: 'not_v0',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const v0State = v0Data;
|
|
105
|
+
// 7. Read and parse knowledge-map.md
|
|
106
|
+
let parsedMap;
|
|
107
|
+
if (await FileSystemUtils.fileExists(knowledgeMapPath)) {
|
|
108
|
+
try {
|
|
109
|
+
const kmContent = await fs.readFile(knowledgeMapPath, 'utf-8');
|
|
110
|
+
parsedMap = parseKnowledgeMap(kmContent);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
return {
|
|
114
|
+
migrated: false,
|
|
115
|
+
topic: v0State.topic,
|
|
116
|
+
reason: 'error',
|
|
117
|
+
error: `Failed to parse knowledge-map.md: ${err.message}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Without knowledge-map.md we can't build the hierarchy; skip
|
|
123
|
+
return {
|
|
124
|
+
migrated: false,
|
|
125
|
+
topic: v0State.topic,
|
|
126
|
+
reason: 'error',
|
|
127
|
+
error: 'knowledge-map.md not found',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 8. Merge: build a lookup from v0 path -> V0Concept
|
|
131
|
+
const conceptLookup = new Map();
|
|
132
|
+
for (const c of v0State.concepts) {
|
|
133
|
+
conceptLookup.set(c.path, c);
|
|
134
|
+
}
|
|
135
|
+
// 9. Build StateV1 from parsed hierarchy + v0 state data
|
|
136
|
+
const domains = parsedMap.domains.map((pd) => ({
|
|
137
|
+
name: pd.name,
|
|
138
|
+
slug: generateSlug(pd.name),
|
|
139
|
+
concepts: pd.concepts.map((pc) => {
|
|
140
|
+
const v0Path = `${pd.name}/${pc.name}`;
|
|
141
|
+
const v0Concept = conceptLookup.get(v0Path);
|
|
142
|
+
return {
|
|
143
|
+
name: pc.name,
|
|
144
|
+
slug: generateSlug(pc.name),
|
|
145
|
+
status: mapStatus(v0Concept?.status),
|
|
146
|
+
confidence: v0Concept?.confidence ?? 0,
|
|
147
|
+
practice_count: v0Concept?.practice_count ?? 0,
|
|
148
|
+
explain_count: v0Concept?.explain_count ?? 0,
|
|
149
|
+
last_explained: v0Concept?.last_session ?? null,
|
|
150
|
+
last_practiced: v0Concept?.last_practiced ?? null,
|
|
151
|
+
details: pc.children,
|
|
152
|
+
};
|
|
153
|
+
}),
|
|
154
|
+
}));
|
|
155
|
+
const stateV1 = {
|
|
156
|
+
version: 1,
|
|
157
|
+
topic: v0State.topic,
|
|
158
|
+
slug: generateSlug(v0State.topic),
|
|
159
|
+
created: v0State.created,
|
|
160
|
+
domains,
|
|
161
|
+
};
|
|
162
|
+
// 10. Validate the generated state against the schema
|
|
163
|
+
const validation = stateV1Schema.safeParse(stateV1);
|
|
164
|
+
if (!validation.success) {
|
|
165
|
+
return {
|
|
166
|
+
migrated: false,
|
|
167
|
+
topic: v0State.topic,
|
|
168
|
+
reason: 'error',
|
|
169
|
+
error: `Generated state.json failed validation: ${validation.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// 11. Write state.json
|
|
173
|
+
try {
|
|
174
|
+
await FileSystemUtils.writeFile(stateJsonPath, JSON.stringify(stateV1, null, 2) + '\n');
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
migrated: false,
|
|
179
|
+
topic: v0State.topic,
|
|
180
|
+
reason: 'error',
|
|
181
|
+
error: `Failed to write state.json: ${err.message}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// 12. Create backup files, then remove originals
|
|
185
|
+
// After migration, state.json is the single source of truth,
|
|
186
|
+
// so the v0 files should not remain alongside it.
|
|
187
|
+
try {
|
|
188
|
+
await fs.copyFile(stateYamlPath, stateYamlBackup);
|
|
189
|
+
await fs.copyFile(knowledgeMapPath, knowledgeMapBackup);
|
|
190
|
+
await fs.rm(stateYamlPath, { force: true });
|
|
191
|
+
await fs.rm(knowledgeMapPath, { force: true });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Backup/cleanup failure is non-fatal — state.json was already written
|
|
195
|
+
console.error(`Warning: Failed to create backup files or clean up originals in ${topicDir}: ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
// 13. Regenerate knowledge-map.md from state.json (v1 format)
|
|
198
|
+
try {
|
|
199
|
+
const rendered = render(stateV1);
|
|
200
|
+
await FileSystemUtils.writeFile(knowledgeMapPath, rendered);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
// Render failure is non-fatal — migration itself succeeded
|
|
204
|
+
console.error(`Warning: Failed to regenerate knowledge-map.md in ${topicDir}: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
migrated: true,
|
|
208
|
+
topic: v0State.topic,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Migrate ALL topics under a base directory from v0 to v1.
|
|
213
|
+
*
|
|
214
|
+
* Scans `.learn/topics/` for topic subdirectories and runs
|
|
215
|
+
* migrateV0ToV1 on each one. Returns a summary report.
|
|
216
|
+
*
|
|
217
|
+
* @param baseDir — Path to the topics directory (e.g. `.learn/topics`)
|
|
218
|
+
* @returns MigrationReport with counts and per-topic results
|
|
219
|
+
*/
|
|
220
|
+
export async function migrateAll(baseDir) {
|
|
221
|
+
const results = [];
|
|
222
|
+
let entryNames;
|
|
223
|
+
try {
|
|
224
|
+
entryNames = await fs.readdir(baseDir);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Directory doesn't exist — nothing to migrate
|
|
228
|
+
return { migratedCount: 0, skippedCount: 0, results: [] };
|
|
229
|
+
}
|
|
230
|
+
// Check each entry to see if it's a directory
|
|
231
|
+
const topicDirs = [];
|
|
232
|
+
for (const name of entryNames) {
|
|
233
|
+
const fullPath = path.join(baseDir, name);
|
|
234
|
+
try {
|
|
235
|
+
const stat = await fs.stat(fullPath);
|
|
236
|
+
if (stat.isDirectory())
|
|
237
|
+
topicDirs.push(fullPath);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Skip entries we can't stat
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const dir of topicDirs) {
|
|
244
|
+
const result = await migrateV0ToV1(dir);
|
|
245
|
+
results.push(result);
|
|
246
|
+
}
|
|
247
|
+
const migratedCount = results.filter((r) => r.migrated).length;
|
|
248
|
+
const skippedCount = results.filter((r) => !r.migrated).length;
|
|
249
|
+
return { migratedCount, skippedCount, results };
|
|
250
|
+
}
|
|
251
|
+
// ---- Helpers -----------------------------------------------------------
|
|
252
|
+
/** Map v0 status string to v1 ConceptStatus; default to 'unexplored'. */
|
|
253
|
+
function mapStatus(status) {
|
|
254
|
+
if (status === 'in_progress' || status === 'needs_practice' || status === 'mastered') {
|
|
255
|
+
return status;
|
|
256
|
+
}
|
|
257
|
+
return 'unexplored';
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown parser for v0 knowledge-map.md.
|
|
3
|
+
*
|
|
4
|
+
* Uses unified + remark-parse to extract the hierarchical structure
|
|
5
|
+
* (domains -> concepts -> details) from the v0 knowledge-map format.
|
|
6
|
+
*
|
|
7
|
+
* This is used ONLY during migration (init/update), not at AI runtime.
|
|
8
|
+
*
|
|
9
|
+
* v0 knowledge-map.md format:
|
|
10
|
+
*
|
|
11
|
+
* ```md
|
|
12
|
+
* # Topic Name
|
|
13
|
+
* ## Domain 1
|
|
14
|
+
* - Concept A
|
|
15
|
+
* - Detail A1
|
|
16
|
+
* - Detail A2
|
|
17
|
+
* - Concept B
|
|
18
|
+
* ## Domain 2
|
|
19
|
+
* - Concept C
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import type { ParsedKnowledgeMap } from './types.js';
|
|
23
|
+
/**
|
|
24
|
+
* Parse a v0 knowledge-map.md file content into a structured representation.
|
|
25
|
+
*
|
|
26
|
+
* The parser walks the mdast tree:
|
|
27
|
+
* - `# Title` (h1) -> topic name
|
|
28
|
+
* - `## DomainName` (h2) -> start a new domain
|
|
29
|
+
* - `- Concept` (top-level list item) -> add concept to current domain
|
|
30
|
+
* - ` - Detail` (nested list item) -> add detail to preceding concept
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseKnowledgeMap(markdown: string): ParsedKnowledgeMap;
|
|
33
|
+
//# sourceMappingURL=parser.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown parser for v0 knowledge-map.md.
|
|
3
|
+
*
|
|
4
|
+
* Uses unified + remark-parse to extract the hierarchical structure
|
|
5
|
+
* (domains -> concepts -> details) from the v0 knowledge-map format.
|
|
6
|
+
*
|
|
7
|
+
* This is used ONLY during migration (init/update), not at AI runtime.
|
|
8
|
+
*
|
|
9
|
+
* v0 knowledge-map.md format:
|
|
10
|
+
*
|
|
11
|
+
* ```md
|
|
12
|
+
* # Topic Name
|
|
13
|
+
* ## Domain 1
|
|
14
|
+
* - Concept A
|
|
15
|
+
* - Detail A1
|
|
16
|
+
* - Detail A2
|
|
17
|
+
* - Concept B
|
|
18
|
+
* ## Domain 2
|
|
19
|
+
* - Concept C
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { unified } from 'unified';
|
|
23
|
+
import remarkParse from 'remark-parse';
|
|
24
|
+
// ---- Helpers -----------------------------------------------------------
|
|
25
|
+
/** Recursively extract plain text from an AST node.
|
|
26
|
+
*
|
|
27
|
+
* Does NOT descend into nested `list` nodes — those are structural
|
|
28
|
+
* children (details under a concept) and should not contribute to
|
|
29
|
+
* the parent concept's name.
|
|
30
|
+
*/
|
|
31
|
+
function extractText(node) {
|
|
32
|
+
if (!node || typeof node !== 'object')
|
|
33
|
+
return '';
|
|
34
|
+
const n = node;
|
|
35
|
+
if (n.type === 'text' && typeof n.value === 'string') {
|
|
36
|
+
return n.value;
|
|
37
|
+
}
|
|
38
|
+
// Don't descend into lists: their content belongs to child concepts/details.
|
|
39
|
+
if (n.type === 'list')
|
|
40
|
+
return '';
|
|
41
|
+
if (Array.isArray(n.children)) {
|
|
42
|
+
return n.children.map(extractText).join('');
|
|
43
|
+
}
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
/** Narrow a child node to a specific type. */
|
|
47
|
+
function isHeading(node, depth) {
|
|
48
|
+
if (!node || typeof node !== 'object')
|
|
49
|
+
return false;
|
|
50
|
+
const n = node;
|
|
51
|
+
return n.type === 'heading' && n.depth === depth;
|
|
52
|
+
}
|
|
53
|
+
function isList(node) {
|
|
54
|
+
if (!node || typeof node !== 'object')
|
|
55
|
+
return false;
|
|
56
|
+
return node.type === 'list';
|
|
57
|
+
}
|
|
58
|
+
// ---- Public API ---------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Parse a v0 knowledge-map.md file content into a structured representation.
|
|
61
|
+
*
|
|
62
|
+
* The parser walks the mdast tree:
|
|
63
|
+
* - `# Title` (h1) -> topic name
|
|
64
|
+
* - `## DomainName` (h2) -> start a new domain
|
|
65
|
+
* - `- Concept` (top-level list item) -> add concept to current domain
|
|
66
|
+
* - ` - Detail` (nested list item) -> add detail to preceding concept
|
|
67
|
+
*/
|
|
68
|
+
export function parseKnowledgeMap(markdown) {
|
|
69
|
+
const tree = unified().use(remarkParse).parse(markdown);
|
|
70
|
+
let topic = '';
|
|
71
|
+
const domains = [];
|
|
72
|
+
let currentDomain = null;
|
|
73
|
+
for (const node of tree.children) {
|
|
74
|
+
// # Title
|
|
75
|
+
if (isHeading(node, 1)) {
|
|
76
|
+
topic = extractText(node).trim();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// ## DomainName
|
|
80
|
+
if (isHeading(node, 2)) {
|
|
81
|
+
if (currentDomain)
|
|
82
|
+
domains.push(currentDomain);
|
|
83
|
+
currentDomain = {
|
|
84
|
+
name: extractText(node).trim(),
|
|
85
|
+
concepts: [],
|
|
86
|
+
};
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// - Concept list (only process if we're inside a domain)
|
|
90
|
+
if (isList(node) && currentDomain) {
|
|
91
|
+
const list = node;
|
|
92
|
+
for (const item of list.children) {
|
|
93
|
+
processListItem(item, currentDomain, markdown);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Push the last domain
|
|
98
|
+
if (currentDomain)
|
|
99
|
+
domains.push(currentDomain);
|
|
100
|
+
return { topic, domains };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get list-item text via source position slicing.
|
|
104
|
+
*
|
|
105
|
+
* remark-parse consumes `__init__` as `<strong>init</strong>`, losing the
|
|
106
|
+
* underscores. By slicing the original markdown source at the paragraph's
|
|
107
|
+
* position offsets, we preserve the exact original text including any
|
|
108
|
+
* markdown formatting characters.
|
|
109
|
+
*/
|
|
110
|
+
function extractListItemText(item, source) {
|
|
111
|
+
for (const child of item.children) {
|
|
112
|
+
if (child.type === 'paragraph') {
|
|
113
|
+
const para = child;
|
|
114
|
+
const pos = para.position;
|
|
115
|
+
if (pos?.start && pos?.end) {
|
|
116
|
+
const start = pos.start;
|
|
117
|
+
const end = pos.end;
|
|
118
|
+
if (start.offset !== undefined && end.offset !== undefined) {
|
|
119
|
+
return source.slice(start.offset, end.offset);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Fallback to recursive text extraction
|
|
125
|
+
return extractText(item);
|
|
126
|
+
}
|
|
127
|
+
/** Strip common Markdown escape backslashes (e.g. `\_` → `_`, `\*` → `*`). */
|
|
128
|
+
function unescapeMarkdown(text) {
|
|
129
|
+
return text.replace(/\\([\\`*{}[\]()#+\-.!_>~|])/g, '$1');
|
|
130
|
+
}
|
|
131
|
+
/** Extract a concept (and its optional details) from a list item. */
|
|
132
|
+
function processListItem(item, domain, source) {
|
|
133
|
+
const name = unescapeMarkdown(extractListItemText(item, source).trim());
|
|
134
|
+
if (!name)
|
|
135
|
+
return;
|
|
136
|
+
const concept = { name, children: [] };
|
|
137
|
+
// Look for a nested list inside this list item (these are third-level details)
|
|
138
|
+
for (const child of item.children) {
|
|
139
|
+
if (isList(child)) {
|
|
140
|
+
for (const nestedItem of child.children) {
|
|
141
|
+
const detailName = unescapeMarkdown(extractListItemText(nestedItem, source).trim());
|
|
142
|
+
if (detailName) {
|
|
143
|
+
concept.children.push(detailName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
domain.concepts.push(concept);
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=parser.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const stateV1Schema: z.ZodObject<{
|
|
3
|
+
version: z.ZodLiteral<1>;
|
|
4
|
+
topic: z.ZodString;
|
|
5
|
+
slug: z.ZodString;
|
|
6
|
+
created: z.ZodString;
|
|
7
|
+
domains: z.ZodArray<z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
slug: z.ZodString;
|
|
10
|
+
concepts: z.ZodArray<z.ZodObject<{
|
|
11
|
+
name: z.ZodString;
|
|
12
|
+
slug: z.ZodString;
|
|
13
|
+
status: z.ZodEnum<{
|
|
14
|
+
unexplored: "unexplored";
|
|
15
|
+
in_progress: "in_progress";
|
|
16
|
+
needs_practice: "needs_practice";
|
|
17
|
+
mastered: "mastered";
|
|
18
|
+
}>;
|
|
19
|
+
confidence: z.ZodNumber;
|
|
20
|
+
practice_count: z.ZodNumber;
|
|
21
|
+
explain_count: z.ZodNumber;
|
|
22
|
+
last_explained: z.ZodNullable<z.ZodString>;
|
|
23
|
+
last_practiced: z.ZodNullable<z.ZodString>;
|
|
24
|
+
details: z.ZodArray<z.ZodString>;
|
|
25
|
+
}, z.core.$strip>>;
|
|
26
|
+
}, z.core.$strip>>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type StateV1Schema = z.infer<typeof stateV1Schema>;
|
|
29
|
+
export type ValidationResult = {
|
|
30
|
+
success: true;
|
|
31
|
+
data: StateV1Schema;
|
|
32
|
+
} | {
|
|
33
|
+
success: false;
|
|
34
|
+
errors: z.ZodIssue[];
|
|
35
|
+
};
|
|
36
|
+
/** Validate an unknown value against the StateV1 schema. */
|
|
37
|
+
export declare function validateStateV1(value: unknown): ValidationResult;
|
|
38
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// ---- Helpers -----------------------------------------------------------
|
|
3
|
+
/** Datetime: YYYY-MM-DD or YYYY-MM-DD HH:mm:ss. */
|
|
4
|
+
const dateTimeStr = () => z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/, 'Expected YYYY-MM-DD or YYYY-MM-DD HH:mm:ss');
|
|
7
|
+
const nullableDateTimeStr = () => dateTimeStr().nullable();
|
|
8
|
+
// ---- Concept schema ----------------------------------------------------
|
|
9
|
+
const conceptSchema = z.object({
|
|
10
|
+
name: z.string().min(1),
|
|
11
|
+
slug: z.string().min(1),
|
|
12
|
+
status: z.enum(['unexplored', 'in_progress', 'needs_practice', 'mastered']),
|
|
13
|
+
confidence: z.number().min(0).max(1),
|
|
14
|
+
practice_count: z.number().int().min(0),
|
|
15
|
+
explain_count: z.number().int().min(0),
|
|
16
|
+
last_explained: nullableDateTimeStr(),
|
|
17
|
+
last_practiced: nullableDateTimeStr(),
|
|
18
|
+
details: z.array(z.string()),
|
|
19
|
+
});
|
|
20
|
+
// ---- Domain schema -----------------------------------------------------
|
|
21
|
+
const domainSchema = z.object({
|
|
22
|
+
name: z.string().min(1),
|
|
23
|
+
slug: z.string().min(1),
|
|
24
|
+
concepts: z.array(conceptSchema),
|
|
25
|
+
});
|
|
26
|
+
// ---- Top-level StateV1 schema ------------------------------------------
|
|
27
|
+
export const stateV1Schema = z.object({
|
|
28
|
+
version: z.literal(1),
|
|
29
|
+
topic: z.string().min(1),
|
|
30
|
+
slug: z.string().min(1),
|
|
31
|
+
created: dateTimeStr(),
|
|
32
|
+
domains: z.array(domainSchema),
|
|
33
|
+
});
|
|
34
|
+
// ---- Public API ---------------------------------------------------------
|
|
35
|
+
/** Validate an unknown value against the StateV1 schema. */
|
|
36
|
+
export function validateStateV1(value) {
|
|
37
|
+
const result = stateV1Schema.safeParse(value);
|
|
38
|
+
if (result.success) {
|
|
39
|
+
return { success: true, data: result.data };
|
|
40
|
+
}
|
|
41
|
+
return { success: false, errors: result.error.issues };
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a stable kebab-case slug from a human-readable name.
|
|
3
|
+
*
|
|
4
|
+
* Rules (applied in order):
|
|
5
|
+
* 1. Replace `/` with `-`
|
|
6
|
+
* 2. Replace spaces with `-`
|
|
7
|
+
* 3. Remove characters that are NOT letters, digits, `-`, or `_`
|
|
8
|
+
* 4. Convert ASCII letters to lowercase; preserve non-ASCII characters
|
|
9
|
+
* 5. Collapse consecutive `-` into a single `-`
|
|
10
|
+
* 6. Trim leading / trailing `-`
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateSlug(name: string): string;
|
|
13
|
+
//# sourceMappingURL=slug.d.ts.map
|