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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -0
  3. package/bin/llmwiki.js +1493 -0
  4. package/docs/INSTALLATION.md +228 -0
  5. package/docs/SCOPE_LOCK.md +79 -0
  6. package/docs/STAGE1_GUIDE.md +265 -0
  7. package/docs/STAGE2_AGENT_TEAM_GUIDE.md +141 -0
  8. package/docs/STAGE3_CONVERSATIONAL_GROWTH_GUIDE.md +50 -0
  9. package/docs/TEST_WORKSHEET.md +120 -0
  10. package/docs/github-private-bootstrap.md +53 -0
  11. package/docs/release.md +79 -0
  12. package/docs/stage4-slice1-manual-test.md +259 -0
  13. package/docs/stage4-slice1-user-guide.md +269 -0
  14. package/docs/user-guide-ko.md +452 -0
  15. package/package.json +76 -0
  16. package/scripts/install-llmwiki.ps1 +229 -0
  17. package/src/config.js +74 -0
  18. package/src/curator/browser-data.js +134 -0
  19. package/src/curator/queue.js +324 -0
  20. package/src/curator/schema.js +237 -0
  21. package/src/curator/scoring.js +83 -0
  22. package/src/hooks.js +199 -0
  23. package/src/librarian/schema.js +218 -0
  24. package/src/librarian/weekly-digest.js +478 -0
  25. package/src/security.js +127 -0
  26. package/src/server.js +860 -0
  27. package/src/stage4/graph-reasoning/analyzer.js +255 -0
  28. package/src/stage4/graph-reasoning/browser-data.js +130 -0
  29. package/src/stage4/graph-reasoning/index.js +35 -0
  30. package/src/stage4/graph-reasoning/loader.js +122 -0
  31. package/src/stage4/graph-reasoning/queue.js +154 -0
  32. package/src/stage4/graph-reasoning/schema.js +190 -0
  33. package/src/team/browser-data.js +142 -0
  34. package/src/team/capabilities.js +79 -0
  35. package/src/team/dispatch.js +108 -0
  36. package/src/team/queue.js +290 -0
  37. package/src/team/schema.js +225 -0
  38. package/src/team/shared-memory.js +183 -0
  39. package/src/todo/browser-data.js +71 -0
  40. package/src/todo/queue.js +159 -0
  41. package/src/todo/schema.js +90 -0
  42. package/src/utils/embedding-model.js +111 -0
  43. package/src/wiki/alias-suggestions.js +180 -0
  44. package/src/wiki/browser-data.js +284 -0
  45. package/src/wiki/doctor.js +218 -0
  46. package/src/wiki/entry-normalizer.js +139 -0
  47. package/src/wiki/ingest.js +443 -0
  48. package/src/wiki/lesson-proposal-analyzer.js +463 -0
  49. package/src/wiki/lesson-proposal-manager.js +331 -0
  50. package/src/wiki/lesson-template.js +182 -0
  51. package/src/wiki/lint.js +294 -0
  52. package/src/wiki/notebooklm-adapter.js +264 -0
  53. package/src/wiki/query.js +304 -0
  54. package/src/wiki/raw-manager.js +400 -0
  55. package/src/wiki/search-feedback.js +211 -0
  56. package/src/wiki/semantic-index.js +333 -0
  57. package/src/wiki/semantic-search.js +170 -0
  58. package/src/wiki/source-ledger.js +370 -0
  59. package/src/wiki/store.js +1329 -0
  60. 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
+ }