@3030-labs/wotw 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
4
+
5
+ // src/daemon/config.ts
6
+ import { cosmiconfig } from "cosmiconfig";
7
+ import { parse as parseYaml } from "yaml";
8
+ import { z } from "zod";
9
+
10
+ // src/utils/actionable-error.ts
11
+ var ActionableError = class extends Error {
12
+ static {
13
+ __name(this, "ActionableError");
14
+ }
15
+ code;
16
+ summary;
17
+ suggestions;
18
+ docs;
19
+ constructor(opts) {
20
+ const lines = [opts.summary];
21
+ if (opts.suggestions.length > 0) {
22
+ lines.push("", "What to try:");
23
+ for (const suggestion of opts.suggestions) {
24
+ lines.push(` - ${suggestion}`);
25
+ }
26
+ }
27
+ if (opts.docs) {
28
+ lines.push("", `Docs: ${opts.docs}`);
29
+ }
30
+ super(lines.join("\n"));
31
+ this.name = "ActionableError";
32
+ this.code = opts.code;
33
+ this.summary = opts.summary;
34
+ this.suggestions = opts.suggestions;
35
+ this.docs = opts.docs;
36
+ if (opts.cause !== void 0) {
37
+ this.cause = opts.cause;
38
+ }
39
+ }
40
+ };
41
+ function looksLikePermissionDenied(e) {
42
+ const msg = e instanceof Error ? e.message : String(e);
43
+ const code = e instanceof Error ? e.code : void 0;
44
+ return code === "EACCES" || code === "EPERM" || /EACCES|EPERM/i.test(msg);
45
+ }
46
+ __name(looksLikePermissionDenied, "looksLikePermissionDenied");
47
+ function configParseError(configPath, cause) {
48
+ return new ActionableError({
49
+ code: "CONFIG_PARSE_ERROR",
50
+ summary: `Could not parse wotw config at ${configPath}: ${cause instanceof Error ? cause.message : String(cause)}`,
51
+ suggestions: [
52
+ `Validate the file with \`node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")))' ` + configPath + "` (for JSON) or `yamllint " + configPath + "` (for YAML).",
53
+ "Check for unmatched braces, missing colons, or trailing commas.",
54
+ "Compare against the example at docs/configuration.md.",
55
+ "Delete the file and re-run `wotw init` to regenerate a fresh config."
56
+ ],
57
+ docs: "docs/configuration.md",
58
+ cause
59
+ });
60
+ }
61
+ __name(configParseError, "configParseError");
62
+ function wikiDirPermissionError(path, cause) {
63
+ return new ActionableError({
64
+ code: "WIKI_DIR_PERMISSION_DENIED",
65
+ summary: `Cannot create or write to wiki directory at ${path}: permission denied.`,
66
+ suggestions: [
67
+ `Check the directory's owner and permissions: \`ls -la "${path}"\`.`,
68
+ `Ensure the daemon's user can write: \`chmod u+w "${path}"\` (or relocate to a writable parent).`,
69
+ "If running under Docker, confirm the volume mount has the right ownership.",
70
+ "Choose a different `wiki_root` in wotw.config.yaml."
71
+ ],
72
+ docs: "docs/configuration.md",
73
+ cause
74
+ });
75
+ }
76
+ __name(wikiDirPermissionError, "wikiDirPermissionError");
77
+ function daemonAlreadyRunningError(lockPath, cause) {
78
+ return new ActionableError({
79
+ code: "DAEMON_ALREADY_RUNNING",
80
+ summary: `Another wotw daemon already holds the lock at ${lockPath}.`,
81
+ suggestions: [
82
+ "Run `wotw status` to see the live daemon's PID and health.",
83
+ "If you intend to replace it, `wotw stop` first, then `wotw start`.",
84
+ "If the lock is stale (e.g. after a crash), `wotw stop --force` clears it."
85
+ ],
86
+ docs: "docs/cli-reference.md",
87
+ cause
88
+ });
89
+ }
90
+ __name(daemonAlreadyRunningError, "daemonAlreadyRunningError");
91
+
92
+ // src/utils/fs.ts
93
+ import {
94
+ existsSync,
95
+ mkdirSync,
96
+ readFileSync,
97
+ renameSync,
98
+ rmSync,
99
+ statSync,
100
+ writeFileSync
101
+ } from "fs";
102
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
103
+ import { homedir } from "os";
104
+ import { dirname, isAbsolute, resolve } from "path";
105
+ import { randomUUID } from "crypto";
106
+ function expandHome(path) {
107
+ if (path.startsWith("~")) {
108
+ return resolve(homedir(), path.slice(path.startsWith("~/") ? 2 : 1));
109
+ }
110
+ return path;
111
+ }
112
+ __name(expandHome, "expandHome");
113
+ function resolvePath(path, base) {
114
+ const expanded = expandHome(path);
115
+ if (isAbsolute(expanded)) return expanded;
116
+ return resolve(base ?? process.cwd(), expanded);
117
+ }
118
+ __name(resolvePath, "resolvePath");
119
+ function ensureDirSync(dir) {
120
+ if (!existsSync(dir)) {
121
+ mkdirSync(dir, { recursive: true });
122
+ }
123
+ }
124
+ __name(ensureDirSync, "ensureDirSync");
125
+ function atomicWriteSync(filePath, contents) {
126
+ ensureDirSync(dirname(filePath));
127
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
128
+ let renamed = false;
129
+ try {
130
+ writeFileSync(tmp, contents);
131
+ renameSync(tmp, filePath);
132
+ renamed = true;
133
+ } finally {
134
+ if (!renamed) {
135
+ try {
136
+ rmSync(tmp, { force: true });
137
+ } catch {
138
+ }
139
+ }
140
+ }
141
+ }
142
+ __name(atomicWriteSync, "atomicWriteSync");
143
+ function removeIfExistsSync(filePath) {
144
+ if (existsSync(filePath)) {
145
+ rmSync(filePath, { force: true });
146
+ }
147
+ }
148
+ __name(removeIfExistsSync, "removeIfExistsSync");
149
+
150
+ // src/daemon/config.ts
151
+ var MODULE_NAME = "wotw";
152
+ var PLAN_DEFAULTS = {
153
+ founding: {
154
+ storage_bytes: 2 * 1024 ** 3,
155
+ // 2 GB
156
+ max_files_per_day: 50,
157
+ max_file_size_bytes: 25 * 1024 ** 2,
158
+ // 25 MB
159
+ max_ingest_bytes_per_day: 250 * 1024 ** 2,
160
+ // 250 MB
161
+ heal_cooldown_seconds: 3600,
162
+ // 1 hour
163
+ query_rate_limit_per_hour: 60
164
+ },
165
+ pro: {
166
+ storage_bytes: 10 * 1024 ** 3,
167
+ // 10 GB
168
+ max_files_per_day: 200,
169
+ max_file_size_bytes: 100 * 1024 ** 2,
170
+ // 100 MB
171
+ max_ingest_bytes_per_day: 1024 ** 3,
172
+ // 1 GB
173
+ heal_cooldown_seconds: 900,
174
+ // 15 min
175
+ query_rate_limit_per_hour: 300
176
+ }
177
+ };
178
+ function defaultConfig() {
179
+ return {
180
+ wiki_root: "./wiki-store",
181
+ raw_path: "./wiki-store/raw",
182
+ llm: {
183
+ provider: "anthropic",
184
+ model: "claude-sonnet-4-5"
185
+ },
186
+ execution: {
187
+ mode: "auto",
188
+ cli_path: "claude",
189
+ cli_model: "claude-sonnet-4-5",
190
+ api_key_env: "ANTHROPIC_API_KEY"
191
+ },
192
+ models: {
193
+ ingest: "claude-haiku-4-5",
194
+ query: "claude-sonnet-4-5",
195
+ lint: "claude-sonnet-4-5",
196
+ compound_eval: "claude-haiku-4-5"
197
+ },
198
+ watcher: {
199
+ debounce_initial_ms: 5e3,
200
+ debounce_max_ms: 6e4,
201
+ debounce_growth_factor: 1.5,
202
+ burst_threshold: 5,
203
+ max_batch_size: 20,
204
+ ignore_patterns: ["**/.git/**", "**/node_modules/**", "**/.DS_Store", "**/Thumbs.db"]
205
+ },
206
+ ingestion: {
207
+ max_turns: 50,
208
+ max_budget_per_batch_usd: 1,
209
+ resume_session: true,
210
+ dead_letter_file: ".wotw/failed-batches.jsonl",
211
+ staging: true
212
+ },
213
+ cost: {
214
+ max_daily_usd: 10,
215
+ max_per_query_usd: 0.5,
216
+ max_per_ingest_usd: 2,
217
+ track_file: "~/.wotw/cost-log.jsonl"
218
+ },
219
+ server: {
220
+ port: 8787,
221
+ host: "127.0.0.1",
222
+ auth_token: null,
223
+ rate_limit_rpm: 60,
224
+ trust_proxy: false
225
+ },
226
+ daemon: {
227
+ pid_file: "~/.wotw/daemon.pid",
228
+ lock_file: "~/.wotw/daemon.lock",
229
+ log_file: "~/.wotw/daemon.log",
230
+ log_level: "info"
231
+ },
232
+ compounding: {
233
+ enabled: true,
234
+ min_source_pages: 3,
235
+ confidence_threshold: 70
236
+ },
237
+ provenance: {
238
+ enabled: true,
239
+ chain_file: "provenance-chain.jsonl",
240
+ // Review item 37: default ON so partial-corruption is detected at
241
+ // boot. Pre-fix default false let a chain with one bad line boot
242
+ // silently and advance on top of a verifiably-broken tail forever.
243
+ verify_on_startup: true
244
+ },
245
+ multi_user: {
246
+ enabled: false,
247
+ workspaces_dir: "~/.wotw/workspaces"
248
+ },
249
+ query: {
250
+ expand: true
251
+ },
252
+ fact_extraction: {
253
+ enabled: "auto",
254
+ force_enabled: false,
255
+ questions_per_fact: 3
256
+ },
257
+ lint: {
258
+ schedule_enabled: false,
259
+ interval_hours: 24,
260
+ auto_fix: false
261
+ },
262
+ health: {
263
+ staleness_thresholds: [7, 30, 90, 180, 365],
264
+ staleness_scores: [100, 80, 60, 40, 20, 0],
265
+ weights: {
266
+ staleness: 0.25,
267
+ source_availability: 0.25,
268
+ link_health: 0.2,
269
+ duplicate_risk: 0.15,
270
+ contradiction_risk: 0.15
271
+ },
272
+ duplicate_threshold: 60,
273
+ auto_fix_staleness_below: 40,
274
+ max_fixes_per_run: 10,
275
+ detect_contradictions: false,
276
+ consolidation_threshold: 5,
277
+ consolidation_enabled: true,
278
+ zero_hit_threshold: 0.2,
279
+ enrichment_enabled: true,
280
+ query_log_file: ".wotw/query-log.jsonl"
281
+ },
282
+ hosted: {
283
+ enabled: false,
284
+ tenant_id: null,
285
+ concurrency_cap: 1,
286
+ paused: false,
287
+ plan: "pro",
288
+ limits: {
289
+ storage_bytes: PLAN_DEFAULTS.pro.storage_bytes,
290
+ max_files_per_day: PLAN_DEFAULTS.pro.max_files_per_day,
291
+ max_file_size_bytes: PLAN_DEFAULTS.pro.max_file_size_bytes,
292
+ max_ingest_bytes_per_day: PLAN_DEFAULTS.pro.max_ingest_bytes_per_day,
293
+ heal_cooldown_seconds: PLAN_DEFAULTS.pro.heal_cooldown_seconds,
294
+ query_rate_limit_per_hour: PLAN_DEFAULTS.pro.query_rate_limit_per_hour,
295
+ onboarding_burst_multiplier: 3,
296
+ onboarding_burst_hours: 48
297
+ },
298
+ timezone: "America/New_York",
299
+ created_at: null
300
+ }
301
+ };
302
+ }
303
+ __name(defaultConfig, "defaultConfig");
304
+ async function loadConfig(searchFrom) {
305
+ const explorer = cosmiconfig(MODULE_NAME, {
306
+ searchPlaces: [
307
+ "package.json",
308
+ `.${MODULE_NAME}rc`,
309
+ `.${MODULE_NAME}rc.json`,
310
+ `.${MODULE_NAME}rc.yaml`,
311
+ `.${MODULE_NAME}rc.yml`,
312
+ // `wotw.yaml` / `wotw.yml` are what the `wotw init` wizard writes
313
+ // (see src/cli/commands/init.ts:CONFIG_CANDIDATES). They must be in
314
+ // searchPlaces or every fresh-init vault runs with all-defaults.
315
+ // Finding #12 from PASS-023 dogfood pass.
316
+ `${MODULE_NAME}.yaml`,
317
+ `${MODULE_NAME}.yml`,
318
+ `${MODULE_NAME}.config.json`,
319
+ `${MODULE_NAME}.config.yaml`,
320
+ `${MODULE_NAME}.config.yml`
321
+ ],
322
+ loaders: {
323
+ ".yaml": /* @__PURE__ */ __name((_filepath, content) => parseYaml(content), ".yaml"),
324
+ ".yml": /* @__PURE__ */ __name((_filepath, content) => parseYaml(content), ".yml")
325
+ }
326
+ });
327
+ let result;
328
+ try {
329
+ result = await explorer.search(searchFrom ?? process.cwd());
330
+ } catch (err) {
331
+ const hintPath = err instanceof Error && err.filepath ? err.filepath : searchFrom ?? process.cwd();
332
+ throw configParseError(hintPath, err);
333
+ }
334
+ const defaults = defaultConfig();
335
+ let merged;
336
+ let path = null;
337
+ if (!result || !result.config) {
338
+ console.warn(
339
+ "[wotw] no wotw.yaml found \u2014 using all defaults (auth disabled, max_daily_usd: 10.0)"
340
+ );
341
+ merged = defaults;
342
+ } else {
343
+ merged = mergeConfig(defaults, result.config);
344
+ path = result.filepath;
345
+ }
346
+ const withEnv = applyEnvOverrides(merged);
347
+ const validated = validateConfig(withEnv);
348
+ validateHostedConfig(validated);
349
+ return { config: validated, path };
350
+ }
351
+ __name(loadConfig, "loadConfig");
352
+ function applyEnvOverrides(config) {
353
+ const out = structuredClone(config);
354
+ const env = process.env;
355
+ if (env.WOTW_HOSTED !== void 0) {
356
+ const v = env.WOTW_HOSTED.trim().toLowerCase();
357
+ out.hosted.enabled = v === "true" || v === "1" || v === "yes" || v === "on";
358
+ }
359
+ if (env.TENANT_ID && env.TENANT_ID.length > 0) {
360
+ out.hosted.tenant_id = env.TENANT_ID;
361
+ }
362
+ if (env.WIKI_ROOT && env.WIKI_ROOT.length > 0) {
363
+ out.wiki_root = env.WIKI_ROOT;
364
+ if (out.raw_path === "./wiki-store/raw") {
365
+ out.raw_path = "raw";
366
+ }
367
+ }
368
+ if (env.WOTW_PLAN === "founding" || env.WOTW_PLAN === "pro") {
369
+ out.hosted.plan = env.WOTW_PLAN;
370
+ }
371
+ if (env.WOTW_TIMEZONE && env.WOTW_TIMEZONE.length > 0) {
372
+ out.hosted.timezone = env.WOTW_TIMEZONE;
373
+ }
374
+ if (env.WOTW_PORT) {
375
+ const n = Number.parseInt(env.WOTW_PORT, 10);
376
+ if (Number.isFinite(n) && n > 0 && n < 65536) {
377
+ out.server.port = n;
378
+ }
379
+ }
380
+ if (env.WOTW_HOST && env.WOTW_HOST.length > 0) {
381
+ out.server.host = env.WOTW_HOST;
382
+ }
383
+ if (env.WOTW_LOG_LEVEL) {
384
+ const lvl = env.WOTW_LOG_LEVEL;
385
+ if (lvl === "trace" || lvl === "debug" || lvl === "info" || lvl === "warn" || lvl === "error" || lvl === "fatal") {
386
+ out.daemon.log_level = lvl;
387
+ }
388
+ }
389
+ if (env.WOTW_RUNTIME_MODE === "auto" || env.WOTW_RUNTIME_MODE === "cli" || env.WOTW_RUNTIME_MODE === "api") {
390
+ out.execution.mode = env.WOTW_RUNTIME_MODE;
391
+ }
392
+ if (env.WOTW_LLM_PROVIDER === "anthropic" || env.WOTW_LLM_PROVIDER === "openai" || env.WOTW_LLM_PROVIDER === "gemini" || env.WOTW_LLM_PROVIDER === "ollama") {
393
+ out.llm.provider = env.WOTW_LLM_PROVIDER;
394
+ switch (env.WOTW_LLM_PROVIDER) {
395
+ case "anthropic":
396
+ out.execution.api_key_env = "ANTHROPIC_API_KEY";
397
+ break;
398
+ case "openai":
399
+ out.execution.api_key_env = "OPENAI_API_KEY";
400
+ break;
401
+ case "gemini":
402
+ out.execution.api_key_env = "GOOGLE_API_KEY";
403
+ break;
404
+ case "ollama":
405
+ break;
406
+ }
407
+ }
408
+ if (env.WOTW_LLM_MODEL && env.WOTW_LLM_MODEL.length > 0) {
409
+ out.llm.model = env.WOTW_LLM_MODEL;
410
+ }
411
+ if (env.WOTW_OLLAMA_URL && env.WOTW_OLLAMA_URL.length > 0) {
412
+ out.llm.ollama_url = env.WOTW_OLLAMA_URL;
413
+ }
414
+ if (env.WOTW_MCP_BEARER && env.WOTW_MCP_BEARER.length > 0) {
415
+ out.server.auth_token = env.WOTW_MCP_BEARER;
416
+ } else if (env.ADMIN_SERVICE_KEY && env.ADMIN_SERVICE_KEY.length > 0) {
417
+ out.server.auth_token = env.ADMIN_SERVICE_KEY;
418
+ }
419
+ if (env.WOTW_LOG_FILE !== void 0) {
420
+ out.daemon.log_file = env.WOTW_LOG_FILE;
421
+ } else if (out.hosted.enabled) {
422
+ out.daemon.log_file = "";
423
+ }
424
+ if (out.hosted.enabled) {
425
+ out.ingestion.staging = false;
426
+ out.lint.schedule_enabled = true;
427
+ out.lint.auto_fix = true;
428
+ }
429
+ return out;
430
+ }
431
+ __name(applyEnvOverrides, "applyEnvOverrides");
432
+ var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
433
+ function validateHostedConfig(config) {
434
+ if (!config.hosted.enabled) return;
435
+ if (!config.hosted.tenant_id || config.hosted.tenant_id.length === 0) {
436
+ throw new Error(
437
+ "Config error: hosted.enabled is true but TENANT_ID / hosted.tenant_id is unset."
438
+ );
439
+ }
440
+ if (!UUID_REGEX.test(config.hosted.tenant_id)) {
441
+ throw new Error(
442
+ `Config error: hosted.tenant_id "${config.hosted.tenant_id}" is not a valid UUID.`
443
+ );
444
+ }
445
+ if (!config.wiki_root || config.wiki_root.length === 0) {
446
+ throw new Error("Config error: hosted.enabled is true but wiki_root / WIKI_ROOT is unset.");
447
+ }
448
+ if (!config.wiki_root.startsWith("/")) {
449
+ throw new Error(
450
+ `Config error: hosted.enabled is true but wiki_root "${config.wiki_root}" is not absolute. In hosted mode the daemon must persist tenant data on a mounted volume; a relative path resolves against process.cwd() which is the ephemeral container fs.`
451
+ );
452
+ }
453
+ }
454
+ __name(validateHostedConfig, "validateHostedConfig");
455
+ function mergeConfig(base, override) {
456
+ const out = structuredClone(base);
457
+ const assign = /* @__PURE__ */ __name((key, value) => {
458
+ out[key] = { ...out[key], ...value };
459
+ }, "assign");
460
+ if (override.wiki_root !== void 0) out.wiki_root = override.wiki_root;
461
+ if (override.raw_path !== void 0) out.raw_path = override.raw_path;
462
+ if (override.llm) assign("llm", override.llm);
463
+ if (override.execution) assign("execution", override.execution);
464
+ if (override.models) assign("models", override.models);
465
+ if (override.watcher) assign("watcher", override.watcher);
466
+ if (override.ingestion) assign("ingestion", override.ingestion);
467
+ if (override.cost) assign("cost", override.cost);
468
+ if (override.server) assign("server", override.server);
469
+ if (override.daemon) assign("daemon", override.daemon);
470
+ if (override.compounding) assign("compounding", override.compounding);
471
+ if (override.provenance) assign("provenance", override.provenance);
472
+ if (override.multi_user) assign("multi_user", override.multi_user);
473
+ if (override.query) assign("query", override.query);
474
+ if (override.fact_extraction) assign("fact_extraction", override.fact_extraction);
475
+ if (override.lint) assign("lint", override.lint);
476
+ if (override.health) {
477
+ const healthBase = out.health;
478
+ const healthOverride = override.health;
479
+ out.health = { ...healthBase, ...healthOverride };
480
+ if (healthOverride.weights) {
481
+ out.health.weights = { ...healthBase.weights, ...healthOverride.weights };
482
+ }
483
+ }
484
+ if (override.hosted) assign("hosted", override.hosted);
485
+ return out;
486
+ }
487
+ __name(mergeConfig, "mergeConfig");
488
+ var positiveNumber = z.number().positive();
489
+ var nonNegativeNumber = z.number().min(0);
490
+ var logLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
491
+ var llmProviderSchema = z.enum(["anthropic", "openai", "gemini", "ollama"]);
492
+ var WotwConfigSchema = z.object({
493
+ wiki_root: z.string().min(1),
494
+ raw_path: z.string().min(1),
495
+ llm: z.object({
496
+ provider: llmProviderSchema,
497
+ model: z.string().min(1),
498
+ ollama_url: z.string().min(1).optional()
499
+ }),
500
+ execution: z.object({
501
+ mode: z.enum(["auto", "cli", "api"]),
502
+ cli_path: z.string().min(1),
503
+ cli_model: z.string().min(1),
504
+ api_key_env: z.string().min(1)
505
+ }),
506
+ models: z.object({
507
+ ingest: z.string().min(1),
508
+ query: z.string().min(1),
509
+ lint: z.string().min(1),
510
+ compound_eval: z.string().min(1)
511
+ }),
512
+ watcher: z.object({
513
+ debounce_initial_ms: positiveNumber,
514
+ debounce_max_ms: positiveNumber,
515
+ debounce_growth_factor: positiveNumber,
516
+ burst_threshold: z.number().int().positive(),
517
+ max_batch_size: z.number().int().positive(),
518
+ ignore_patterns: z.array(z.string())
519
+ }),
520
+ ingestion: z.object({
521
+ max_turns: z.number().int().positive(),
522
+ max_budget_per_batch_usd: positiveNumber,
523
+ resume_session: z.boolean(),
524
+ dead_letter_file: z.string(),
525
+ staging: z.boolean()
526
+ }),
527
+ cost: z.object({
528
+ max_daily_usd: positiveNumber,
529
+ max_per_query_usd: positiveNumber,
530
+ max_per_ingest_usd: positiveNumber,
531
+ track_file: z.string().min(1)
532
+ }),
533
+ server: z.object({
534
+ port: z.number().int().min(1).max(65535),
535
+ host: z.string().min(1),
536
+ auth_token: z.string().nullable(),
537
+ rate_limit_rpm: z.number().int().positive(),
538
+ trust_proxy: z.boolean()
539
+ }),
540
+ daemon: z.object({
541
+ pid_file: z.string().min(1),
542
+ lock_file: z.string().min(1),
543
+ // Empty string is meaningful: "log to stdout" (used by hosted mode for
544
+ // container log capture). initLogger treats empty as no destination
545
+ // and defaults to pino's stdout output.
546
+ log_file: z.string(),
547
+ log_level: logLevelSchema
548
+ }),
549
+ compounding: z.object({
550
+ enabled: z.boolean(),
551
+ min_source_pages: z.number().int().min(0),
552
+ confidence_threshold: z.number().min(0).max(100)
553
+ }),
554
+ provenance: z.object({
555
+ enabled: z.boolean(),
556
+ chain_file: z.string().min(1),
557
+ verify_on_startup: z.boolean()
558
+ }),
559
+ multi_user: z.object({
560
+ enabled: z.boolean(),
561
+ workspaces_dir: z.string().min(1)
562
+ }),
563
+ query: z.object({
564
+ expand: z.boolean()
565
+ }),
566
+ fact_extraction: z.object({
567
+ enabled: z.union([z.boolean(), z.literal("auto")]),
568
+ force_enabled: z.boolean(),
569
+ questions_per_fact: z.number().int().min(1).max(5),
570
+ model: z.string().min(1).optional()
571
+ }),
572
+ lint: z.object({
573
+ schedule_enabled: z.boolean(),
574
+ interval_hours: positiveNumber,
575
+ auto_fix: z.boolean()
576
+ }),
577
+ hosted: z.object({
578
+ enabled: z.boolean(),
579
+ tenant_id: z.string().nullable(),
580
+ concurrency_cap: z.number().int().positive(),
581
+ paused: z.boolean(),
582
+ plan: z.enum(["founding", "pro"]),
583
+ limits: z.object({
584
+ storage_bytes: positiveNumber,
585
+ max_files_per_day: z.number().int().positive(),
586
+ max_file_size_bytes: positiveNumber,
587
+ max_ingest_bytes_per_day: positiveNumber,
588
+ heal_cooldown_seconds: nonNegativeNumber,
589
+ query_rate_limit_per_hour: z.number().int().positive(),
590
+ onboarding_burst_multiplier: positiveNumber,
591
+ onboarding_burst_hours: positiveNumber
592
+ }),
593
+ timezone: z.string().min(1),
594
+ created_at: z.string().nullable()
595
+ }),
596
+ health: z.object({
597
+ staleness_thresholds: z.array(z.number().int().min(0)),
598
+ staleness_scores: z.array(z.number().min(0).max(100)),
599
+ weights: z.object({
600
+ staleness: nonNegativeNumber,
601
+ source_availability: nonNegativeNumber,
602
+ link_health: nonNegativeNumber,
603
+ duplicate_risk: nonNegativeNumber,
604
+ contradiction_risk: nonNegativeNumber
605
+ }),
606
+ duplicate_threshold: z.number().min(0).max(100),
607
+ auto_fix_staleness_below: z.number().min(0).max(100),
608
+ max_fixes_per_run: z.number().int().min(0),
609
+ detect_contradictions: z.boolean(),
610
+ consolidation_threshold: z.number().int().min(2),
611
+ consolidation_enabled: z.boolean(),
612
+ zero_hit_threshold: z.number().min(0).max(1),
613
+ enrichment_enabled: z.boolean(),
614
+ query_log_file: z.string()
615
+ })
616
+ });
617
+ function validateConfig(config) {
618
+ const result = WotwConfigSchema.safeParse(config);
619
+ if (!result.success) {
620
+ const issue = result.error.issues[0];
621
+ const path = issue.path.join(".");
622
+ throw new Error(`Config error: "${path}" ${issue.message}`);
623
+ }
624
+ return result.data;
625
+ }
626
+ __name(validateConfig, "validateConfig");
627
+ function resolveConfigPaths(config, baseDir) {
628
+ const out = structuredClone(config);
629
+ out.wiki_root = resolvePath(out.wiki_root, baseDir);
630
+ out.raw_path = resolvePath(out.raw_path, baseDir);
631
+ out.cost.track_file = resolvePath(out.cost.track_file, baseDir);
632
+ out.daemon.pid_file = resolvePath(out.daemon.pid_file, baseDir);
633
+ out.daemon.lock_file = resolvePath(out.daemon.lock_file, baseDir);
634
+ if (out.daemon.log_file.length > 0) {
635
+ out.daemon.log_file = resolvePath(out.daemon.log_file, baseDir);
636
+ }
637
+ out.multi_user.workspaces_dir = resolvePath(out.multi_user.workspaces_dir, baseDir);
638
+ out.provenance.chain_file = resolvePath(out.provenance.chain_file, out.wiki_root);
639
+ if (out.ingestion.dead_letter_file.length > 0) {
640
+ out.ingestion.dead_letter_file = resolvePath(out.ingestion.dead_letter_file, out.wiki_root);
641
+ }
642
+ if (out.health.query_log_file.length > 0) {
643
+ out.health.query_log_file = resolvePath(out.health.query_log_file, out.wiki_root);
644
+ }
645
+ return out;
646
+ }
647
+ __name(resolveConfigPaths, "resolveConfigPaths");
648
+
649
+ // src/daemon/lifecycle.ts
650
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
651
+ import { dirname as dirname2 } from "path";
652
+ import lockfile from "proper-lockfile";
653
+ function writePidFile(pidFilePath, contents) {
654
+ ensureDirSync(dirname2(pidFilePath));
655
+ atomicWriteSync(pidFilePath, JSON.stringify(contents));
656
+ }
657
+ __name(writePidFile, "writePidFile");
658
+ function readPidFile(pidFilePath) {
659
+ if (!existsSync2(pidFilePath)) return null;
660
+ try {
661
+ const raw = readFileSync2(pidFilePath, "utf8");
662
+ const parsed = JSON.parse(raw);
663
+ if (parsed && typeof parsed === "object" && "pid" in parsed && typeof parsed.pid === "number") {
664
+ return parsed;
665
+ }
666
+ return null;
667
+ } catch {
668
+ return null;
669
+ }
670
+ }
671
+ __name(readPidFile, "readPidFile");
672
+ function removePidFile(pidFilePath) {
673
+ removeIfExistsSync(pidFilePath);
674
+ }
675
+ __name(removePidFile, "removePidFile");
676
+ function isProcessAlive(pid) {
677
+ try {
678
+ process.kill(pid, 0);
679
+ return true;
680
+ } catch (err) {
681
+ const code = err.code;
682
+ if (code === "ESRCH") return false;
683
+ if (code === "EPERM") return true;
684
+ return false;
685
+ }
686
+ }
687
+ __name(isProcessAlive, "isProcessAlive");
688
+ function checkDaemonAlive(pidFilePath) {
689
+ const contents = readPidFile(pidFilePath);
690
+ if (!contents) return { alive: false, pid: null, stale: false, contents: null };
691
+ const alive = isProcessAlive(contents.pid);
692
+ return { alive, pid: contents.pid, stale: !alive, contents };
693
+ }
694
+ __name(checkDaemonAlive, "checkDaemonAlive");
695
+ async function acquireStartLock(lockPath) {
696
+ ensureDirSync(dirname2(lockPath));
697
+ if (!existsSync2(lockPath)) writeFileSync2(lockPath, "");
698
+ const release = await lockfile.lock(lockPath, {
699
+ stale: 1e4,
700
+ retries: { retries: 0 }
701
+ });
702
+ return release;
703
+ }
704
+ __name(acquireStartLock, "acquireStartLock");
705
+
706
+ // src/utils/logger.ts
707
+ import { mkdirSync as mkdirSync2 } from "fs";
708
+ import { dirname as dirname3 } from "path";
709
+ import pino from "pino";
710
+ var rootLogger = null;
711
+ var REDACT_PATHS = [
712
+ "headers.authorization",
713
+ "headers.Authorization",
714
+ "*.headers.authorization",
715
+ "*.headers.Authorization",
716
+ "req.headers.authorization",
717
+ "req.headers.Authorization",
718
+ "request.headers.authorization",
719
+ "request.headers.Authorization",
720
+ "response.headers.authorization",
721
+ "response.headers.Authorization",
722
+ "headers['x-admin-key']",
723
+ "headers['x-api-key']",
724
+ "headers.cookie",
725
+ "*.headers.cookie",
726
+ "config.api_key",
727
+ "config.headers.authorization",
728
+ "err.config.headers.authorization",
729
+ "err.request.headers.authorization",
730
+ "err.response.headers.authorization",
731
+ // Provider SDK error shapes — observed leaking via err.headers.* on Axios-based clients.
732
+ "err.headers.authorization",
733
+ "err.headers.Authorization",
734
+ // Env-bag dumps (occasionally added by ad-hoc debug logs).
735
+ "env.ANTHROPIC_API_KEY",
736
+ "env.OPENAI_API_KEY",
737
+ "env.GOOGLE_API_KEY",
738
+ "env.ADMIN_SERVICE_KEY",
739
+ "env.WOTW_MCP_BEARER",
740
+ "env.WOTW_INTERNAL_ADMIN_KEY",
741
+ "env.WOTW_CLOUD_SINK_SECRET",
742
+ "ANTHROPIC_API_KEY",
743
+ "OPENAI_API_KEY",
744
+ "GOOGLE_API_KEY",
745
+ "ADMIN_SERVICE_KEY",
746
+ "WOTW_MCP_BEARER",
747
+ "WOTW_INTERNAL_ADMIN_KEY",
748
+ "WOTW_CLOUD_SINK_SECRET",
749
+ "apiKey",
750
+ "api_key",
751
+ "*.apiKey",
752
+ "*.api_key",
753
+ "secret",
754
+ "*.secret"
755
+ ];
756
+ function safeErrSerializer(err) {
757
+ if (!(err instanceof Error)) {
758
+ return { message: String(err) };
759
+ }
760
+ const out = {
761
+ type: err.constructor.name,
762
+ message: err.message
763
+ };
764
+ if (err.stack) out.stack = err.stack;
765
+ const maybeCode = err.code;
766
+ if (typeof maybeCode === "string") out.code = maybeCode;
767
+ const maybeCause = err.cause;
768
+ if (maybeCause && typeof maybeCause === "object" && "message" in maybeCause) {
769
+ out.cause = {
770
+ type: maybeCause.constructor?.name ?? "Error",
771
+ message: maybeCause.message
772
+ };
773
+ }
774
+ return out;
775
+ }
776
+ __name(safeErrSerializer, "safeErrSerializer");
777
+ function initLogger(level = "info", logFile) {
778
+ const options = {
779
+ level,
780
+ base: { pid: process.pid, hostname: void 0 },
781
+ timestamp: pino.stdTimeFunctions.isoTime,
782
+ redact: {
783
+ paths: REDACT_PATHS,
784
+ censor: "[Redacted]",
785
+ remove: false
786
+ },
787
+ serializers: {
788
+ err: safeErrSerializer,
789
+ error: safeErrSerializer
790
+ }
791
+ };
792
+ if (logFile) {
793
+ mkdirSync2(dirname3(logFile), { recursive: true });
794
+ rootLogger = pino(options, pino.destination({ dest: logFile, sync: false, mkdir: true }));
795
+ } else {
796
+ rootLogger = pino({
797
+ ...options,
798
+ transport: {
799
+ target: "pino-pretty",
800
+ options: { colorize: true, translateTime: "HH:MM:ss.l", ignore: "pid,hostname" }
801
+ }
802
+ });
803
+ }
804
+ return rootLogger;
805
+ }
806
+ __name(initLogger, "initLogger");
807
+ var defaultContext = {};
808
+ function getLogger(module, extra) {
809
+ if (!rootLogger) {
810
+ rootLogger = initLogger("info");
811
+ }
812
+ const ctx = { ...defaultContext, ...extra, ...module ? { module } : {} };
813
+ return Object.keys(ctx).length > 0 ? rootLogger.child(ctx) : rootLogger;
814
+ }
815
+ __name(getLogger, "getLogger");
816
+
817
+ // src/utils/version.ts
818
+ import { createRequire } from "module";
819
+ var require2 = createRequire(import.meta.url);
820
+ var pkg = require2("../../package.json");
821
+ var VERSION = pkg.version;
822
+
823
+ // src/ingestion/execution-mode.ts
824
+ import { spawnSync } from "child_process";
825
+ import { platform } from "os";
826
+ var ExecutionModeError = class extends Error {
827
+ static {
828
+ __name(this, "ExecutionModeError");
829
+ }
830
+ code;
831
+ constructor(message, code) {
832
+ super(message);
833
+ this.name = "ExecutionModeError";
834
+ this.code = code;
835
+ }
836
+ };
837
+ function findOnPath(binary) {
838
+ const prog = platform() === "win32" ? "where" : "which";
839
+ try {
840
+ const result = spawnSync(prog, [binary], { encoding: "utf8" });
841
+ if (result.status !== 0) return null;
842
+ const stdout = result.stdout.trim();
843
+ if (!stdout) return null;
844
+ const first = stdout.split(/\r?\n/)[0]?.trim();
845
+ return first && first.length > 0 ? first : null;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+ __name(findOnPath, "findOnPath");
851
+ function findApiKey(envVarName) {
852
+ const value = process.env[envVarName];
853
+ if (value && value.trim().length > 0) return envVarName;
854
+ return null;
855
+ }
856
+ __name(findApiKey, "findApiKey");
857
+ function resolveExecutionMode(config) {
858
+ const { mode, cli_path: cliPath, cli_model: cliModel, api_key_env: apiKeyEnv } = config.execution;
859
+ const detectCli = /* @__PURE__ */ __name(() => findOnPath(cliPath), "detectCli");
860
+ const detectKey = /* @__PURE__ */ __name(() => findApiKey(apiKeyEnv), "detectKey");
861
+ if (mode === "cli") {
862
+ const path = detectCli();
863
+ if (!path) {
864
+ throw new ExecutionModeError(
865
+ `execution.mode is 'cli' but the '${cliPath}' binary was not found on PATH. Install Claude Code CLI (https://docs.claude.com/claude-code) or set execution.mode to 'api'.`,
866
+ "CLI_BINARY_NOT_FOUND"
867
+ );
868
+ }
869
+ return {
870
+ mode: "cli",
871
+ configuredMode: "cli",
872
+ cliPath: path,
873
+ apiKeyEnv: null,
874
+ effectiveModelHint: cliModel,
875
+ description: `CLI mode: using claude binary at ${path}, model ${cliModel}, zero marginal cost (subscription-covered)`
876
+ };
877
+ }
878
+ if (mode === "api") {
879
+ const keyEnv2 = detectKey();
880
+ if (!keyEnv2) {
881
+ throw new ExecutionModeError(
882
+ `execution.mode is 'api' but ${apiKeyEnv} is not set. Set the env var or change execution.mode to 'cli'/'auto'.`,
883
+ "API_KEY_NOT_SET"
884
+ );
885
+ }
886
+ return {
887
+ mode: "api",
888
+ configuredMode: "api",
889
+ cliPath: null,
890
+ apiKeyEnv: keyEnv2,
891
+ effectiveModelHint: `model-router (ingest=${config.models.ingest}, query=${config.models.query})`,
892
+ description: `API mode: using Agent SDK with ${keyEnv2}, model routing enabled (per-token billing)`
893
+ };
894
+ }
895
+ if (config.llm.provider !== "anthropic") {
896
+ const keyEnv2 = detectKey();
897
+ if (!keyEnv2 && config.llm.provider !== "ollama") {
898
+ throw new ExecutionModeError(
899
+ `auto-detect: llm.provider='${config.llm.provider}' but ${apiKeyEnv} is not set. Refusing to fall back to CLI mode for non-anthropic provider.`,
900
+ "API_KEY_NOT_SET"
901
+ );
902
+ }
903
+ return {
904
+ mode: "api",
905
+ configuredMode: "auto",
906
+ cliPath: null,
907
+ apiKeyEnv: keyEnv2,
908
+ effectiveModelHint: `model-router (ingest=${config.models.ingest}, query=${config.models.query})`,
909
+ description: `API mode (auto-detected, provider=${config.llm.provider}): using ${keyEnv2 ?? "no-key"}, model routing enabled`
910
+ };
911
+ }
912
+ const cli = detectCli();
913
+ if (cli) {
914
+ return {
915
+ mode: "cli",
916
+ configuredMode: "auto",
917
+ cliPath: cli,
918
+ apiKeyEnv: null,
919
+ effectiveModelHint: cliModel,
920
+ description: `CLI mode (auto-detected): using claude binary at ${cli}, model ${cliModel}, zero marginal cost (subscription-covered)`
921
+ };
922
+ }
923
+ const keyEnv = detectKey();
924
+ if (keyEnv) {
925
+ return {
926
+ mode: "api",
927
+ configuredMode: "auto",
928
+ cliPath: null,
929
+ apiKeyEnv: keyEnv,
930
+ effectiveModelHint: `model-router (ingest=${config.models.ingest}, query=${config.models.query})`,
931
+ description: `API mode (auto-detected): using Agent SDK with ${keyEnv}, model routing enabled (per-token billing)`
932
+ };
933
+ }
934
+ throw new ExecutionModeError(
935
+ `No '${cliPath}' binary on PATH and no ${apiKeyEnv} env var set. Install Claude Code CLI (https://docs.claude.com/claude-code) or set an API key to run watcher-on-the-wall.`,
936
+ "NO_RUNTIME_AVAILABLE"
937
+ );
938
+ }
939
+ __name(resolveExecutionMode, "resolveExecutionMode");
940
+
941
+ // src/daemon/index.ts
942
+ function ensureDirSyncOrActionable(path) {
943
+ try {
944
+ ensureDirSync(path);
945
+ } catch (err) {
946
+ if (looksLikePermissionDenied(err)) {
947
+ throw wikiDirPermissionError(path, err);
948
+ }
949
+ throw err;
950
+ }
951
+ }
952
+ __name(ensureDirSyncOrActionable, "ensureDirSyncOrActionable");
953
+ var Daemon = class {
954
+ static {
955
+ __name(this, "Daemon");
956
+ }
957
+ subsystems = [];
958
+ shuttingDown = false;
959
+ opts;
960
+ config = null;
961
+ executionMode = null;
962
+ releaseLock = null;
963
+ constructor(opts) {
964
+ this.opts = opts;
965
+ }
966
+ /** Resolve config and return it. */
967
+ async init() {
968
+ const loaded = await loadConfig(this.opts.workingDir);
969
+ this.config = resolveConfigPaths(loaded.config, this.opts.workingDir);
970
+ const logToStdout = process.env.WOTW_LOG_STDOUT === "1" || process.env.WOTW_LOG_STDOUT === "true";
971
+ initLogger(this.config.daemon.log_level, logToStdout ? void 0 : this.config.daemon.log_file);
972
+ const log = getLogger("daemon");
973
+ log.info(
974
+ {
975
+ pid: process.pid,
976
+ cwd: this.opts.workingDir,
977
+ configPath: loaded.path
978
+ },
979
+ "daemon initializing"
980
+ );
981
+ ensureDirSyncOrActionable(this.config.wiki_root);
982
+ ensureDirSyncOrActionable(this.config.raw_path);
983
+ try {
984
+ this.executionMode = resolveExecutionMode(this.config);
985
+ } catch (err) {
986
+ if (err instanceof ExecutionModeError) {
987
+ log.fatal({ code: err.code }, err.message);
988
+ } else {
989
+ log.fatal({ err }, "failed to resolve execution mode");
990
+ }
991
+ throw err;
992
+ }
993
+ log.info(
994
+ {
995
+ mode: this.executionMode.mode,
996
+ configured: this.executionMode.configuredMode,
997
+ cliPath: this.executionMode.cliPath,
998
+ apiKeyEnv: this.executionMode.apiKeyEnv,
999
+ model: this.executionMode.effectiveModelHint
1000
+ },
1001
+ this.executionMode.description
1002
+ );
1003
+ return this.config;
1004
+ }
1005
+ /** Return the resolved execution mode, or null if init() hasn't run yet. */
1006
+ getExecutionMode() {
1007
+ return this.executionMode;
1008
+ }
1009
+ /** Attach a subsystem for start/stop management. */
1010
+ attachSubsystem(sub) {
1011
+ this.subsystems.push(sub);
1012
+ }
1013
+ /**
1014
+ * Main run loop. Acquires the start lock, writes the PID file, starts all
1015
+ * subsystems, installs signal handlers, and blocks until shutdown.
1016
+ */
1017
+ async run() {
1018
+ if (!this.config) throw new Error("Daemon.init() must be called before run()");
1019
+ const log = getLogger("daemon");
1020
+ const alive = checkDaemonAlive(this.config.daemon.pid_file);
1021
+ if (alive.alive) {
1022
+ log.error({ pid: alive.pid }, "another daemon instance is already running");
1023
+ throw daemonAlreadyRunningError(this.config.daemon.pid_file);
1024
+ }
1025
+ try {
1026
+ this.releaseLock = await acquireStartLock(this.config.daemon.lock_file);
1027
+ } catch (err) {
1028
+ log.error({ err }, "failed to acquire start lock");
1029
+ throw daemonAlreadyRunningError(this.config.daemon.lock_file, err);
1030
+ }
1031
+ writePidFile(this.config.daemon.pid_file, {
1032
+ pid: process.pid,
1033
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
1034
+ version: VERSION
1035
+ });
1036
+ log.info({ pidFile: this.config.daemon.pid_file }, "PID file written");
1037
+ this.installSignalHandlers();
1038
+ for (const sub of this.subsystems) {
1039
+ try {
1040
+ log.info({ subsystem: sub.name }, "starting subsystem");
1041
+ await sub.start();
1042
+ } catch (err) {
1043
+ log.error({ err, subsystem: sub.name }, "subsystem failed to start");
1044
+ await this.shutdown(1);
1045
+ return;
1046
+ }
1047
+ }
1048
+ log.info({ subsystems: this.subsystems.map((s) => s.name) }, "daemon running");
1049
+ await new Promise((resolve2) => {
1050
+ const check = setInterval(() => {
1051
+ if (this.shuttingDown) {
1052
+ clearInterval(check);
1053
+ resolve2();
1054
+ }
1055
+ }, 250);
1056
+ });
1057
+ }
1058
+ /** Install SIGTERM / SIGINT handlers for graceful shutdown. */
1059
+ installSignalHandlers() {
1060
+ const handle = /* @__PURE__ */ __name((signal) => {
1061
+ const log = getLogger("daemon");
1062
+ log.info({ signal }, "received shutdown signal");
1063
+ void this.shutdown(0);
1064
+ }, "handle");
1065
+ process.on("SIGTERM", handle);
1066
+ process.on("SIGINT", handle);
1067
+ process.on("uncaughtException", (err) => {
1068
+ const log = getLogger("daemon");
1069
+ log.fatal({ err }, "uncaught exception");
1070
+ void this.shutdown(1);
1071
+ });
1072
+ process.on("unhandledRejection", (reason) => {
1073
+ const log = getLogger("daemon");
1074
+ log.fatal(
1075
+ { reason: reason instanceof Error ? reason.message : String(reason) },
1076
+ "unhandled rejection \u2014 shutting down"
1077
+ );
1078
+ void this.shutdown(1);
1079
+ });
1080
+ }
1081
+ /** Stop all subsystems, release the lock, remove the PID file, and exit. */
1082
+ async shutdown(exitCode) {
1083
+ if (this.shuttingDown) return;
1084
+ this.shuttingDown = true;
1085
+ const log = getLogger("daemon");
1086
+ log.info("daemon shutting down");
1087
+ for (const sub of [...this.subsystems].reverse()) {
1088
+ try {
1089
+ await sub.stop();
1090
+ log.info({ subsystem: sub.name }, "subsystem stopped");
1091
+ } catch (err) {
1092
+ log.error({ err, subsystem: sub.name }, "subsystem stop failed");
1093
+ }
1094
+ }
1095
+ if (this.config) {
1096
+ removePidFile(this.config.daemon.pid_file);
1097
+ }
1098
+ if (this.releaseLock) {
1099
+ try {
1100
+ await this.releaseLock();
1101
+ } catch {
1102
+ }
1103
+ }
1104
+ log.info("daemon shutdown complete");
1105
+ await new Promise((r) => setTimeout(r, 50));
1106
+ process.exit(exitCode);
1107
+ }
1108
+ };
1109
+
1110
+ // src/provenance/hash.ts
1111
+ import { createHash } from "crypto";
1112
+ import { readFileSync as readFileSync3 } from "fs";
1113
+ import { readFile as readFile2 } from "fs/promises";
1114
+ var GENESIS_HASH = "0".repeat(64);
1115
+ function canonicalJson(value) {
1116
+ const normalize = /* @__PURE__ */ __name((v) => {
1117
+ if (v === null || typeof v !== "object") return v;
1118
+ if (Array.isArray(v)) return v.map(normalize);
1119
+ const sorted = {};
1120
+ for (const key of Object.keys(v).sort()) {
1121
+ sorted[key] = normalize(v[key]);
1122
+ }
1123
+ return sorted;
1124
+ }, "normalize");
1125
+ return JSON.stringify(normalize(value));
1126
+ }
1127
+ __name(canonicalJson, "canonicalJson");
1128
+ function sha256Hex(input) {
1129
+ return createHash("sha256").update(input).digest("hex");
1130
+ }
1131
+ __name(sha256Hex, "sha256Hex");
1132
+ var sha256 = sha256Hex;
1133
+ function sha256Canonical(value) {
1134
+ return sha256Hex(canonicalJson(value));
1135
+ }
1136
+ __name(sha256Canonical, "sha256Canonical");
1137
+ var sha256Json = sha256Canonical;
1138
+ var stableStringify = canonicalJson;
1139
+ function sha256FileSync(filePath) {
1140
+ return sha256Hex(readFileSync3(filePath));
1141
+ }
1142
+ __name(sha256FileSync, "sha256FileSync");
1143
+ async function sha256File(path) {
1144
+ try {
1145
+ const buf = await readFile2(path);
1146
+ return sha256Hex(buf);
1147
+ } catch (err) {
1148
+ if (err.code === "ENOENT") return null;
1149
+ throw err;
1150
+ }
1151
+ }
1152
+ __name(sha256File, "sha256File");
1153
+ async function sha256Files(paths) {
1154
+ const out = {};
1155
+ await Promise.all(
1156
+ paths.map(async (p) => {
1157
+ const h = await sha256File(p);
1158
+ if (h !== null) out[p] = h;
1159
+ })
1160
+ );
1161
+ return out;
1162
+ }
1163
+ __name(sha256Files, "sha256Files");
1164
+
1165
+ // src/utils/sanitize.ts
1166
+ var DEFAULT_REDACTIONS = [
1167
+ {
1168
+ name: "aws-access-key",
1169
+ pattern: /\bAKIA[0-9A-Z]{16}\b/g,
1170
+ replacement: "[REDACTED:AWS_ACCESS_KEY]"
1171
+ },
1172
+ {
1173
+ name: "aws-secret-key",
1174
+ pattern: /\b[A-Za-z0-9/+=]{40}\b(?=.*(?:secret|aws))/gi,
1175
+ replacement: "[REDACTED:AWS_SECRET_KEY]"
1176
+ },
1177
+ {
1178
+ name: "github-token",
1179
+ // Review item 2: also catch GitHub fine-grained personal access
1180
+ // tokens (`github_pat_*`, 82+ chars per docs).
1181
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/g,
1182
+ replacement: "[REDACTED:GITHUB_TOKEN]"
1183
+ },
1184
+ {
1185
+ name: "anthropic-api-key",
1186
+ // Anthropic keys span ~95-115 chars after `sk-ant-`. The 80,120
1187
+ // window stays generous enough to catch both legacy and current
1188
+ // formats including api03- prefix.
1189
+ pattern: /\bsk-ant-[A-Za-z0-9-_]{80,120}\b/g,
1190
+ replacement: "[REDACTED:ANTHROPIC_API_KEY]"
1191
+ },
1192
+ {
1193
+ name: "openai-api-key",
1194
+ // Review item 2: original `\bsk-[A-Za-z0-9]{20,}\b` missed modern
1195
+ // formats with `-` and `_` in the body — `sk-proj-*`,
1196
+ // `sk-svcacct-*`, `sk-admin-*` all use `-` separators after the
1197
+ // prefix and longer character set. Updated character class.
1198
+ pattern: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,200}\b|\bsk-[A-Za-z0-9]{20,200}\b/g,
1199
+ replacement: "[REDACTED:OPENAI_API_KEY]"
1200
+ },
1201
+ {
1202
+ name: "gemini-api-key",
1203
+ // Review item 2: Google AI Studio API keys are `AIza` + 35 chars.
1204
+ // No rule existed pre-fix.
1205
+ pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
1206
+ replacement: "[REDACTED:GEMINI_API_KEY]"
1207
+ },
1208
+ {
1209
+ name: "wotw-daemon-token",
1210
+ // Review item 2: daemon tokens emitted by `wotw user add` are
1211
+ // `wotw_` + base64url chars. Pre-fix these went unredacted.
1212
+ pattern: /\bwotw_[A-Za-z0-9_-]{30,200}\b/g,
1213
+ replacement: "[REDACTED:WOTW_TOKEN]"
1214
+ },
1215
+ {
1216
+ name: "private-key-block",
1217
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
1218
+ replacement: "[REDACTED:PRIVATE_KEY_BLOCK]"
1219
+ },
1220
+ {
1221
+ name: "jwt",
1222
+ pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
1223
+ replacement: "[REDACTED:JWT]"
1224
+ },
1225
+ {
1226
+ // L-SEC-3: This pattern is deliberately scoped to full `scheme://`
1227
+ // URIs with a `user:password@` userinfo component. The `\w+:\/\/`
1228
+ // prefix is load-bearing — without it the pattern would also match
1229
+ // bare `user@host` email addresses and `mailto:user@host` URIs,
1230
+ // both of which carry no password and must not be over-redacted.
1231
+ // Verified by unit tests in test/unit/sanitize.test.ts.
1232
+ name: "password-in-url",
1233
+ pattern: /(\w+:\/\/[^:/\s]+:)[^@\s]+(@)/g,
1234
+ replacement: "$1[REDACTED]$2"
1235
+ },
1236
+ {
1237
+ name: "credit-card",
1238
+ pattern: /\b(?:\d[ -]*?){13,16}\b/g,
1239
+ replacement: "[REDACTED:PAN]"
1240
+ },
1241
+ {
1242
+ name: "us-ssn",
1243
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
1244
+ replacement: "[REDACTED:SSN]"
1245
+ }
1246
+ ];
1247
+ function sanitize(input, rules = DEFAULT_REDACTIONS) {
1248
+ let out = input;
1249
+ for (const rule of rules) {
1250
+ out = out.replace(rule.pattern, rule.replacement);
1251
+ }
1252
+ return out;
1253
+ }
1254
+ __name(sanitize, "sanitize");
1255
+ function sanitizeWithReport(input, rules = DEFAULT_REDACTIONS) {
1256
+ const triggered = [];
1257
+ let out = input;
1258
+ for (const rule of rules) {
1259
+ if (rule.pattern.test(out)) {
1260
+ triggered.push(rule.name);
1261
+ rule.pattern.lastIndex = 0;
1262
+ out = out.replace(rule.pattern, rule.replacement);
1263
+ }
1264
+ }
1265
+ return { output: out, triggered };
1266
+ }
1267
+ __name(sanitizeWithReport, "sanitizeWithReport");
1268
+ export {
1269
+ DEFAULT_REDACTIONS,
1270
+ Daemon,
1271
+ GENESIS_HASH,
1272
+ canonicalJson,
1273
+ defaultConfig,
1274
+ getLogger,
1275
+ initLogger,
1276
+ loadConfig,
1277
+ mergeConfig,
1278
+ resolveConfigPaths,
1279
+ sanitize,
1280
+ sanitizeWithReport,
1281
+ sha256,
1282
+ sha256Canonical,
1283
+ sha256File,
1284
+ sha256FileSync,
1285
+ sha256Files,
1286
+ sha256Hex,
1287
+ sha256Json,
1288
+ stableStringify
1289
+ };
1290
+ //# sourceMappingURL=index.js.map