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,139 @@
1
+ export const VALID_STATUSES = ["candidate", "lesson", "problem", "rejected", "superseded", "private", "needs-clarification"];
2
+ export const VALID_SOURCES = ["auto-memory", "manual", "import", "notebooklm", "chat.message"];
3
+ export const VALID_CONFIDENCES = ["EXTRACTED", "INFERRED", "AMBIGUOUS"];
4
+
5
+ function compareStrings(a, b) {
6
+ const left = String(a ?? "");
7
+ const right = String(b ?? "");
8
+ if (left < right) return -1;
9
+ if (left > right) return 1;
10
+ return 0;
11
+ }
12
+
13
+ function isRecord(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ function stringifyRecord(value) {
18
+ try {
19
+ return JSON.stringify(value) || "";
20
+ } catch (error) {
21
+ return Object.prototype.toString.call(value);
22
+ }
23
+ }
24
+
25
+ export function normalizeScalar(value) {
26
+ const scalar = isRecord(value) ? stringifyRecord(value) : String(value ?? "");
27
+ return scalar
28
+ .replace(/\r?\n/g, " ")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
31
+ }
32
+
33
+ export function normalizeEntryText(value) {
34
+ if (Array.isArray(value)) {
35
+ return value.map((item) => normalizeScalar(item)).filter(Boolean).join(" ");
36
+ }
37
+ return normalizeScalar(value);
38
+ }
39
+
40
+ export function normalizeEntryTags(value) {
41
+ const values = Array.isArray(value) ? value : typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? [value] : [];
42
+ return [...new Set(values.map((item) => normalizeScalar(item)).filter(Boolean))].sort(compareStrings);
43
+ }
44
+
45
+ export function normalizeStringList(value) {
46
+ const values = Array.isArray(value) ? value : typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? [value] : [];
47
+ return [...new Set(values.map((item) => normalizeScalar(item)).filter(Boolean))].sort(compareStrings);
48
+ }
49
+
50
+ export function normalizeEntryStatus(value) {
51
+ const normalized = normalizeScalar(value || "candidate").toLowerCase();
52
+ return VALID_STATUSES.includes(normalized) ? normalized : "candidate";
53
+ }
54
+
55
+ export function normalizeEntrySource(value) {
56
+ const normalized = normalizeScalar(value || "manual").toLowerCase();
57
+ return VALID_SOURCES.includes(normalized) ? normalized : "manual";
58
+ }
59
+
60
+ export function normalizeEntryConfidence(value) {
61
+ const normalized = normalizeScalar(value || "EXTRACTED").toUpperCase();
62
+ return VALID_CONFIDENCES.includes(normalized) ? normalized : "EXTRACTED";
63
+ }
64
+
65
+ export function normalizeConfidenceScore(value, fallback = 1.0) {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
67
+ }
68
+
69
+ export function normalizeIndexEntry(entry, fallbackPath = "") {
70
+ const rawEntry = isRecord(entry) ? entry : {};
71
+ const normalizedPath = normalizeEntryText(rawEntry.path) || normalizeEntryText(fallbackPath);
72
+ const normalizedTitle = normalizeEntryText(rawEntry.title) || normalizeEntryText(rawEntry.summary) || normalizedPath;
73
+
74
+ return {
75
+ ...rawEntry,
76
+ path: normalizedPath,
77
+ category: normalizeEntryText(rawEntry.category) || "wiki",
78
+ title: normalizedTitle,
79
+ aliases: normalizeStringList(rawEntry.aliases),
80
+ tags: normalizeEntryTags(rawEntry.tags),
81
+ status: normalizeEntryStatus(rawEntry.status),
82
+ source: normalizeEntrySource(rawEntry.source),
83
+ summary: normalizeEntryText(rawEntry.summary || normalizedTitle),
84
+ confidence: normalizeEntryConfidence(rawEntry.confidence),
85
+ confidence_score: normalizeConfidenceScore(rawEntry.confidence_score),
86
+ related: normalizeStringList(rawEntry.related),
87
+ };
88
+ }
89
+
90
+ function normalizePostingList(value) {
91
+ if (!Array.isArray(value)) return [];
92
+ return [...new Set(value.map((item) => Number.parseInt(item, 10)).filter((item) => Number.isInteger(item) && item >= 0))]
93
+ .sort((left, right) => left - right);
94
+ }
95
+
96
+ function normalizeTermFrequency(value) {
97
+ if (!isRecord(value)) return {};
98
+ const normalized = {};
99
+ const tokenEntries = Object.entries(value)
100
+ .map(([token, entryCounts]) => {
101
+ const normalizedToken = normalizeScalar(token).toLowerCase();
102
+ if (!normalizedToken) return null;
103
+ if (!isRecord(entryCounts)) return null;
104
+ const normalizedCounts = Object.fromEntries(
105
+ Object.entries(entryCounts)
106
+ .map(([id, count]) => [Number.parseInt(id, 10), count])
107
+ .filter(([id, count]) => Number.isInteger(id) && id >= 0 && typeof count === "number" && count > 0)
108
+ .sort(([a], [b]) => a - b)
109
+ );
110
+ if (Object.keys(normalizedCounts).length === 0) return null;
111
+ return [normalizedToken, normalizedCounts];
112
+ })
113
+ .filter(Boolean)
114
+ .sort(([left], [right]) => compareStrings(left, right));
115
+ for (const [token, counts] of tokenEntries) {
116
+ normalized[token] = counts;
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ function normalizeInvertedIndex(value) {
122
+ if (!isRecord(value)) return {};
123
+ const normalizedEntries = Object.entries(value)
124
+ .map(([token, postings]) => [normalizeScalar(token).toLowerCase(), normalizePostingList(postings)])
125
+ .filter(([token, postings]) => token && postings.length > 0)
126
+ .sort(([left], [right]) => compareStrings(left, right));
127
+ return Object.fromEntries(normalizedEntries);
128
+ }
129
+
130
+ export function normalizeWikiIndex(index) {
131
+ const rawIndex = isRecord(index) ? index : {};
132
+ const rawEntries = Array.isArray(rawIndex.entries) ? rawIndex.entries : [];
133
+ return {
134
+ ...rawIndex,
135
+ entries: rawEntries.map((entry, entryIndex) => normalizeIndexEntry(entry, `entry-${entryIndex}.md`)),
136
+ inverted_index: normalizeInvertedIndex(rawIndex.inverted_index),
137
+ term_frequency: normalizeTermFrequency(rawIndex.term_frequency),
138
+ };
139
+ }
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Runtime ingest engine for oh-my-llmwikimode.
3
+ *
4
+ * Creates review-first candidate entries in inbox/ after privacy gates,
5
+ * path validation, and secret redaction.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { hasPrivacyOptOut, hasOversizedMessage } from "../hooks.js";
11
+ import { containsPromptInjection, redactSecrets } from "../security.js";
12
+ import {
13
+ buildIndex,
14
+ ensureWikiStructure,
15
+ getWikiPaths,
16
+ storeKnowledge,
17
+ } from "./store.js";
18
+ import { importNotebookLmArtifacts } from "./notebooklm-adapter.js";
19
+
20
+ const ALLOWED_SOURCES = new Set(["manual", "notebooklm", "import", "auto-memory"]);
21
+ const ALLOWED_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".csv"]);
22
+
23
+ function normalizeSource(source) {
24
+ const normalized = String(source || "manual").toLowerCase();
25
+ return ALLOWED_SOURCES.has(normalized) ? normalized : null;
26
+ }
27
+
28
+ function normalizeTags(tags, fallbackSource) {
29
+ if (Array.isArray(tags)) {
30
+ return tags.map((tag) => String(tag || "").trim()).filter(Boolean);
31
+ }
32
+
33
+ if (typeof tags === "string") {
34
+ return tags
35
+ .split(",")
36
+ .map((tag) => tag.trim())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ return [fallbackSource].filter(Boolean);
41
+ }
42
+
43
+ function isInsideOrEqual(parentDirectory, candidatePath) {
44
+ const relativePath = path.relative(parentDirectory, candidatePath);
45
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
46
+ }
47
+
48
+ function relativeWikiPath(wikiRoot, filePath) {
49
+ return path.relative(wikiRoot, filePath).replace(/\\/g, "/");
50
+ }
51
+
52
+ function baseResult({ wikiRoot, source }) {
53
+ return {
54
+ success: true,
55
+ wikiRoot,
56
+ source,
57
+ candidatePaths: [],
58
+ rejections: [],
59
+ duplicateWarnings: [],
60
+ duplicates: [],
61
+ };
62
+ }
63
+
64
+ function failResult({ wikiRoot, source, error, rejections = [] }) {
65
+ return {
66
+ ...baseResult({ wikiRoot, source }),
67
+ success: false,
68
+ error,
69
+ rejections,
70
+ };
71
+ }
72
+
73
+ function rejection(input, reason, message) {
74
+ return { input, reason, message };
75
+ }
76
+
77
+ function privacyGate(text, inputLabel) {
78
+ if (!text || !String(text).trim()) {
79
+ return rejection(inputLabel, "empty", "Input is empty");
80
+ }
81
+
82
+ if (hasPrivacyOptOut(text)) {
83
+ return rejection(inputLabel, "privacy-opt-out", "Privacy opt-out detected; refusing to store content");
84
+ }
85
+
86
+ if (hasOversizedMessage(text)) {
87
+ return rejection(inputLabel, "oversized", "Input exceeds the safe ingest size limit");
88
+ }
89
+
90
+ if (containsPromptInjection(text)) {
91
+ return rejection(inputLabel, "prompt-injection", "Prompt injection pattern detected; refusing to store content");
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ function ensureSafeRoot(wikiRoot) {
98
+ const rootPath = path.resolve(wikiRoot);
99
+ const paths = getWikiPaths(rootPath);
100
+ ensureWikiStructure(paths);
101
+ return { rootPath, realRoot: fs.realpathSync(rootPath) };
102
+ }
103
+
104
+ function validateInputPath({ wikiRoot, inputPath, allowOutsideWikiRoot = false }) {
105
+ if (!inputPath || typeof inputPath !== "string") {
106
+ return { success: false, error: "Input path is required" };
107
+ }
108
+
109
+ const resolvedPath = path.resolve(inputPath);
110
+ if (!fs.existsSync(resolvedPath)) {
111
+ return { success: false, error: "Input path does not exist" };
112
+ }
113
+
114
+ const stat = fs.lstatSync(resolvedPath);
115
+ if (stat.isSymbolicLink()) {
116
+ return { success: false, error: "Symbolic link input paths are not allowed", reason: "symlink" };
117
+ }
118
+
119
+ if (!allowOutsideWikiRoot) {
120
+ const { realRoot } = ensureSafeRoot(wikiRoot);
121
+ const realInput = fs.realpathSync(resolvedPath);
122
+ if (!isInsideOrEqual(realRoot, realInput)) {
123
+ return { success: false, error: "Input path resolves outside wiki root", reason: "outside-wiki-root" };
124
+ }
125
+ }
126
+
127
+ return { success: true, path: resolvedPath, stat };
128
+ }
129
+
130
+ function validateExtension(filePath) {
131
+ const extension = path.extname(filePath).toLowerCase();
132
+ return ALLOWED_EXTENSIONS.has(extension);
133
+ }
134
+
135
+ function collectFilesFromDirectory({ wikiRoot, directoryPath }) {
136
+ const files = [];
137
+ const rejections = [];
138
+ const { realRoot } = ensureSafeRoot(wikiRoot);
139
+
140
+ function visit(currentDirectory) {
141
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
142
+
143
+ for (const entry of entries) {
144
+ const fullPath = path.join(currentDirectory, entry.name);
145
+ const inputLabel = relativeWikiPath(wikiRoot, fullPath);
146
+ const stat = fs.lstatSync(fullPath);
147
+
148
+ if (stat.isSymbolicLink()) {
149
+ rejections.push(rejection(inputLabel, "symlink", "Symbolic links are not allowed"));
150
+ continue;
151
+ }
152
+
153
+ const realPath = fs.realpathSync(fullPath);
154
+ if (!isInsideOrEqual(realRoot, realPath)) {
155
+ rejections.push(rejection(inputLabel, "outside-wiki-root", "File resolves outside wiki root"));
156
+ continue;
157
+ }
158
+
159
+ if (stat.isDirectory()) {
160
+ visit(fullPath);
161
+ continue;
162
+ }
163
+
164
+ if (!stat.isFile()) {
165
+ rejections.push(rejection(inputLabel, "not-file", "Only regular files can be ingested"));
166
+ continue;
167
+ }
168
+
169
+ if (!validateExtension(fullPath)) {
170
+ rejections.push(rejection(inputLabel, "invalid-extension", "File extension is not allowed"));
171
+ continue;
172
+ }
173
+
174
+ files.push(fullPath);
175
+ }
176
+ }
177
+
178
+ visit(directoryPath);
179
+ return { files, rejections };
180
+ }
181
+
182
+ function titleFromInput({ providedTitle, filePath, text }) {
183
+ if (providedTitle && String(providedTitle).trim()) {
184
+ return String(providedTitle).trim();
185
+ }
186
+
187
+ if (filePath) {
188
+ return path.basename(filePath, path.extname(filePath)).replace(/[-_]+/g, " ").trim() || "Imported Knowledge";
189
+ }
190
+
191
+ const firstLine = String(text || "")
192
+ .split(/\r?\n/)
193
+ .map((line) => line.trim())
194
+ .find(Boolean);
195
+ return (firstLine || "Manual Ingest").slice(0, 100);
196
+ }
197
+
198
+ function duplicateWarningsForCandidates(wikiRoot, candidatePaths) {
199
+ if (candidatePaths.length === 0) {
200
+ return { duplicateWarnings: [], duplicates: [] };
201
+ }
202
+
203
+ const index = buildIndex(wikiRoot);
204
+ const candidateRelPaths = new Set(candidatePaths.map((candidatePath) => relativeWikiPath(wikiRoot, candidatePath)));
205
+ const duplicates = (index.duplicates || []).filter((duplicate) => (
206
+ Array.isArray(duplicate.entries)
207
+ && duplicate.entries.some((entryPath) => candidateRelPaths.has(entryPath))
208
+ ));
209
+
210
+ return {
211
+ duplicates,
212
+ duplicateWarnings: duplicates.map((duplicate) => (
213
+ `Possible duplicate ${duplicate.type}: ${duplicate.entries.join(", ")}`
214
+ )),
215
+ };
216
+ }
217
+
218
+ function ingestTextItems({ wikiRoot, source, title, tags, items, project, initialRejections = [] }) {
219
+ const result = baseResult({ wikiRoot, source });
220
+ result.rejections.push(...initialRejections);
221
+ const safeTags = normalizeTags(tags, source).map((tag) => redactSecrets(tag));
222
+
223
+ for (const item of items) {
224
+ const gateRejection = privacyGate(item.text, item.input);
225
+ if (gateRejection) {
226
+ result.rejections.push(gateRejection);
227
+ continue;
228
+ }
229
+
230
+ const safeText = redactSecrets(item.text);
231
+ const safeTitle = redactSecrets(titleFromInput({ providedTitle: item.title || title, filePath: item.filePath, text: item.text }));
232
+ const candidatePath = storeKnowledge(wikiRoot, {
233
+ summary: safeTitle,
234
+ details: safeText,
235
+ tags: safeTags,
236
+ status: "candidate",
237
+ source,
238
+ project,
239
+ });
240
+
241
+ if (candidatePath) {
242
+ result.candidatePaths.push(candidatePath);
243
+ }
244
+ }
245
+
246
+ const duplicateResult = duplicateWarningsForCandidates(wikiRoot, result.candidatePaths);
247
+ result.duplicates = duplicateResult.duplicates;
248
+ result.duplicateWarnings = duplicateResult.duplicateWarnings;
249
+
250
+ if (result.candidatePaths.length === 0) {
251
+ const firstRejection = result.rejections[0];
252
+ return {
253
+ ...result,
254
+ success: false,
255
+ error: firstRejection?.message || "No valid input was ingested",
256
+ };
257
+ }
258
+
259
+ result.message = `Ingested ${result.candidatePaths.length} candidate${result.candidatePaths.length === 1 ? "" : "s"}.`;
260
+ return result;
261
+ }
262
+
263
+ function notebookLmGate(inputPath) {
264
+ const files = [];
265
+ const entries = fs.readdirSync(inputPath);
266
+
267
+ for (const entry of entries) {
268
+ const fullPath = path.join(inputPath, entry);
269
+ const stat = fs.lstatSync(fullPath);
270
+ if (stat.isSymbolicLink() || !stat.isFile() || !validateExtension(fullPath)) {
271
+ continue;
272
+ }
273
+ files.push(fullPath);
274
+ }
275
+
276
+ for (const filePath of files) {
277
+ const text = fs.readFileSync(filePath, "utf-8");
278
+ const gateRejection = privacyGate(text, path.basename(filePath));
279
+ if (gateRejection) {
280
+ return gateRejection;
281
+ }
282
+ }
283
+
284
+ return null;
285
+ }
286
+
287
+ function ingestNotebookLm({ wikiRoot, inputPath, title, tags, originalQuery }) {
288
+ const source = "notebooklm";
289
+ const validation = validateInputPath({ wikiRoot, inputPath, allowOutsideWikiRoot: true });
290
+ if (!validation.success) {
291
+ return failResult({
292
+ wikiRoot,
293
+ source,
294
+ error: validation.error,
295
+ rejections: [rejection(inputPath, validation.reason || "invalid-path", validation.error)],
296
+ });
297
+ }
298
+
299
+ if (!validation.stat.isDirectory()) {
300
+ return failResult({
301
+ wikiRoot,
302
+ source,
303
+ error: "NotebookLM input path must be a directory",
304
+ rejections: [rejection(inputPath, "not-directory", "NotebookLM input path must be a directory")],
305
+ });
306
+ }
307
+
308
+ const gateRejection = notebookLmGate(validation.path);
309
+ if (gateRejection) {
310
+ return failResult({ wikiRoot, source, error: gateRejection.message, rejections: [gateRejection] });
311
+ }
312
+
313
+ const importResult = importNotebookLmArtifacts({
314
+ inputPath: validation.path,
315
+ wikiRoot,
316
+ originalQuery: originalQuery || "",
317
+ title,
318
+ tags: normalizeTags(tags, null),
319
+ });
320
+
321
+ if (!importResult.success) {
322
+ return failResult({
323
+ wikiRoot,
324
+ source,
325
+ error: importResult.error,
326
+ rejections: [rejection(inputPath, "notebooklm-import", importResult.error)],
327
+ });
328
+ }
329
+
330
+ const duplicateResult = duplicateWarningsForCandidates(wikiRoot, importResult.candidatePaths || []);
331
+ return {
332
+ ...baseResult({ wikiRoot, source }),
333
+ ...importResult,
334
+ candidatePaths: importResult.candidatePaths || [],
335
+ rejections: [],
336
+ duplicates: duplicateResult.duplicates,
337
+ duplicateWarnings: duplicateResult.duplicateWarnings,
338
+ message: `Imported ${importResult.artifactCount} NotebookLM artifact${importResult.artifactCount === 1 ? "" : "s"}.`,
339
+ };
340
+ }
341
+
342
+ export function ingestKnowledge(options = {}) {
343
+ const wikiRoot = path.resolve(options.wikiRoot || "");
344
+ const source = normalizeSource(options.source);
345
+
346
+ if (!source) {
347
+ return failResult({
348
+ wikiRoot,
349
+ source: String(options.source || ""),
350
+ error: "Invalid source; expected manual, notebooklm, import, or auto-memory",
351
+ });
352
+ }
353
+
354
+ ensureSafeRoot(wikiRoot);
355
+
356
+ if (source === "notebooklm") {
357
+ return ingestNotebookLm({
358
+ wikiRoot,
359
+ inputPath: options.inputPath,
360
+ title: options.title,
361
+ tags: options.tags,
362
+ originalQuery: options.originalQuery,
363
+ });
364
+ }
365
+
366
+ const hasInputText = typeof options.inputText === "string";
367
+ const hasInputPath = typeof options.inputPath === "string" && options.inputPath.length > 0;
368
+
369
+ if (hasInputText && hasInputPath) {
370
+ return failResult({ wikiRoot, source, error: "Provide either stdin text or input-path, not both" });
371
+ }
372
+
373
+ if (!hasInputText && !hasInputPath) {
374
+ return failResult({ wikiRoot, source, error: "No ingest input provided" });
375
+ }
376
+
377
+ if (hasInputText) {
378
+ return ingestTextItems({
379
+ wikiRoot,
380
+ source,
381
+ title: options.title,
382
+ tags: options.tags,
383
+ project: options.project,
384
+ items: [{ input: "stdin", text: options.inputText }],
385
+ });
386
+ }
387
+
388
+ const validation = validateInputPath({ wikiRoot, inputPath: options.inputPath });
389
+ if (!validation.success) {
390
+ return failResult({
391
+ wikiRoot,
392
+ source,
393
+ error: validation.error,
394
+ rejections: [rejection(options.inputPath, validation.reason || "invalid-path", validation.error)],
395
+ });
396
+ }
397
+
398
+ if (validation.stat.isFile()) {
399
+ if (!validateExtension(validation.path)) {
400
+ const item = rejection(relativeWikiPath(wikiRoot, validation.path), "invalid-extension", "File extension is not allowed");
401
+ return failResult({ wikiRoot, source, error: item.message, rejections: [item] });
402
+ }
403
+
404
+ return ingestTextItems({
405
+ wikiRoot,
406
+ source,
407
+ title: options.title,
408
+ tags: options.tags,
409
+ project: options.project,
410
+ items: [{
411
+ input: relativeWikiPath(wikiRoot, validation.path),
412
+ filePath: validation.path,
413
+ text: fs.readFileSync(validation.path, "utf-8"),
414
+ }],
415
+ });
416
+ }
417
+
418
+ if (validation.stat.isDirectory()) {
419
+ const collected = collectFilesFromDirectory({ wikiRoot, directoryPath: validation.path });
420
+ const items = collected.files.map((filePath) => ({
421
+ input: relativeWikiPath(wikiRoot, filePath),
422
+ filePath,
423
+ text: fs.readFileSync(filePath, "utf-8"),
424
+ }));
425
+
426
+ return ingestTextItems({
427
+ wikiRoot,
428
+ source,
429
+ title: options.title,
430
+ tags: options.tags,
431
+ project: options.project,
432
+ items,
433
+ initialRejections: collected.rejections,
434
+ });
435
+ }
436
+
437
+ return failResult({
438
+ wikiRoot,
439
+ source,
440
+ error: "Input path must be a regular file or directory",
441
+ rejections: [rejection(options.inputPath, "not-file-or-directory", "Input path must be a regular file or directory")],
442
+ });
443
+ }