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,183 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { wrapWithBoundary } from "../security.js";
4
+ import { buildIndex, getWikiPaths } from "../wiki/store.js";
5
+ import { queryWiki } from "../wiki/query.js";
6
+ import { normalizeEntryStatus, normalizeEntryTags, normalizeEntryText } from "../wiki/entry-normalizer.js";
7
+
8
+ const EXCLUDED_STATUSES = new Set(["rejected", "superseded", "private", "needs-clarification"]);
9
+
10
+ function normalizePath(value) {
11
+ if (!value || typeof value !== "string") return "";
12
+ const normalized = value.replace(/\\/g, "/");
13
+ if (path.isAbsolute(normalized)) return "";
14
+ if (normalized.split("/").some((segment) => segment === "..")) return "";
15
+ return normalized;
16
+ }
17
+
18
+ function entrySummary(entry, relation = "direct") {
19
+ return {
20
+ path: entry.path,
21
+ title: normalizeEntryText(entry.title) || entry.path,
22
+ status: normalizeEntryStatus(entry.status),
23
+ tags: normalizeEntryTags(entry.tags),
24
+ confidence: entry.confidence || "EXTRACTED",
25
+ confidence_score: typeof entry.confidence_score === "number" ? entry.confidence_score : 1,
26
+ relation,
27
+ };
28
+ }
29
+
30
+ function isVisible(entry) {
31
+ return entry && !EXCLUDED_STATUSES.has(normalizeEntryStatus(entry.status));
32
+ }
33
+
34
+ function readGraph(wikiRoot) {
35
+ const graphPath = path.join(getWikiPaths(wikiRoot).system, "graph.json");
36
+ try {
37
+ if (!fs.existsSync(graphPath)) return { nodes: [], edges: [] };
38
+ const graph = JSON.parse(fs.readFileSync(graphPath, "utf-8"));
39
+ return {
40
+ nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
41
+ edges: Array.isArray(graph.edges) ? graph.edges : [],
42
+ };
43
+ } catch {
44
+ return { nodes: [], edges: [] };
45
+ }
46
+ }
47
+
48
+ function mapEntriesByPath(index) {
49
+ return new Map((index.entries || []).map((entry) => [entry.path, entry]));
50
+ }
51
+
52
+ function relationEdge(edge) {
53
+ return {
54
+ source: String(edge.source || ""),
55
+ target: String(edge.target || ""),
56
+ relation: String(edge.relation || "related"),
57
+ confidence: edge.confidence || "EXTRACTED",
58
+ confidence_score: typeof edge.confidence_score === "number" ? edge.confidence_score : 1,
59
+ };
60
+ }
61
+
62
+ function findNodePath(graph, refPath) {
63
+ const node = graph.nodes.find((item) => item.id === refPath || item.path === refPath);
64
+ return node?.path || node?.id || refPath;
65
+ }
66
+
67
+ function resolveWikiPath(wikiRoot, index, ref) {
68
+ const refPath = normalizePath(ref.path);
69
+ if (!refPath) return { warning: "invalid wiki reference path" };
70
+ const entry = (index.entries || []).find((item) => item.path === refPath);
71
+ if (!entry) return { warning: `missing wiki reference: ${refPath}` };
72
+ if (!isVisible(entry)) return { warning: `excluded wiki reference: ${refPath}` };
73
+ const fullPath = path.join(wikiRoot, refPath);
74
+ if (!fs.existsSync(fullPath)) return { warning: `missing wiki file: ${refPath}` };
75
+ return { context: entrySummary(entry, "wiki") };
76
+ }
77
+
78
+ function resolveQuery(wikiRoot, ref) {
79
+ const query = String(ref.query || "").trim();
80
+ if (!query) return { warning: "query reference is empty" };
81
+ const result = queryWiki(wikiRoot, query, { maxResults: 3, format: "json" });
82
+ if (!result.success) return { warning: `query failed: ${result.error}` };
83
+ return {
84
+ context: [
85
+ ...(result.direct_matches || []).filter(isVisible).map((entry) => entrySummary(entry, "query")),
86
+ ...(result.graph_neighbors || []).filter(isVisible).map((entry) => entrySummary(entry, "graph_neighbor")),
87
+ ],
88
+ warnings: result.duplicate_warnings?.map((warning) => `duplicate warning: ${warning.type}`) || [],
89
+ };
90
+ }
91
+
92
+ function resolveGraphReference(index, graph, ref) {
93
+ const graphId = normalizePath(ref.graph_id || ref.graphId || ref.path);
94
+ if (!graphId) return { warning: "invalid graph reference" };
95
+ const nodePath = findNodePath(graph, graphId);
96
+ const entriesByPath = mapEntriesByPath(index);
97
+ const entry = entriesByPath.get(nodePath);
98
+ if (!entry) return { warning: `missing graph reference: ${graphId}` };
99
+ if (!isVisible(entry)) return { warning: `excluded graph reference: ${graphId}` };
100
+ const relationEdges = graph.edges
101
+ .filter((edge) => edge.source === nodePath || edge.target === nodePath)
102
+ .map(relationEdge);
103
+ return { context: entrySummary(entry, "graph"), relation_edges: relationEdges };
104
+ }
105
+
106
+ function resolveRelatedReference(index, graph, ref) {
107
+ const sourcePath = normalizePath(ref.path || ref.graph_id || ref.graphId);
108
+ if (!sourcePath) return { warning: "invalid related reference path" };
109
+ const entriesByPath = mapEntriesByPath(index);
110
+ const sourceEntry = entriesByPath.get(sourcePath);
111
+ if (!sourceEntry) return { warning: `missing related reference: ${sourcePath}` };
112
+ if (!isVisible(sourceEntry)) return { warning: `excluded related reference: ${sourcePath}` };
113
+
114
+ const relationEdges = graph.edges
115
+ .filter((edge) => edge.source === sourcePath && String(edge.relation || "related") === "related")
116
+ .map(relationEdge);
117
+ const context = relationEdges
118
+ .map((edge) => entriesByPath.get(edge.target))
119
+ .filter(isVisible)
120
+ .map((entry) => entrySummary(entry, "related"));
121
+ return { context, relation_edges: relationEdges };
122
+ }
123
+
124
+ function dedupeRelationEdges(edges) {
125
+ return [...new Map(edges
126
+ .filter((edge) => edge.source && edge.target)
127
+ .map((edge) => [`${edge.source}:${edge.target}:${edge.relation}`, edge])).values()]
128
+ .sort((a, b) => {
129
+ if (a.source !== b.source) return a.source.localeCompare(b.source);
130
+ if (a.target !== b.target) return a.target.localeCompare(b.target);
131
+ return a.relation.localeCompare(b.relation);
132
+ });
133
+ }
134
+
135
+ export function resolveTaskContextRefs(wikiRoot, taskCard) {
136
+ const index = buildIndex(wikiRoot);
137
+ const graph = readGraph(wikiRoot);
138
+ const refs = Array.isArray(taskCard?.context_refs) ? taskCard.context_refs : [];
139
+ const context = [];
140
+ const relationEdges = [];
141
+ const warnings = [];
142
+
143
+ for (const ref of refs) {
144
+ if (!ref || typeof ref !== "object") continue;
145
+ if (ref.type === "wiki") {
146
+ const result = resolveWikiPath(wikiRoot, index, ref);
147
+ if (result.context) context.push(result.context);
148
+ if (result.warning) warnings.push(result.warning);
149
+ } else if (ref.type === "query") {
150
+ const result = resolveQuery(wikiRoot, ref);
151
+ if (Array.isArray(result.context)) context.push(...result.context);
152
+ if (Array.isArray(result.warnings)) warnings.push(...result.warnings);
153
+ if (result.warning) warnings.push(result.warning);
154
+ } else if (ref.type === "graph") {
155
+ const result = resolveGraphReference(index, graph, ref);
156
+ if (result.context) context.push(result.context);
157
+ if (Array.isArray(result.relation_edges)) relationEdges.push(...result.relation_edges);
158
+ if (result.warning) warnings.push(result.warning);
159
+ } else if (ref.type === "related") {
160
+ const result = resolveRelatedReference(index, graph, ref);
161
+ if (Array.isArray(result.context)) context.push(...result.context);
162
+ if (Array.isArray(result.relation_edges)) relationEdges.push(...result.relation_edges);
163
+ if (result.warning) warnings.push(result.warning);
164
+ } else if (ref.graph_id) {
165
+ warnings.push(`graph reference recorded for review: ${ref.graph_id}`);
166
+ }
167
+ }
168
+
169
+ const unique = [...new Map(context.map((item) => [item.path, item])).values()]
170
+ .sort((a, b) => a.path.localeCompare(b.path));
171
+
172
+ return {
173
+ success: true,
174
+ context: unique,
175
+ relation_edges: dedupeRelationEdges(relationEdges),
176
+ warnings: [...new Set(warnings)].sort((a, b) => a.localeCompare(b)),
177
+ boundary: wrapWithBoundary("Agent Team context references are summaries only."),
178
+ };
179
+ }
180
+
181
+ export function getAgentTeamSharedMemoryPath(wikiRoot) {
182
+ return path.join(getWikiPaths(wikiRoot).system, "agent-team", "queue.json");
183
+ }
@@ -0,0 +1,71 @@
1
+ import { DEFAULT_BUILT_AT } from "./schema.js";
2
+ import { readTodoQueue } from "./queue.js";
3
+
4
+ function compareStrings(left, right) {
5
+ return String(left ?? "").localeCompare(String(right ?? ""));
6
+ }
7
+
8
+ function hasSafeDate(value) {
9
+ return typeof value === "string" && value.trim() && !Number.isNaN(Date.parse(value));
10
+ }
11
+
12
+ function deterministicBuiltAt(todos) {
13
+ const dates = [];
14
+ for (const todo of todos) {
15
+ if (hasSafeDate(todo.created_at)) dates.push(todo.created_at);
16
+ if (hasSafeDate(todo.completed_at)) dates.push(todo.completed_at);
17
+ }
18
+ if (dates.length === 0) return DEFAULT_BUILT_AT;
19
+ return dates.sort(compareStrings).at(-1);
20
+ }
21
+
22
+ function summarizeTodo(todo) {
23
+ const summary = {
24
+ id: todo.id,
25
+ title: todo.title,
26
+ status: todo.status,
27
+ };
28
+ if (todo.project) summary.project = todo.project;
29
+ summary.tags = todo.tags;
30
+ summary.created_at = todo.created_at;
31
+ summary.completed_at = todo.completed_at;
32
+ return summary;
33
+ }
34
+
35
+ function summarizeProjects(todos) {
36
+ const projects = new Map();
37
+ for (const todo of todos) {
38
+ if (!todo.project) continue;
39
+ const current = projects.get(todo.project) || {
40
+ id: todo.project,
41
+ total_count: 0,
42
+ pending_count: 0,
43
+ done_count: 0,
44
+ };
45
+ current.total_count += 1;
46
+ if (todo.status === "done") current.done_count += 1;
47
+ if (todo.status === "pending") current.pending_count += 1;
48
+ projects.set(todo.project, current);
49
+ }
50
+ return [...projects.values()].sort((left, right) => compareStrings(left.id, right.id));
51
+ }
52
+
53
+ export function buildTodoBrowserData(wikiRoot) {
54
+ const queue = readTodoQueue(wikiRoot);
55
+ const todos = queue.todos.map(summarizeTodo);
56
+ const pending = todos.filter((todo) => todo.status === "pending");
57
+ const done = todos.filter((todo) => todo.status === "done");
58
+
59
+ return {
60
+ todos,
61
+ pending,
62
+ done,
63
+ projects: summarizeProjects(todos),
64
+ meta: {
65
+ built_at: deterministicBuiltAt(todos),
66
+ todo_count: todos.length,
67
+ pending_count: pending.length,
68
+ done_count: done.length,
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { DEFAULT_BUILT_AT, validateTodoItem } from "./schema.js";
5
+
6
+ function nowIso(options = {}) {
7
+ return options.now || new Date().toISOString();
8
+ }
9
+
10
+ function atomicWriteJson(filePath, data) {
11
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
12
+ const tmpFile = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
13
+ fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2));
14
+ fs.renameSync(tmpFile, filePath);
15
+ }
16
+
17
+ function result(success, payload = {}) {
18
+ return { success, ...payload };
19
+ }
20
+
21
+ function sortTodos(todos) {
22
+ return [...todos].sort((left, right) => `${left.created_at}:${left.id}`.localeCompare(`${right.created_at}:${right.id}`));
23
+ }
24
+
25
+ function createEmptyQueue() {
26
+ return { version: 1, todos: [], meta: { built_at: DEFAULT_BUILT_AT } };
27
+ }
28
+
29
+ function normalizeQueue(queue) {
30
+ const todos = [];
31
+ for (const todo of Array.isArray(queue?.todos) ? queue.todos : []) {
32
+ const validation = validateTodoItem(todo);
33
+ if (validation.valid) todos.push(validation.value);
34
+ }
35
+
36
+ return {
37
+ version: 1,
38
+ todos: sortTodos([...new Map(todos.map((todo) => [todo.id, todo])).values()]),
39
+ meta: { built_at: queue?.meta?.built_at || DEFAULT_BUILT_AT },
40
+ };
41
+ }
42
+
43
+ function writeQueue(wikiRoot, queue, options = {}) {
44
+ const normalized = normalizeQueue({ ...queue, meta: { built_at: nowIso(options) } });
45
+ atomicWriteJson(getTodoPaths(wikiRoot).todoFile, normalized);
46
+ return normalized;
47
+ }
48
+
49
+ function generateTodoId(existingIds) {
50
+ let id = `todo_${crypto.randomBytes(4).toString("hex")}`;
51
+ while (existingIds.has(id)) {
52
+ id = `todo_${crypto.randomBytes(4).toString("hex")}`;
53
+ }
54
+ return id;
55
+ }
56
+
57
+ function resolveTodo(queue, idOrTitle) {
58
+ const lookup = String(idOrTitle ?? "").trim();
59
+ if (!lookup) return result(false, { error: "TODO id or title is required" });
60
+
61
+ const idMatch = queue.todos.find((todo) => todo.id === lookup);
62
+ if (idMatch) return result(true, { todo: idMatch });
63
+
64
+ const titleMatches = queue.todos.filter((todo) => todo.title === lookup);
65
+ if (titleMatches.length === 1) return result(true, { todo: titleMatches[0] });
66
+ if (titleMatches.length > 1) return result(false, { error: `Multiple TODOs match title: ${lookup}` });
67
+ return result(false, { error: `TODO not found: ${lookup}` });
68
+ }
69
+
70
+ export function getTodoPaths(wikiRoot) {
71
+ const systemDir = path.join(wikiRoot, ".system");
72
+ return {
73
+ systemDir,
74
+ todoFile: path.join(systemDir, "todos.json"),
75
+ };
76
+ }
77
+
78
+ export function readTodoQueue(wikiRoot) {
79
+ const paths = getTodoPaths(wikiRoot);
80
+ if (!fs.existsSync(paths.todoFile)) return createEmptyQueue();
81
+
82
+ try {
83
+ return normalizeQueue(JSON.parse(fs.readFileSync(paths.todoFile, "utf-8")));
84
+ } catch {
85
+ return createEmptyQueue();
86
+ }
87
+ }
88
+
89
+ export function listTodos(wikiRoot, filters = {}) {
90
+ const queue = readTodoQueue(wikiRoot);
91
+ const project = String(filters.project ?? "").trim();
92
+ const todos = project
93
+ ? queue.todos.filter((todo) => todo.project === project)
94
+ : queue.todos;
95
+
96
+ return result(true, {
97
+ queue,
98
+ todos,
99
+ count: todos.length,
100
+ message: todos.length > 0
101
+ ? todos.map((todo) => `${todo.status === "done" ? "[x]" : "[ ]"} ${todo.id} ${todo.title}${todo.project ? ` (${todo.project})` : ""}`).join("\n")
102
+ : "No TODOs found.",
103
+ });
104
+ }
105
+
106
+ export function addTodo(wikiRoot, todo, options = {}) {
107
+ const queue = readTodoQueue(wikiRoot);
108
+ const createdAt = nowIso(options);
109
+ const validation = validateTodoItem({
110
+ ...todo,
111
+ id: todo.id || generateTodoId(new Set(queue.todos.map((item) => item.id))),
112
+ status: "pending",
113
+ created_at: todo.created_at || todo.createdAt || createdAt,
114
+ completed_at: null,
115
+ });
116
+
117
+ if (!validation.valid) return result(false, { error: validation.errors.join("; ") });
118
+
119
+ queue.todos = [...queue.todos.filter((item) => item.id !== validation.value.id), validation.value];
120
+ writeQueue(wikiRoot, queue, options);
121
+ return result(true, {
122
+ todo: validation.value,
123
+ message: `TODO added: ${validation.value.title}`,
124
+ });
125
+ }
126
+
127
+ export function markTodoDone(wikiRoot, idOrTitle, options = {}) {
128
+ const queue = readTodoQueue(wikiRoot);
129
+ const resolved = resolveTodo(queue, idOrTitle);
130
+ if (!resolved.success) return resolved;
131
+
132
+ const completedAt = resolved.todo.completed_at || nowIso(options);
133
+ const validation = validateTodoItem({
134
+ ...resolved.todo,
135
+ status: "done",
136
+ completed_at: completedAt,
137
+ });
138
+ if (!validation.valid) return result(false, { error: validation.errors.join("; ") });
139
+
140
+ queue.todos = queue.todos.map((todo) => todo.id === validation.value.id ? validation.value : todo);
141
+ writeQueue(wikiRoot, queue, options);
142
+ return result(true, {
143
+ todo: validation.value,
144
+ message: `TODO done: ${validation.value.title}`,
145
+ });
146
+ }
147
+
148
+ export function removeTodo(wikiRoot, idOrTitle, options = {}) {
149
+ const queue = readTodoQueue(wikiRoot);
150
+ const resolved = resolveTodo(queue, idOrTitle);
151
+ if (!resolved.success) return resolved;
152
+
153
+ queue.todos = queue.todos.filter((todo) => todo.id !== resolved.todo.id);
154
+ writeQueue(wikiRoot, queue, options);
155
+ return result(true, {
156
+ removed: resolved.todo,
157
+ message: `TODO removed: ${resolved.todo.title}`,
158
+ });
159
+ }
@@ -0,0 +1,90 @@
1
+ import { redactSecrets } from "../security.js";
2
+
3
+ export const TODO_STATUSES = ["pending", "done"];
4
+ export const DEFAULT_BUILT_AT = "1970-01-01T00:00:00.000Z";
5
+
6
+ function isRecord(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function compareStrings(left, right) {
11
+ return String(left ?? "").localeCompare(String(right ?? ""));
12
+ }
13
+
14
+ function normalizeScalar(value, maxLength = 500) {
15
+ return redactSecrets(String(value ?? "")
16
+ .replace(/\r?\n/g, " ")
17
+ .replace(/\s+/g, " ")
18
+ .trim())
19
+ .slice(0, maxLength);
20
+ }
21
+
22
+ function normalizeId(value, label = "todo id") {
23
+ const id = normalizeScalar(value, 120).toLowerCase();
24
+ if (!/^todo_[a-z0-9][a-z0-9_-]{0,114}$/.test(id)) {
25
+ throw new Error(`Invalid ${label}: ${value}`);
26
+ }
27
+ return id;
28
+ }
29
+
30
+ function normalizeStringList(value, maxLength = 120) {
31
+ const values = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
32
+ return [...new Set(values.map((item) => normalizeScalar(item, maxLength)).filter(Boolean))]
33
+ .sort(compareStrings);
34
+ }
35
+
36
+ function normalizeTodoStatus(value) {
37
+ const status = normalizeScalar(value || "pending", 40).toLowerCase();
38
+ if (!TODO_STATUSES.includes(status)) {
39
+ throw new Error(`Invalid TODO status: ${value}`);
40
+ }
41
+ return status;
42
+ }
43
+
44
+ function normalizeProject(value) {
45
+ return normalizeScalar(value, 120);
46
+ }
47
+
48
+ function normalizeTimestamp(value, label, required = true) {
49
+ const timestamp = normalizeScalar(value, 80);
50
+ if (!timestamp && required) throw new Error(`${label} is required`);
51
+ if (timestamp && Number.isNaN(Date.parse(timestamp))) throw new Error(`Invalid ${label}: ${value}`);
52
+ return timestamp;
53
+ }
54
+
55
+ function validationResult(builder) {
56
+ try {
57
+ return { valid: true, value: builder(), errors: [] };
58
+ } catch (error) {
59
+ return { valid: false, value: null, errors: [error.message] };
60
+ }
61
+ }
62
+
63
+ export function validateTodoItem(input) {
64
+ return validationResult(() => {
65
+ if (!isRecord(input)) throw new Error("TODO item must be an object");
66
+ const status = normalizeTodoStatus(input.status);
67
+ const title = normalizeScalar(input.title, 220);
68
+ if (!title) throw new Error("TODO title is required");
69
+
70
+ const todo = {
71
+ id: normalizeId(input.id),
72
+ title,
73
+ status,
74
+ };
75
+
76
+ const project = normalizeProject(input.project);
77
+ if (project) todo.project = project;
78
+
79
+ todo.tags = normalizeStringList(input.tags, 80);
80
+ todo.created_at = normalizeTimestamp(input.created_at || input.createdAt || DEFAULT_BUILT_AT, "created_at");
81
+
82
+ if (status === "pending") {
83
+ todo.completed_at = null;
84
+ } else {
85
+ todo.completed_at = normalizeTimestamp(input.completed_at || input.completedAt, "completed_at");
86
+ }
87
+
88
+ return todo;
89
+ });
90
+ }
@@ -0,0 +1,111 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const DEFAULT_DIMENSIONS = 384;
4
+ const FEATURE_SALTS = ["0", "1", "2", "3", "4", "5"];
5
+
6
+ function normalizeText(value) {
7
+ return String(value || "")
8
+ .normalize("NFKC")
9
+ .toLowerCase();
10
+ }
11
+
12
+ function addFeature(features, feature, weight) {
13
+ if (!feature) return;
14
+ features.set(feature, (features.get(feature) || 0) + weight);
15
+ }
16
+
17
+ function tokenize(text) {
18
+ const normalizedText = normalizeText(text);
19
+ const words = normalizedText
20
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
21
+ .split(/\s+/)
22
+ .filter((word) => word.length >= 2);
23
+
24
+ const features = new Map();
25
+ for (const word of words) {
26
+ addFeature(features, `w:${word}`, 1);
27
+ if (word.length > 3) {
28
+ for (let index = 0; index <= word.length - 3; index += 1) {
29
+ addFeature(features, `c:${word.slice(index, index + 3)}`, 0.25);
30
+ }
31
+ }
32
+ }
33
+
34
+ for (let index = 0; index < words.length - 1; index += 1) {
35
+ addFeature(features, `b:${words[index]} ${words[index + 1]}`, 0.75);
36
+ }
37
+
38
+ const koreanRuns = normalizedText.match(/[\uac00-\ud7af]+/g) || [];
39
+ for (const run of koreanRuns) {
40
+ for (let index = 0; index < run.length - 1; index += 1) {
41
+ addFeature(features, `ko:${run.slice(index, index + 2)}`, 0.8);
42
+ }
43
+ }
44
+
45
+ if (features.size === 0) {
46
+ addFeature(features, "empty", 1);
47
+ }
48
+
49
+ return features;
50
+ }
51
+
52
+ function projectFeature(vector, feature, weight) {
53
+ for (const salt of FEATURE_SALTS) {
54
+ const hash = crypto.createHash("sha256").update(`${salt}:${feature}`).digest();
55
+ const dimension = hash.readUInt32BE(0) % vector.length;
56
+ const sign = (hash.readUInt32BE(4) & 1) === 0 ? 1 : -1;
57
+ const magnitude = 0.5 + hash.readUInt16BE(8) / 0xffff;
58
+ vector[dimension] += sign * weight * magnitude;
59
+ }
60
+ }
61
+
62
+ function normalizeVector(vector) {
63
+ const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
64
+ if (norm === 0) return vector;
65
+ return vector.map((value) => value / norm);
66
+ }
67
+
68
+ export class EmbeddingModel {
69
+ constructor(options = {}) {
70
+ this.dimensions = Number(options.dimensions) || DEFAULT_DIMENSIONS;
71
+ this.loaded = false;
72
+ }
73
+
74
+ async load() {
75
+ this.loaded = true;
76
+ return this;
77
+ }
78
+
79
+ isAvailable() {
80
+ return true;
81
+ }
82
+
83
+ async embed(text) {
84
+ if (!this.loaded) {
85
+ await this.load();
86
+ }
87
+
88
+ const vector = Array.from({ length: this.dimensions }, () => 0);
89
+ const features = tokenize(text);
90
+ for (const [feature, weight] of features.entries()) {
91
+ projectFeature(vector, feature, weight);
92
+ }
93
+
94
+ return normalizeVector(vector);
95
+ }
96
+
97
+ async embedBatch(texts) {
98
+ if (!Array.isArray(texts)) return [];
99
+ if (!this.loaded) {
100
+ await this.load();
101
+ }
102
+
103
+ const embeddings = [];
104
+ for (const text of texts) {
105
+ embeddings.push(await this.embed(text));
106
+ }
107
+ return embeddings;
108
+ }
109
+ }
110
+
111
+ export default EmbeddingModel;