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,324 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { parseFrontmatter } from "../wiki/store.js";
5
+ import { scoreConsolidationProposal, scoreLessonCandidate } from "./scoring.js";
6
+ import {
7
+ CURATOR_REVIEW_STATUS,
8
+ DEFAULT_BUILT_AT,
9
+ normalizeCuratorPath,
10
+ validateAuditRecord,
11
+ validateConsolidationProposal,
12
+ validateCuratorQueueCard,
13
+ validateLessonCandidateArtifact,
14
+ } from "./schema.js";
15
+
16
+ const ENTRY_PREFIXES = ["inbox/", "problems/", "editorial/lessons/"];
17
+
18
+ function nowIso(options = {}) {
19
+ return options.now || new Date().toISOString();
20
+ }
21
+
22
+ function compareStrings(left, right) {
23
+ return String(left ?? "").localeCompare(String(right ?? ""));
24
+ }
25
+
26
+ function atomicWriteJson(filePath, data) {
27
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
28
+ const tmpFile = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
29
+ fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2));
30
+ fs.renameSync(tmpFile, filePath);
31
+ }
32
+
33
+ function safeId(value, fallback = "curator") {
34
+ return String(value || fallback)
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9_-]+/g, "-")
37
+ .replace(/^-+|-+$/g, "")
38
+ .slice(0, 80) || fallback;
39
+ }
40
+
41
+ function shortHash(value) {
42
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 12);
43
+ }
44
+
45
+ function contentHash(content) {
46
+ return crypto.createHash("sha256").update(content).digest("hex");
47
+ }
48
+
49
+ function isInsideDirectory(parentDirectory, candidatePath) {
50
+ const relativePath = path.relative(parentDirectory, candidatePath);
51
+ return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
52
+ }
53
+
54
+ function normalizeQueue(queue) {
55
+ const cards = [];
56
+ for (const card of Array.isArray(queue?.cards) ? queue.cards : []) {
57
+ const validation = validateCuratorQueueCard(card);
58
+ if (validation.valid) cards.push(validation.value);
59
+ }
60
+ return {
61
+ version: 1,
62
+ cards: [...new Map(cards.map((card) => [card.id, card])).values()]
63
+ .sort((left, right) => compareStrings(`${left.created_at}:${left.id}`, `${right.created_at}:${right.id}`)),
64
+ meta: { built_at: queue?.meta?.built_at || DEFAULT_BUILT_AT },
65
+ };
66
+ }
67
+
68
+ function createEmptyQueue() {
69
+ return { version: 1, cards: [], meta: { built_at: DEFAULT_BUILT_AT } };
70
+ }
71
+
72
+ function writeQueue(wikiRoot, queue, options = {}) {
73
+ const normalized = normalizeQueue({ ...queue, meta: { built_at: nowIso(options) } });
74
+ atomicWriteJson(getCuratorPaths(wikiRoot).queueFile, normalized);
75
+ return normalized;
76
+ }
77
+
78
+ function result(success, payload = {}) {
79
+ return { success, ...payload };
80
+ }
81
+
82
+ function resolveEntry(wikiRoot, entryPath) {
83
+ const normalizedPath = normalizeCuratorPath(entryPath, "entry path");
84
+ if (!normalizedPath.endsWith(".md")) return result(false, { error: "Only Markdown wiki entries are supported" });
85
+ if (!ENTRY_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
86
+ return result(false, { error: "Entry must be under inbox/, problems/, or editorial/lessons/" });
87
+ }
88
+
89
+ const rootPath = path.resolve(wikiRoot);
90
+ const fullPath = path.resolve(rootPath, normalizedPath);
91
+ if (!isInsideDirectory(rootPath, fullPath)) return result(false, { error: "Entry path escapes wiki root" });
92
+ if (!fs.existsSync(fullPath)) return result(false, { error: `Entry not found: ${normalizedPath}` });
93
+ if (fs.lstatSync(fullPath).isSymbolicLink()) return result(false, { error: "Symbolic link entries are not supported" });
94
+ const realRoot = fs.realpathSync(rootPath);
95
+ const realFullPath = fs.realpathSync(fullPath);
96
+ if (!isInsideDirectory(realRoot, realFullPath)) return result(false, { error: "Entry path resolves outside wiki root" });
97
+
98
+ return result(true, { path: normalizedPath, fullPath });
99
+ }
100
+
101
+ function readEntryRef(wikiRoot, entryPath) {
102
+ const resolved = resolveEntry(wikiRoot, entryPath);
103
+ if (!resolved.success) return resolved;
104
+ const content = fs.readFileSync(resolved.fullPath, "utf-8");
105
+ const { frontmatter } = parseFrontmatter(content);
106
+ return result(true, {
107
+ ref: {
108
+ path: resolved.path,
109
+ title: frontmatter.title || path.basename(resolved.path, ".md"),
110
+ status: frontmatter.status || "candidate",
111
+ source: frontmatter.source || "manual",
112
+ tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : typeof frontmatter.tags === "string" ? [frontmatter.tags] : [],
113
+ summary: frontmatter.summary || frontmatter.title || "",
114
+ created_at: frontmatter.created_at || "",
115
+ updated_at: frontmatter.updated_at || frontmatter.created_at || "",
116
+ seen_count: frontmatter.seen_count || 0,
117
+ backlink_count: frontmatter.backlink_count || 0,
118
+ confidence_score: typeof frontmatter.confidence_score === "number" ? frontmatter.confidence_score : 1,
119
+ content_sha256: contentHash(content),
120
+ },
121
+ });
122
+ }
123
+
124
+ function readEntryRefs(wikiRoot, entryPaths) {
125
+ const refs = [];
126
+ for (const entryPath of [...new Set(entryPaths)].sort(compareStrings)) {
127
+ const readResult = readEntryRef(wikiRoot, entryPath);
128
+ if (!readResult.success) return readResult;
129
+ refs.push(readResult.ref);
130
+ }
131
+ return result(true, { refs });
132
+ }
133
+
134
+ function appendAudit(wikiRoot, action, entry = {}, options = {}) {
135
+ const validation = validateAuditRecord({
136
+ timestamp: nowIso(options),
137
+ action,
138
+ actor: options.actor || "system",
139
+ executed: false,
140
+ ...entry,
141
+ });
142
+ if (!validation.valid) return result(false, { error: validation.errors.join("; ") });
143
+ const paths = getCuratorPaths(wikiRoot);
144
+ fs.mkdirSync(paths.root, { recursive: true });
145
+ fs.appendFileSync(paths.auditFile, `${JSON.stringify(validation.value)}\n`);
146
+ return result(true, { audit: validation.value });
147
+ }
148
+
149
+ function upsertCard(wikiRoot, card, options = {}) {
150
+ const validation = validateCuratorQueueCard(card);
151
+ if (!validation.valid) return result(false, { error: validation.errors.join("; ") });
152
+ const queue = readCuratorQueue(wikiRoot);
153
+ queue.cards = [...queue.cards.filter((item) => item.id !== validation.value.id), validation.value];
154
+ return result(true, { queue: writeQueue(wikiRoot, queue, options), card: validation.value });
155
+ }
156
+
157
+ export function getCuratorPaths(wikiRoot) {
158
+ const systemDir = path.join(wikiRoot, ".system");
159
+ const root = path.join(systemDir, "curator");
160
+ return {
161
+ systemDir,
162
+ root,
163
+ queueFile: path.join(root, "queue.json"),
164
+ candidatesDir: path.join(root, "lesson-candidates"),
165
+ proposalsDir: path.join(root, "consolidation-proposals"),
166
+ auditFile: path.join(root, "audit.jsonl"),
167
+ };
168
+ }
169
+
170
+ export function ensureCuratorStructure(wikiRoot) {
171
+ const paths = getCuratorPaths(wikiRoot);
172
+ fs.mkdirSync(paths.candidatesDir, { recursive: true });
173
+ fs.mkdirSync(paths.proposalsDir, { recursive: true });
174
+ return paths;
175
+ }
176
+
177
+ export function readCuratorQueue(wikiRoot) {
178
+ const paths = getCuratorPaths(wikiRoot);
179
+ if (!fs.existsSync(paths.queueFile)) return createEmptyQueue();
180
+ try {
181
+ return normalizeQueue(JSON.parse(fs.readFileSync(paths.queueFile, "utf-8")));
182
+ } catch {
183
+ return createEmptyQueue();
184
+ }
185
+ }
186
+
187
+ export function suggestLessonCandidate(wikiRoot, request = {}, options = {}) {
188
+ try {
189
+ const source = request.source || request.source_path || request.sourcePath;
190
+ if (!source) return result(false, { error: "suggest-lesson requires --source" });
191
+ const sourceResult = readEntryRef(wikiRoot, source);
192
+ if (!sourceResult.success) return sourceResult;
193
+ const relatedPaths = Array.isArray(request.related) ? request.related : typeof request.related === "string" ? [request.related] : [];
194
+ const relatedResult = readEntryRefs(wikiRoot, relatedPaths);
195
+ if (!relatedResult.success) return relatedResult;
196
+
197
+ const createdAt = nowIso(options);
198
+ const title = request.title || `${sourceResult.ref.title} Lesson`;
199
+ const id = `candidate-${safeId(title, "lesson")}-${shortHash([sourceResult.ref.path, ...relatedResult.refs.map((ref) => ref.path), title].join("|"))}`;
200
+ const scoringHints = scoreLessonCandidate({ source_refs: [sourceResult.ref], related_refs: relatedResult.refs }, { now: createdAt });
201
+ const candidateValidation = validateLessonCandidateArtifact({
202
+ id,
203
+ title,
204
+ status: CURATOR_REVIEW_STATUS,
205
+ source_refs: [sourceResult.ref],
206
+ related_refs: relatedResult.refs,
207
+ scoring_hints: scoringHints,
208
+ proposed_lesson: {
209
+ title,
210
+ tags: [...new Set([...sourceResult.ref.tags, ...relatedResult.refs.flatMap((ref) => ref.tags), "lesson-candidate"])].sort(compareStrings),
211
+ evidence_paths: [sourceResult.ref.path, ...relatedResult.refs.map((ref) => ref.path)],
212
+ reviewer_note: "Review this local artifact before promoting any lesson.",
213
+ },
214
+ boundaries: { executed: false, auto_promote: false, auto_merge: false, auto_delete: false },
215
+ created_at: createdAt,
216
+ updated_at: createdAt,
217
+ });
218
+ if (!candidateValidation.valid) return result(false, { error: candidateValidation.errors.join("; ") });
219
+
220
+ const paths = ensureCuratorStructure(wikiRoot);
221
+ const fileName = `${safeId(id)}.json`;
222
+ const filePath = path.join(paths.candidatesDir, fileName);
223
+ const relativePath = `.system/curator/lesson-candidates/${fileName}`;
224
+ atomicWriteJson(filePath, candidateValidation.value);
225
+
226
+ const sourcePaths = [sourceResult.ref.path, ...relatedResult.refs.map((ref) => ref.path)].sort(compareStrings);
227
+ const cardResult = upsertCard(wikiRoot, {
228
+ id: `card-${id}`,
229
+ kind: "lesson_candidate",
230
+ title,
231
+ status: CURATOR_REVIEW_STATUS,
232
+ artifact_path: relativePath,
233
+ source_paths: sourcePaths,
234
+ scoring_hints: scoringHints,
235
+ created_at: createdAt,
236
+ updated_at: createdAt,
237
+ }, options);
238
+ if (!cardResult.success) return cardResult;
239
+
240
+ const auditResult = appendAudit(wikiRoot, "suggest_lesson", {
241
+ subject_id: id,
242
+ artifact_path: relativePath,
243
+ source_paths: sourcePaths,
244
+ }, options);
245
+ if (!auditResult.success) return auditResult;
246
+
247
+ return result(true, {
248
+ candidate: candidateValidation.value,
249
+ queue_card: cardResult.card,
250
+ audit: auditResult.audit,
251
+ path: relativePath,
252
+ executed: false,
253
+ promoted: false,
254
+ message: `Lesson candidate artifact created: ${relativePath}`,
255
+ });
256
+ } catch (error) {
257
+ return result(false, { error: error.message });
258
+ }
259
+ }
260
+
261
+ export function proposeConsolidation(wikiRoot, request = {}, options = {}) {
262
+ try {
263
+ const entries = Array.isArray(request.entries) ? request.entries : typeof request.entries === "string" ? [request.entries] : [];
264
+ if (entries.length < 2) return result(false, { error: "propose-consolidation requires at least two entries" });
265
+ const refsResult = readEntryRefs(wikiRoot, entries);
266
+ if (!refsResult.success) return refsResult;
267
+ const createdAt = nowIso(options);
268
+ const title = request.title || `Consolidate ${refsResult.refs.length} wiki entries`;
269
+ const id = `proposal-${safeId(title, "consolidation")}-${shortHash(refsResult.refs.map((ref) => ref.path).join("|"))}`;
270
+ const scoringHints = scoreConsolidationProposal({ entry_refs: refsResult.refs }, { now: createdAt });
271
+ const proposalValidation = validateConsolidationProposal({
272
+ id,
273
+ title,
274
+ status: CURATOR_REVIEW_STATUS,
275
+ entry_refs: refsResult.refs,
276
+ rationale: request.rationale || "Reviewer should decide whether these entries should be consolidated.",
277
+ scoring_hints: scoringHints,
278
+ boundaries: { executed: false, auto_promote: false, auto_merge: false, auto_delete: false },
279
+ created_at: createdAt,
280
+ updated_at: createdAt,
281
+ });
282
+ if (!proposalValidation.valid) return result(false, { error: proposalValidation.errors.join("; ") });
283
+
284
+ const paths = ensureCuratorStructure(wikiRoot);
285
+ const fileName = `${safeId(id)}.json`;
286
+ const filePath = path.join(paths.proposalsDir, fileName);
287
+ const relativePath = `.system/curator/consolidation-proposals/${fileName}`;
288
+ atomicWriteJson(filePath, proposalValidation.value);
289
+ const sourcePaths = refsResult.refs.map((ref) => ref.path).sort(compareStrings);
290
+
291
+ const cardResult = upsertCard(wikiRoot, {
292
+ id: `card-${id}`,
293
+ kind: "consolidation_proposal",
294
+ title,
295
+ status: CURATOR_REVIEW_STATUS,
296
+ artifact_path: relativePath,
297
+ source_paths: sourcePaths,
298
+ scoring_hints: scoringHints,
299
+ created_at: createdAt,
300
+ updated_at: createdAt,
301
+ }, options);
302
+ if (!cardResult.success) return cardResult;
303
+
304
+ const auditResult = appendAudit(wikiRoot, "propose_consolidation", {
305
+ subject_id: id,
306
+ artifact_path: relativePath,
307
+ source_paths: sourcePaths,
308
+ }, options);
309
+ if (!auditResult.success) return auditResult;
310
+
311
+ return result(true, {
312
+ proposal: proposalValidation.value,
313
+ queue_card: cardResult.card,
314
+ audit: auditResult.audit,
315
+ path: relativePath,
316
+ executed: false,
317
+ merged: false,
318
+ deleted: false,
319
+ message: `Consolidation proposal artifact created: ${relativePath}`,
320
+ });
321
+ } catch (error) {
322
+ return result(false, { error: error.message });
323
+ }
324
+ }
@@ -0,0 +1,237 @@
1
+ import { redactSecrets } from "../security.js";
2
+
3
+ export const CURATOR_REVIEW_STATUS = "review_required";
4
+ export const CURATOR_CARD_KINDS = ["lesson_candidate", "consolidation_proposal"];
5
+ export const DEFAULT_BUILT_AT = "1970-01-01T00:00:00.000Z";
6
+
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ function compareStrings(left, right) {
12
+ return String(left ?? "").localeCompare(String(right ?? ""));
13
+ }
14
+
15
+ function normalizeScalar(value, maxLength = 500) {
16
+ return redactSecrets(String(value ?? "")
17
+ .replace(/\r?\n/g, " ")
18
+ .replace(/\s+/g, " ")
19
+ .trim())
20
+ .slice(0, maxLength);
21
+ }
22
+
23
+ function normalizeId(value, label = "id") {
24
+ const id = normalizeScalar(value, 120).toLowerCase();
25
+ if (!/^[a-z0-9][a-z0-9_-]{0,119}$/.test(id)) {
26
+ throw new Error(`Invalid ${label}: ${value}`);
27
+ }
28
+ return id;
29
+ }
30
+
31
+ function normalizeStringList(value, maxLength = 120) {
32
+ const values = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
33
+ return [...new Set(values.map((item) => normalizeScalar(item, maxLength)).filter(Boolean))]
34
+ .sort(compareStrings);
35
+ }
36
+
37
+ function normalizeBooleanFalse(value, label) {
38
+ if (value === undefined || value === null || value === false) return false;
39
+ throw new Error(`${label} must remain false`);
40
+ }
41
+
42
+ function normalizeScore(value, label) {
43
+ const number = Number(value);
44
+ if (!Number.isFinite(number)) throw new Error(`Invalid ${label} score`);
45
+ return Math.max(0, Math.min(1, Math.round(number * 1000) / 1000));
46
+ }
47
+
48
+ function validationResult(builder) {
49
+ try {
50
+ return { valid: true, value: builder(), errors: [] };
51
+ } catch (error) {
52
+ return { valid: false, value: null, errors: [error.message] };
53
+ }
54
+ }
55
+
56
+ function normalizeReviewStatus(value) {
57
+ const status = normalizeScalar(value || CURATOR_REVIEW_STATUS, 80).toLowerCase();
58
+ if (status !== CURATOR_REVIEW_STATUS) {
59
+ throw new Error(`Curator artifacts must stay ${CURATOR_REVIEW_STATUS}`);
60
+ }
61
+ return CURATOR_REVIEW_STATUS;
62
+ }
63
+
64
+ function normalizeContentHash(value, label) {
65
+ const hash = normalizeScalar(value, 80).toLowerCase();
66
+ if (!/^[a-f0-9]{64}$/.test(hash)) {
67
+ throw new Error(`${label} content_sha256 is required`);
68
+ }
69
+ return hash;
70
+ }
71
+
72
+ function normalizeSourceRef(input, label = "source evidence ref", requireHash = true) {
73
+ if (!isRecord(input)) throw new Error(`${label} must be an object`);
74
+ const path = normalizeCuratorPath(input.path, `${label} path`);
75
+ const ref = {
76
+ path,
77
+ title: normalizeScalar(input.title || path, 180),
78
+ status: normalizeScalar(input.status || "candidate", 80).toLowerCase(),
79
+ source: normalizeScalar(input.source || "manual", 120).toLowerCase(),
80
+ tags: normalizeStringList(input.tags, 80),
81
+ summary: normalizeScalar(input.summary, 240),
82
+ created_at: normalizeScalar(input.created_at || input.createdAt, 80),
83
+ updated_at: normalizeScalar(input.updated_at || input.updatedAt, 80),
84
+ seen_count: Number.isFinite(Number(input.seen_count || input.seenCount)) ? Math.max(0, Math.trunc(Number(input.seen_count || input.seenCount))) : 0,
85
+ backlink_count: Number.isFinite(Number(input.backlink_count || input.backlinkCount)) ? Math.max(0, Math.trunc(Number(input.backlink_count || input.backlinkCount))) : 0,
86
+ confidence_score: Number.isFinite(Number(input.confidence_score || input.confidenceScore)) ? normalizeScore(Number(input.confidence_score || input.confidenceScore), "confidence") : 1,
87
+ };
88
+ const hash = normalizeScalar(input.content_sha256 || input.contentSha256, 80).toLowerCase();
89
+ if (hash || requireHash) ref.content_sha256 = normalizeContentHash(hash, label);
90
+ return ref;
91
+ }
92
+
93
+ function normalizeSourceRefs(value, label = "source evidence refs", requireHash = true) {
94
+ const refs = Array.isArray(value) ? value : [];
95
+ return [...new Map(refs.map((ref) => normalizeSourceRef(ref, label.slice(0, -1), requireHash)).map((ref) => [ref.path, ref])).values()]
96
+ .sort((left, right) => compareStrings(left.path, right.path));
97
+ }
98
+
99
+ function normalizeProposedLesson(value = {}) {
100
+ const proposed = isRecord(value) ? value : {};
101
+ return {
102
+ title: normalizeScalar(proposed.title, 180),
103
+ tags: normalizeStringList(proposed.tags, 80),
104
+ evidence_paths: normalizeStringList(proposed.evidence_paths || proposed.evidencePaths, 240).map((item) => normalizeCuratorPath(item, "evidence path")),
105
+ reviewer_note: normalizeScalar(proposed.reviewer_note || proposed.reviewerNote, 500),
106
+ };
107
+ }
108
+
109
+ function normalizeBoundaries(value = {}) {
110
+ const boundaries = isRecord(value) ? value : {};
111
+ return {
112
+ executed: normalizeBooleanFalse(boundaries.executed, "executed"),
113
+ auto_promote: normalizeBooleanFalse(boundaries.auto_promote || boundaries.autoPromote, "auto_promote"),
114
+ auto_merge: normalizeBooleanFalse(boundaries.auto_merge || boundaries.autoMerge, "auto_merge"),
115
+ auto_delete: normalizeBooleanFalse(boundaries.auto_delete || boundaries.autoDelete, "auto_delete"),
116
+ };
117
+ }
118
+
119
+ export function normalizeCuratorPath(value, label = "path", maxLength = 260) {
120
+ const normalized = normalizeScalar(value, maxLength).replace(/\\/g, "/");
121
+ if (!normalized) throw new Error(`Unsafe ${label}: path is required`);
122
+ if (/^[a-z]:\//i.test(normalized) || normalized.startsWith("/") || normalized.startsWith("//")) {
123
+ throw new Error(`Unsafe ${label}: absolute paths are not allowed`);
124
+ }
125
+ if (normalized.split("/").some((segment) => segment === "..")) {
126
+ throw new Error(`Unsafe ${label}: traversal is not allowed`);
127
+ }
128
+ return normalized;
129
+ }
130
+
131
+ export function validateScoringHints(input) {
132
+ return validationResult(() => {
133
+ const hints = isRecord(input) ? input : {};
134
+ return {
135
+ importance: normalizeScore(hints.importance ?? 0, "importance"),
136
+ recency: normalizeScore(hints.recency ?? 0, "recency"),
137
+ trust: normalizeScore(hints.trust ?? 0, "trust"),
138
+ review_only: true,
139
+ review_status: CURATOR_REVIEW_STATUS,
140
+ };
141
+ });
142
+ }
143
+
144
+ export function validateLessonCandidateArtifact(input) {
145
+ return validationResult(() => {
146
+ if (!isRecord(input)) throw new Error("Lesson candidate artifact must be an object");
147
+ const sourceRefs = normalizeSourceRefs(input.source_refs || input.sourceRefs, "source evidence refs", true);
148
+ if (sourceRefs.length === 0) throw new Error("Lesson candidate requires source evidence refs");
149
+ const scoring = validateScoringHints(input.scoring_hints || input.scoringHints);
150
+ if (!scoring.valid) throw new Error(scoring.errors.join("; "));
151
+ const title = normalizeScalar(input.title, 180);
152
+ if (!title) throw new Error("Lesson candidate title is required");
153
+ return {
154
+ version: 1,
155
+ id: normalizeId(input.id, "lesson candidate id"),
156
+ type: "lesson_candidate",
157
+ title,
158
+ status: normalizeReviewStatus(input.status),
159
+ review_required: true,
160
+ source_refs: sourceRefs,
161
+ related_refs: normalizeSourceRefs(input.related_refs || input.relatedRefs, "related refs", true),
162
+ scoring_hints: scoring.value,
163
+ proposed_lesson: normalizeProposedLesson(input.proposed_lesson || input.proposedLesson),
164
+ boundaries: normalizeBoundaries(input.boundaries),
165
+ created_at: normalizeScalar(input.created_at || input.createdAt || DEFAULT_BUILT_AT, 80),
166
+ updated_at: normalizeScalar(input.updated_at || input.updatedAt || DEFAULT_BUILT_AT, 80),
167
+ };
168
+ });
169
+ }
170
+
171
+ export function validateConsolidationProposal(input) {
172
+ return validationResult(() => {
173
+ if (!isRecord(input)) throw new Error("Consolidation proposal must be an object");
174
+ const entryRefs = normalizeSourceRefs(input.entry_refs || input.entryRefs, "entry refs", true);
175
+ if (entryRefs.length < 2) throw new Error("Consolidation proposal requires at least two entry refs");
176
+ const scoring = validateScoringHints(input.scoring_hints || input.scoringHints);
177
+ if (!scoring.valid) throw new Error(scoring.errors.join("; "));
178
+ const title = normalizeScalar(input.title, 180);
179
+ if (!title) throw new Error("Consolidation proposal title is required");
180
+ return {
181
+ version: 1,
182
+ id: normalizeId(input.id, "consolidation proposal id"),
183
+ type: "consolidation_proposal",
184
+ title,
185
+ status: normalizeReviewStatus(input.status),
186
+ review_required: true,
187
+ entry_refs: entryRefs,
188
+ rationale: normalizeScalar(input.rationale, 1000),
189
+ scoring_hints: scoring.value,
190
+ proposed_actions: [{ action: "review_consolidation", requires_human_review: true }],
191
+ boundaries: normalizeBoundaries(input.boundaries),
192
+ created_at: normalizeScalar(input.created_at || input.createdAt || DEFAULT_BUILT_AT, 80),
193
+ updated_at: normalizeScalar(input.updated_at || input.updatedAt || DEFAULT_BUILT_AT, 80),
194
+ };
195
+ });
196
+ }
197
+
198
+ export function validateCuratorQueueCard(input) {
199
+ return validationResult(() => {
200
+ if (!isRecord(input)) throw new Error("Curator queue card must be an object");
201
+ const kind = normalizeScalar(input.kind, 80).toLowerCase();
202
+ if (!CURATOR_CARD_KINDS.includes(kind)) throw new Error(`Invalid curator card kind: ${input.kind}`);
203
+ const title = normalizeScalar(input.title, 180);
204
+ if (!title) throw new Error("Curator queue card title is required");
205
+ const scoring = validateScoringHints(input.scoring_hints || input.scoringHints);
206
+ if (!scoring.valid) throw new Error(scoring.errors.join("; "));
207
+ return {
208
+ id: normalizeId(input.id, "curator queue card id"),
209
+ kind,
210
+ title,
211
+ status: normalizeReviewStatus(input.status),
212
+ review_required: true,
213
+ artifact_path: normalizeCuratorPath(input.artifact_path || input.artifactPath, "artifact path"),
214
+ source_paths: normalizeStringList(input.source_paths || input.sourcePaths, 260).map((item) => normalizeCuratorPath(item, "source path")),
215
+ scoring_hints: scoring.value,
216
+ created_at: normalizeScalar(input.created_at || input.createdAt || DEFAULT_BUILT_AT, 80),
217
+ updated_at: normalizeScalar(input.updated_at || input.updatedAt || DEFAULT_BUILT_AT, 80),
218
+ };
219
+ });
220
+ }
221
+
222
+ export function validateAuditRecord(input) {
223
+ return validationResult(() => {
224
+ if (!isRecord(input)) throw new Error("Curator audit record must be an object");
225
+ const action = normalizeScalar(input.action, 80).toLowerCase();
226
+ if (!action) throw new Error("Curator audit action is required");
227
+ return {
228
+ timestamp: normalizeScalar(input.timestamp || DEFAULT_BUILT_AT, 80),
229
+ action,
230
+ actor: normalizeScalar(input.actor || "system", 120),
231
+ subject_id: normalizeId(input.subject_id || input.subjectId, "audit subject id"),
232
+ artifact_path: normalizeCuratorPath(input.artifact_path || input.artifactPath, "artifact path"),
233
+ source_paths: normalizeStringList(input.source_paths || input.sourcePaths, 260).map((item) => normalizeCuratorPath(item, "source path")),
234
+ executed: normalizeBooleanFalse(input.executed, "executed"),
235
+ };
236
+ });
237
+ }
@@ -0,0 +1,83 @@
1
+ import { CURATOR_REVIEW_STATUS } from "./schema.js";
2
+
3
+ function clamp(value) {
4
+ return Math.max(0, Math.min(1, value));
5
+ }
6
+
7
+ function roundScore(value) {
8
+ return Math.round(clamp(value) * 1000) / 1000;
9
+ }
10
+
11
+ function toArray(value) {
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ function parseDate(value) {
16
+ const time = Date.parse(String(value || ""));
17
+ return Number.isFinite(time) ? time : 0;
18
+ }
19
+
20
+ function newestTimestamp(refs) {
21
+ return refs.reduce((latest, ref) => Math.max(latest, parseDate(ref.updated_at || ref.updatedAt), parseDate(ref.created_at || ref.createdAt)), 0);
22
+ }
23
+
24
+ function sourceTrust(ref) {
25
+ if (Number.isFinite(Number(ref.confidence_score || ref.confidenceScore))) {
26
+ return clamp(Number(ref.confidence_score || ref.confidenceScore));
27
+ }
28
+ const source = String(ref.source || "manual").toLowerCase();
29
+ if (source === "manual") return 0.8;
30
+ if (source === "chat.message") return 0.72;
31
+ if (source === "auto-memory") return 0.68;
32
+ if (source === "notebooklm") return 0.74;
33
+ return 0.6;
34
+ }
35
+
36
+ function average(values, fallback = 0) {
37
+ const finiteValues = values.filter((value) => Number.isFinite(value));
38
+ if (finiteValues.length === 0) return fallback;
39
+ return finiteValues.reduce((sum, value) => sum + value, 0) / finiteValues.length;
40
+ }
41
+
42
+ function uniqueTagCount(refs) {
43
+ const tags = new Set();
44
+ for (const ref of refs) {
45
+ for (const tag of toArray(ref.tags)) tags.add(String(tag).toLowerCase());
46
+ }
47
+ return tags.size;
48
+ }
49
+
50
+ function scoreRefs(refs, options = {}) {
51
+ const now = parseDate(options.now) || Date.now();
52
+ const latest = newestTimestamp(refs);
53
+ const ageDays = latest > 0 ? Math.max(0, (now - latest) / 86_400_000) : 365;
54
+ const seenScore = average(refs.map((ref) => Math.min(5, Number(ref.seen_count || ref.seenCount || 0)) / 5));
55
+ const backlinkScore = average(refs.map((ref) => Math.min(5, Number(ref.backlink_count || ref.backlinkCount || 0)) / 5));
56
+ const confidence = average(refs.map(sourceTrust), 0.5);
57
+ const lessonBonus = refs.every((ref) => String(ref.status || "").toLowerCase() === "lesson") ? 0.05 : 0;
58
+
59
+ return {
60
+ importance: roundScore(0.25 + Math.min(0.25, refs.length * 0.08) + Math.min(0.18, uniqueTagCount(refs) * 0.03) + seenScore * 0.18 + backlinkScore * 0.14),
61
+ recency: roundScore(1 / (1 + ageDays / 30)),
62
+ trust: roundScore(confidence + lessonBonus),
63
+ review_only: true,
64
+ review_status: CURATOR_REVIEW_STATUS,
65
+ };
66
+ }
67
+
68
+ export function scoreLessonCandidate(candidate = {}, options = {}) {
69
+ const refs = [
70
+ ...toArray(candidate.source_refs || candidate.sourceRefs),
71
+ ...toArray(candidate.related_refs || candidate.relatedRefs),
72
+ ];
73
+ return scoreRefs(refs, options);
74
+ }
75
+
76
+ export function scoreConsolidationProposal(proposal = {}, options = {}) {
77
+ const refs = toArray(proposal.entry_refs || proposal.entryRefs);
78
+ const score = scoreRefs(refs, options);
79
+ return {
80
+ ...score,
81
+ importance: roundScore(score.importance + Math.min(0.15, refs.length * 0.04)),
82
+ };
83
+ }