oh-my-llmwikimode 1.0.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/LICENSE +21 -0
- package/README.md +494 -0
- package/bin/llmwiki.js +1493 -0
- package/docs/INSTALLATION.md +228 -0
- package/docs/SCOPE_LOCK.md +79 -0
- package/docs/STAGE1_GUIDE.md +265 -0
- package/docs/STAGE2_AGENT_TEAM_GUIDE.md +141 -0
- package/docs/STAGE3_CONVERSATIONAL_GROWTH_GUIDE.md +50 -0
- package/docs/TEST_WORKSHEET.md +120 -0
- package/docs/github-private-bootstrap.md +53 -0
- package/docs/release.md +79 -0
- package/docs/stage4-slice1-manual-test.md +259 -0
- package/docs/stage4-slice1-user-guide.md +269 -0
- package/docs/user-guide-ko.md +452 -0
- package/package.json +76 -0
- package/scripts/install-llmwiki.ps1 +229 -0
- package/src/config.js +74 -0
- package/src/curator/browser-data.js +134 -0
- package/src/curator/queue.js +324 -0
- package/src/curator/schema.js +237 -0
- package/src/curator/scoring.js +83 -0
- package/src/hooks.js +199 -0
- package/src/librarian/schema.js +218 -0
- package/src/librarian/weekly-digest.js +478 -0
- package/src/security.js +127 -0
- package/src/server.js +860 -0
- package/src/stage4/graph-reasoning/analyzer.js +255 -0
- package/src/stage4/graph-reasoning/browser-data.js +130 -0
- package/src/stage4/graph-reasoning/index.js +35 -0
- package/src/stage4/graph-reasoning/loader.js +122 -0
- package/src/stage4/graph-reasoning/queue.js +154 -0
- package/src/stage4/graph-reasoning/schema.js +190 -0
- package/src/team/browser-data.js +142 -0
- package/src/team/capabilities.js +79 -0
- package/src/team/dispatch.js +108 -0
- package/src/team/queue.js +290 -0
- package/src/team/schema.js +225 -0
- package/src/team/shared-memory.js +183 -0
- package/src/todo/browser-data.js +71 -0
- package/src/todo/queue.js +159 -0
- package/src/todo/schema.js +90 -0
- package/src/utils/embedding-model.js +111 -0
- package/src/wiki/alias-suggestions.js +180 -0
- package/src/wiki/browser-data.js +284 -0
- package/src/wiki/doctor.js +218 -0
- package/src/wiki/entry-normalizer.js +139 -0
- package/src/wiki/ingest.js +443 -0
- package/src/wiki/lesson-proposal-analyzer.js +463 -0
- package/src/wiki/lesson-proposal-manager.js +331 -0
- package/src/wiki/lesson-template.js +182 -0
- package/src/wiki/lint.js +294 -0
- package/src/wiki/notebooklm-adapter.js +264 -0
- package/src/wiki/query.js +304 -0
- package/src/wiki/raw-manager.js +400 -0
- package/src/wiki/search-feedback.js +211 -0
- package/src/wiki/semantic-index.js +333 -0
- package/src/wiki/semantic-search.js +170 -0
- package/src/wiki/source-ledger.js +370 -0
- package/src/wiki/store.js +1329 -0
- package/src/wiki/usage-events.js +144 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B1 Auto Lesson Proposal — Proposal Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages lesson proposal artifacts:
|
|
5
|
+
* - list proposals
|
|
6
|
+
* - get a specific proposal
|
|
7
|
+
* - apply a proposal (user-approved promotion to lesson)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { getWikiPaths, ensureWikiStructure } from "./store.js";
|
|
13
|
+
import { writeLessonTemplate, sanitizeYamlString } from "./lesson-template.js";
|
|
14
|
+
import { PROPOSAL_DIR } from "./lesson-proposal-analyzer.js";
|
|
15
|
+
|
|
16
|
+
function compareStrings(left, right) {
|
|
17
|
+
return String(left ?? "").localeCompare(String(right ?? ""));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureProposalDir(wikiRoot) {
|
|
21
|
+
const dir = path.join(wikiRoot, PROPOSAL_DIR);
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const VALID_ID_RE = /^lp_[A-Za-z0-9_-]+$/;
|
|
27
|
+
|
|
28
|
+
function isValidProposalId(id) {
|
|
29
|
+
return typeof id === "string" && VALID_ID_RE.test(id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ALLOWED_ENTRY_PREFIXES = ["inbox", "problems", "editorial/lessons"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns true only when targetPath is a descendant of basePath.
|
|
36
|
+
* Equality is rejected because callers expect a concrete entry path, not the root itself.
|
|
37
|
+
*/
|
|
38
|
+
function isStrictlyInsidePath(basePath, targetPath) {
|
|
39
|
+
const relative = path.relative(basePath, targetPath);
|
|
40
|
+
return (
|
|
41
|
+
relative !== "" &&
|
|
42
|
+
relative !== ".." &&
|
|
43
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
44
|
+
!path.isAbsolute(relative)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validates that an entry path is safe to read within the wiki root.
|
|
50
|
+
*
|
|
51
|
+
* Checks:
|
|
52
|
+
* - Path is a non-empty string
|
|
53
|
+
* - Path is relative (not absolute)
|
|
54
|
+
* - Path is within allowed directories (inbox/, problems/, editorial/lessons/)
|
|
55
|
+
* - Path stays within wiki root before and after symlink resolution
|
|
56
|
+
* - Path does not point to a symlink (only when the file exists)
|
|
57
|
+
*
|
|
58
|
+
* @param {string} wikiRoot - Absolute path to wiki root
|
|
59
|
+
* @param {string} entryPath - Relative entry path to validate
|
|
60
|
+
* @returns {{safe: boolean, error?: string, resolvedPath?: string}}
|
|
61
|
+
*/
|
|
62
|
+
function validateEntryPath(wikiRoot, entryPath) {
|
|
63
|
+
if (!entryPath || typeof entryPath !== "string") {
|
|
64
|
+
return { safe: false, error: "Entry path must be a non-empty string" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (path.isAbsolute(entryPath)) {
|
|
68
|
+
return { safe: false, error: `Entry path must be relative, got absolute: ${entryPath}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check allowed prefix first (before resolution, for clear error messages)
|
|
72
|
+
const normalizedEntry = entryPath.replace(/\\/g, "/");
|
|
73
|
+
const hasAllowedPrefix = ALLOWED_ENTRY_PREFIXES.some(
|
|
74
|
+
(prefix) => normalizedEntry === prefix || normalizedEntry.startsWith(prefix + "/")
|
|
75
|
+
);
|
|
76
|
+
if (!hasAllowedPrefix) {
|
|
77
|
+
return {
|
|
78
|
+
safe: false,
|
|
79
|
+
error: `Entry path must be within allowed directories (inbox, problems, editorial/lessons): ${entryPath}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const absoluteWikiRoot = path.resolve(wikiRoot);
|
|
84
|
+
const resolvedPath = path.resolve(absoluteWikiRoot, entryPath);
|
|
85
|
+
|
|
86
|
+
if (!isStrictlyInsidePath(absoluteWikiRoot, resolvedPath)) {
|
|
87
|
+
return { safe: false, error: `Entry path resolves outside wiki root before symlink resolution: ${entryPath}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let realWikiRoot;
|
|
91
|
+
try {
|
|
92
|
+
realWikiRoot = fs.realpathSync(absoluteWikiRoot);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
safe: false,
|
|
96
|
+
error: `Wiki root cannot be resolved safely: ${wikiRoot} (${error.message})`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let realResolvedPath;
|
|
101
|
+
try {
|
|
102
|
+
realResolvedPath = fs.realpathSync(resolvedPath);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return {
|
|
105
|
+
safe: false,
|
|
106
|
+
error: `Entry path does not exist or cannot be resolved safely: ${entryPath} (${error.message})`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!isStrictlyInsidePath(realWikiRoot, realResolvedPath)) {
|
|
111
|
+
return { safe: false, error: `Entry path resolves outside wiki root after symlink resolution: ${entryPath}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const realRelative = path.relative(realWikiRoot, realResolvedPath).replace(/\\/g, "/");
|
|
115
|
+
const hasAllowedRealPrefix = ALLOWED_ENTRY_PREFIXES.some(
|
|
116
|
+
(prefix) => realRelative === prefix || realRelative.startsWith(prefix + "/")
|
|
117
|
+
);
|
|
118
|
+
if (!hasAllowedRealPrefix) {
|
|
119
|
+
return {
|
|
120
|
+
safe: false,
|
|
121
|
+
error: `Entry path resolves outside allowed directories after symlink resolution: ${entryPath}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Reject direct symlink entries. Parent symlink escapes are handled by realpath checks above.
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.lstatSync(resolvedPath);
|
|
128
|
+
if (stat.isSymbolicLink()) {
|
|
129
|
+
return { safe: false, error: `Entry path is a symlink, rejected for security: ${entryPath}` };
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return {
|
|
133
|
+
safe: false,
|
|
134
|
+
error: `Entry path cannot be inspected safely: ${entryPath} (${error.message})`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { safe: true, resolvedPath: realResolvedPath };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* List all lesson proposals.
|
|
144
|
+
*/
|
|
145
|
+
export function listProposals(wikiRoot) {
|
|
146
|
+
try {
|
|
147
|
+
const dir = path.join(wikiRoot, PROPOSAL_DIR);
|
|
148
|
+
if (!fs.existsSync(dir)) {
|
|
149
|
+
return { success: true, proposals: [], count: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const files = fs.readdirSync(dir)
|
|
153
|
+
.filter((f) => f.endsWith(".json"))
|
|
154
|
+
.sort(compareStrings);
|
|
155
|
+
|
|
156
|
+
const proposals = [];
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
try {
|
|
159
|
+
const content = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
160
|
+
proposals.push({
|
|
161
|
+
id: content.id,
|
|
162
|
+
status: content.status,
|
|
163
|
+
proposed_title: content.proposed_title,
|
|
164
|
+
created_at: content.created_at,
|
|
165
|
+
evidence: content.evidence,
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
// Skip malformed
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
proposals.sort((a, b) => compareStrings(a.created_at, b.created_at));
|
|
173
|
+
|
|
174
|
+
return { success: true, proposals, count: proposals.length };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return { success: false, error: error.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get a specific proposal by ID.
|
|
182
|
+
*/
|
|
183
|
+
export function getProposal(wikiRoot, id) {
|
|
184
|
+
try {
|
|
185
|
+
if (!id || typeof id !== "string") {
|
|
186
|
+
return { success: false, error: "Proposal ID is required" };
|
|
187
|
+
}
|
|
188
|
+
if (!isValidProposalId(id)) {
|
|
189
|
+
return { success: false, error: `Invalid proposal ID: ${id}` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const filePath = path.join(wikiRoot, PROPOSAL_DIR, `${id}.json`);
|
|
193
|
+
if (!fs.existsSync(filePath)) {
|
|
194
|
+
return { success: false, error: `Proposal not found: ${id}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
198
|
+
return { success: true, proposal: content };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { success: false, error: error.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply a proposal — user-approved promotion to lesson.
|
|
206
|
+
*
|
|
207
|
+
* This creates a new lesson file in editorial/lessons/ using the lesson template.
|
|
208
|
+
* It does NOT modify the original entry/entries.
|
|
209
|
+
* The proposal artifact is preserved but its status changes to "applied".
|
|
210
|
+
*/
|
|
211
|
+
export function applyProposal(wikiRoot, id, options = {}) {
|
|
212
|
+
try {
|
|
213
|
+
if (!id || typeof id !== "string") {
|
|
214
|
+
return { success: false, error: "Proposal ID is required" };
|
|
215
|
+
}
|
|
216
|
+
if (!isValidProposalId(id)) {
|
|
217
|
+
return { success: false, error: `Invalid proposal ID: ${id}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const proposalResult = getProposal(wikiRoot, id);
|
|
221
|
+
if (!proposalResult.success) {
|
|
222
|
+
return proposalResult;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const proposal = proposalResult.proposal;
|
|
226
|
+
|
|
227
|
+
if (proposal.status === "applied") {
|
|
228
|
+
return { success: false, error: "Proposal already applied" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (proposal.status !== "review_required") {
|
|
232
|
+
return { success: false, error: `Cannot apply proposal with status: ${proposal.status}` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Build lesson from proposal (sanitize title for defense-in-depth)
|
|
236
|
+
const rawTitle = proposal.proposed_title || "Untitled Lesson";
|
|
237
|
+
const title = sanitizeYamlString(rawTitle) || "Untitled Lesson";
|
|
238
|
+
const sections = proposal.template_sections || {};
|
|
239
|
+
|
|
240
|
+
// Extract tags from evidence entries if available
|
|
241
|
+
const tags = [];
|
|
242
|
+
if (proposal.evidence?.entry_paths?.length > 0) {
|
|
243
|
+
// Try to read tags from referenced entries (skip unsafe paths)
|
|
244
|
+
for (const entryPath of proposal.evidence.entry_paths) {
|
|
245
|
+
const validation = validateEntryPath(wikiRoot, entryPath);
|
|
246
|
+
if (!validation.safe) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
if (fs.existsSync(validation.resolvedPath)) {
|
|
251
|
+
const content = fs.readFileSync(validation.resolvedPath, "utf-8");
|
|
252
|
+
const tagMatch = content.match(/^tags:\s*\n((?:\s+-\s.+\n?)+)/m);
|
|
253
|
+
if (tagMatch) {
|
|
254
|
+
const entryTags = tagMatch[1]
|
|
255
|
+
.split("\n")
|
|
256
|
+
.map((line) => line.trim().replace(/^-\s*/, ""))
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
tags.push(...entryTags);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// Ignore read errors
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const uniqueTags = [...new Set(tags)].slice(0, 10);
|
|
268
|
+
|
|
269
|
+
// Deterministic fallback chain: explicit override > proposal timestamp > evidence timestamps > empty.
|
|
270
|
+
const now = options.now
|
|
271
|
+
? new Date(options.now).toISOString()
|
|
272
|
+
: (proposal.created_at || proposal.evidence?.last_seen || proposal.evidence?.first_seen || "");
|
|
273
|
+
|
|
274
|
+
// Collision-safe title: append proposal id suffix so repeated applies
|
|
275
|
+
// of the same conceptual title never overwrite an existing lesson.
|
|
276
|
+
const safeTitle = `${title} (${id})`;
|
|
277
|
+
|
|
278
|
+
// Pre-compute lesson path to check for existing file before writing.
|
|
279
|
+
const lessonsDir = path.join(wikiRoot, "editorial", "lessons");
|
|
280
|
+
const safeName = safeTitle
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9\p{L}\p{N}]+/gu, "-")
|
|
283
|
+
.replace(/^-+|-+$/g, "");
|
|
284
|
+
const lessonFilePath = path.join(lessonsDir, `${safeName}.md`);
|
|
285
|
+
|
|
286
|
+
if (fs.existsSync(lessonFilePath)) {
|
|
287
|
+
return { success: false, error: `Lesson file already exists: ${lessonFilePath}` };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Write lesson using template
|
|
291
|
+
const lessonResult = writeLessonTemplate(wikiRoot, {
|
|
292
|
+
title: safeTitle,
|
|
293
|
+
tags: uniqueTags,
|
|
294
|
+
source: "lesson-proposal",
|
|
295
|
+
summary: `Auto-generated from usage pattern (${proposal.evidence?.usage_count || 0} events)`,
|
|
296
|
+
sections: {
|
|
297
|
+
whenToUse: sections.when_to_use || sections.whenToUse || "",
|
|
298
|
+
principles: sections.principles || "",
|
|
299
|
+
checklist: Array.isArray(sections.checklist) ? sections.checklist.join("\n") : sections.checklist || "",
|
|
300
|
+
examples: sections.examples || "",
|
|
301
|
+
failurePatterns: sections.failure_patterns || sections.failurePatterns || "",
|
|
302
|
+
},
|
|
303
|
+
createdAt: now,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (!lessonResult.success) {
|
|
307
|
+
return lessonResult;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Update proposal status to applied (preserve artifact)
|
|
311
|
+
const updatedProposal = {
|
|
312
|
+
...proposal,
|
|
313
|
+
status: "applied",
|
|
314
|
+
applied_at: now,
|
|
315
|
+
applied_by: options.actor || "cli",
|
|
316
|
+
lesson_path: lessonResult.path,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const proposalPath = path.join(wikiRoot, PROPOSAL_DIR, `${id}.json`);
|
|
320
|
+
fs.writeFileSync(proposalPath, JSON.stringify(updatedProposal, null, 2), "utf-8");
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
success: true,
|
|
324
|
+
proposal_id: id,
|
|
325
|
+
lesson_path: lessonResult.path,
|
|
326
|
+
message: `Applied proposal ${id} to ${lessonResult.path}`,
|
|
327
|
+
};
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return { success: false, error: error.message };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* B1 Agent School — Lesson Template Contract
|
|
6
|
+
*
|
|
7
|
+
* Generates deterministic lesson Markdown with the five required sections:
|
|
8
|
+
* 1. When to use
|
|
9
|
+
* 2. Principles
|
|
10
|
+
* 3. Checklist
|
|
11
|
+
* 4. Examples
|
|
12
|
+
* 5. Failure patterns
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const LESSON_SECTIONS = [
|
|
16
|
+
"when-to-use",
|
|
17
|
+
"principles",
|
|
18
|
+
"checklist",
|
|
19
|
+
"examples",
|
|
20
|
+
"failure-patterns",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const LESSON_TEMPLATE_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sanitize a string for safe YAML frontmatter use.
|
|
27
|
+
* - Replaces newlines with spaces
|
|
28
|
+
* - Strips control characters (except tab)
|
|
29
|
+
* - Trims whitespace
|
|
30
|
+
*
|
|
31
|
+
* @param {string} value
|
|
32
|
+
* @returns {string} Cleaned text value
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeYamlString(value) {
|
|
35
|
+
const str = String(value);
|
|
36
|
+
return str
|
|
37
|
+
.replace(/\r\n|\r|\n/g, " ")
|
|
38
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, " ")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert a value to its YAML-safe representation for frontmatter output.
|
|
44
|
+
* Sanitizes the input, then applies double-quoting if the result contains
|
|
45
|
+
* characters unsafe for bare YAML scalars (e.g. ": ", "#", leading special chars).
|
|
46
|
+
*
|
|
47
|
+
* @param {*} value
|
|
48
|
+
* @returns {string} YAML-safe value string
|
|
49
|
+
*/
|
|
50
|
+
function toYamlValue(value) {
|
|
51
|
+
const clean = sanitizeYamlString(value);
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
clean === "" ||
|
|
55
|
+
/^[:\-#?\[\]{}&*!|>'"]/.test(clean) ||
|
|
56
|
+
clean.includes(": ") ||
|
|
57
|
+
clean.includes(" #")
|
|
58
|
+
) {
|
|
59
|
+
const escaped = clean.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
60
|
+
return `"${escaped}"`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return clean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a lesson template as Markdown string.
|
|
68
|
+
*
|
|
69
|
+
* @param {Object} options
|
|
70
|
+
* @param {string} options.title - Lesson title (required)
|
|
71
|
+
* @param {string[]} options.tags - Tags array
|
|
72
|
+
* @param {string} options.source - Source identifier
|
|
73
|
+
* @param {string} [options.summary] - Short summary
|
|
74
|
+
* @param {Object} [options.sections] - Section content overrides
|
|
75
|
+
* @param {string} [options.sections.whenToUse]
|
|
76
|
+
* @param {string} [options.sections.principles]
|
|
77
|
+
* @param {string} [options.sections.checklist]
|
|
78
|
+
* @param {string} [options.sections.examples]
|
|
79
|
+
* @param {string} [options.sections.failurePatterns]
|
|
80
|
+
* @param {string} [options.createdAt] - ISO timestamp
|
|
81
|
+
* @returns {string} Markdown content
|
|
82
|
+
*/
|
|
83
|
+
export function renderLessonTemplate(options = {}) {
|
|
84
|
+
const title = sanitizeYamlString(options.title || "Untitled Lesson") || "Untitled Lesson";
|
|
85
|
+
const tags = Array.isArray(options.tags)
|
|
86
|
+
? options.tags.map((tag) => sanitizeYamlString(tag))
|
|
87
|
+
: [];
|
|
88
|
+
const source = sanitizeYamlString(options.source || "manual") || "manual";
|
|
89
|
+
const summary = sanitizeYamlString(options.summary || "");
|
|
90
|
+
const sections = options.sections || {};
|
|
91
|
+
const createdAt = sanitizeYamlString(options.createdAt || "");
|
|
92
|
+
|
|
93
|
+
const frontmatter = {
|
|
94
|
+
title,
|
|
95
|
+
tags,
|
|
96
|
+
source,
|
|
97
|
+
status: "lesson",
|
|
98
|
+
kind: "lesson",
|
|
99
|
+
created_at: createdAt,
|
|
100
|
+
updated_at: createdAt,
|
|
101
|
+
lesson_template_version: LESSON_TEMPLATE_VERSION,
|
|
102
|
+
...(summary ? { summary } : {}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const fmLines = ["---"];
|
|
106
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
fmLines.push(`${key}:`);
|
|
109
|
+
for (const item of value) {
|
|
110
|
+
fmLines.push(` - ${toYamlValue(item)}`);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
fmLines.push(`${key}: ${toYamlValue(value)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
fmLines.push("---");
|
|
117
|
+
fmLines.push("");
|
|
118
|
+
|
|
119
|
+
const sectionContent = {
|
|
120
|
+
"when-to-use": sections.whenToUse || sections["when-to-use"] || "",
|
|
121
|
+
principles: sections.principles || "",
|
|
122
|
+
checklist: sections.checklist || "",
|
|
123
|
+
examples: sections.examples || "",
|
|
124
|
+
"failure-patterns": sections.failurePatterns || sections["failure-patterns"] || "",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const bodyLines = [];
|
|
128
|
+
for (const sectionKey of LESSON_SECTIONS) {
|
|
129
|
+
const heading = sectionKey
|
|
130
|
+
.split("-")
|
|
131
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
132
|
+
.join(" ");
|
|
133
|
+
bodyLines.push(`## ${heading}`);
|
|
134
|
+
bodyLines.push("");
|
|
135
|
+
const content = sectionContent[sectionKey];
|
|
136
|
+
if (content) {
|
|
137
|
+
bodyLines.push(content);
|
|
138
|
+
} else {
|
|
139
|
+
bodyLines.push("_(Add content here)_");
|
|
140
|
+
}
|
|
141
|
+
bodyLines.push("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [...fmLines, ...bodyLines].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Write a lesson template to the editorial/lessons/ directory.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} wikiRoot
|
|
151
|
+
* @param {Object} options - Same as renderLessonTemplate
|
|
152
|
+
* @returns {{success: boolean, path?: string, error?: string}}
|
|
153
|
+
*/
|
|
154
|
+
export function writeLessonTemplate(wikiRoot, options = {}) {
|
|
155
|
+
try {
|
|
156
|
+
const title = String(options.title || "untitled").trim();
|
|
157
|
+
if (!title || title === "untitled") {
|
|
158
|
+
return { success: false, error: "Lesson title is required" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lessonsDir = path.join(wikiRoot, "editorial", "lessons");
|
|
162
|
+
fs.mkdirSync(lessonsDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const safeName = title
|
|
165
|
+
.toLowerCase()
|
|
166
|
+
.replace(/[^a-z0-9\p{L}\p{N}]+/gu, "-")
|
|
167
|
+
.replace(/^-+|-+$/g, "");
|
|
168
|
+
const filename = `${safeName}.md`;
|
|
169
|
+
const filePath = path.join(lessonsDir, filename);
|
|
170
|
+
|
|
171
|
+
const content = renderLessonTemplate(options);
|
|
172
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
success: true,
|
|
176
|
+
path: `editorial/lessons/${filename}`,
|
|
177
|
+
fullPath: filePath,
|
|
178
|
+
};
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { success: false, error: error.message };
|
|
181
|
+
}
|
|
182
|
+
}
|