@tekmidian/pai 0.49.0 → 0.51.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 (109) hide show
  1. package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
  2. package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
  3. package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
  4. package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
  5. package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
  6. package/dist/cli/index.mjs +14 -14
  7. package/dist/cli/program.mjs +14 -14
  8. package/dist/config-Bn9jpblO.mjs +653 -0
  9. package/dist/config-Bn9jpblO.mjs.map +1 -0
  10. package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
  11. package/dist/config-DYXARgk8.mjs.map +1 -0
  12. package/dist/config-RjeVGJ7K.mjs +3 -0
  13. package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
  14. package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
  15. package/dist/daemon/index.mjs +13 -12
  16. package/dist/daemon/index.mjs.map +1 -1
  17. package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
  18. package/dist/daemon-CBTQYtHF.mjs.map +1 -0
  19. package/dist/daemon-Vf7pHnjR.mjs +25 -0
  20. package/dist/daemon-mcp/index.mjs +186 -23
  21. package/dist/daemon-mcp/index.mjs.map +1 -1
  22. package/dist/factory-Ba8j9K7q.mjs +3 -0
  23. package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
  24. package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
  25. package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
  26. package/dist/fallback-CIGspCSi.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +80 -8
  28. package/dist/hooks/capture-all-events.mjs.map +4 -4
  29. package/dist/hooks/context-compression-hook.mjs +54 -39
  30. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  31. package/dist/hooks/post-compact-inject.mjs +54 -39
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +204 -109
  34. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  35. package/dist/hooks/whisper-rules.mjs +7613 -9
  36. package/dist/hooks/whisper-rules.mjs.map +4 -4
  37. package/dist/hooks/worker-proxy.mjs +204 -109
  38. package/dist/hooks/worker-proxy.mjs.map +4 -4
  39. package/dist/hooks/worker-status-line.mjs +211 -116
  40. package/dist/hooks/worker-status-line.mjs.map +4 -4
  41. package/dist/hooks/worker-supervision.mjs +208 -113
  42. package/dist/hooks/worker-supervision.mjs.map +4 -4
  43. package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
  44. package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
  45. package/dist/main-resolver-BTastpp4.mjs +7 -0
  46. package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
  47. package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
  48. package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
  49. package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
  50. package/dist/{program-D9-wfmkZ.mjs → program-srUeFUfW.mjs} +5806 -5615
  51. package/dist/program-srUeFUfW.mjs.map +1 -0
  52. package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
  53. package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
  54. package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
  55. package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
  56. package/dist/run-TChOnMO_.mjs.map +1 -0
  57. package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
  58. package/dist/server-R9UkCn3v.mjs.map +1 -0
  59. package/dist/session-keepalive-BuG3wvik.mjs +579 -0
  60. package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
  61. package/dist/skills/VaultContext/SKILL.md +1 -1
  62. package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
  63. package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
  64. package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
  65. package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
  66. package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
  67. package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
  68. package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
  69. package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
  70. package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
  71. package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
  72. package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
  73. package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
  74. package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
  75. package/docs/cache-keepalive.md +138 -0
  76. package/docs/commands/README.md +7 -0
  77. package/docs/commands/audit.md +1 -1
  78. package/docs/commands/config.md +84 -0
  79. package/docs/commands/daemon.md +6 -0
  80. package/docs/commands/worker.md +37 -2
  81. package/docs/config.md +171 -0
  82. package/docs/model-catalogue.md +502 -0
  83. package/docs/provider-independence-details.md +1 -1
  84. package/docs/task-bus.md +1 -1
  85. package/docs/worker.md +33 -5
  86. package/docs/workers-config.md +105 -12
  87. package/package.json +1 -1
  88. package/src/hooks/ts/capture-all-events.test.ts +71 -0
  89. package/src/hooks/ts/capture-all-events.ts +10 -3
  90. package/src/hooks/ts/lib/context-fill.test.ts +124 -46
  91. package/src/hooks/ts/lib/context-fill.ts +57 -14
  92. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
  93. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  94. package/statusline-command.sh +103 -16
  95. package/dist/config-DCu8THKi.mjs +0 -228
  96. package/dist/config-DCu8THKi.mjs.map +0 -1
  97. package/dist/config-DeqFqa1N.mjs +0 -3
  98. package/dist/config-DxMZjpvY.mjs.map +0 -1
  99. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  100. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  101. package/dist/factory-47Qf_df7.mjs +0 -3
  102. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  103. package/dist/indexer-C5Le7XE7.mjs +0 -1
  104. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  105. package/dist/program-D9-wfmkZ.mjs.map +0 -1
  106. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  107. package/dist/run-Cls0YGgV.mjs.map +0 -1
  108. package/dist/server-Ch99xt5K.mjs.map +0 -1
  109. package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
@@ -0,0 +1,653 @@
1
+ import { a as paiHomePath, o as resolvePaiFile, r as migratePaiFile } from "./pai-home-ChAOupZc.mjs";
2
+ import { i as paiSocketPath } from "./runtime-paths-B0P1TvUr.mjs";
3
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { Document, YAMLMap, parseDocument } from "yaml";
7
+
8
+ //#region src/notifications/types.ts
9
+ const DEFAULT_ROUTING = {
10
+ error: [
11
+ "whatsapp",
12
+ "macos",
13
+ "ntfy",
14
+ "cli"
15
+ ],
16
+ completion: [
17
+ "whatsapp",
18
+ "macos",
19
+ "ntfy",
20
+ "cli"
21
+ ],
22
+ info: ["cli"],
23
+ progress: ["cli"],
24
+ debug: []
25
+ };
26
+ const DEFAULT_CHANNELS = {
27
+ ntfy: {
28
+ enabled: false,
29
+ url: void 0,
30
+ priority: "default"
31
+ },
32
+ whatsapp: {
33
+ enabled: true,
34
+ recipient: void 0
35
+ },
36
+ macos: { enabled: true },
37
+ voice: {
38
+ enabled: false,
39
+ voiceName: "bm_george"
40
+ },
41
+ cli: { enabled: true }
42
+ };
43
+ const DEFAULT_NOTIFICATION_CONFIG = {
44
+ mode: "auto",
45
+ channels: DEFAULT_CHANNELS,
46
+ routing: DEFAULT_ROUTING
47
+ };
48
+
49
+ //#endregion
50
+ //#region src/tasks/types.ts
51
+ /**
52
+ * Prefix marking a tracker label as a PAI ownership assertion.
53
+ * A task labelled `pai:acme-api` is owned by the `acme-api` project.
54
+ */
55
+ const OWNER_LABEL_PREFIX = "pai:";
56
+ /** An unresolved owner. UNROUTED is a normal state, not an error. */
57
+ const UNROUTED = {
58
+ project: null,
59
+ rootPath: null,
60
+ source: "none"
61
+ };
62
+ const DEFAULT_TASK_CONFIG = {
63
+ enabled: false,
64
+ providers: { todoist: { enabled: false } },
65
+ autoDispatch: false
66
+ };
67
+
68
+ //#endregion
69
+ //#region src/config/json-store.ts
70
+ /**
71
+ * json-store.ts — read/write JSON config files without destroying them
72
+ *
73
+ * The failure this exists to prevent:
74
+ *
75
+ * try { return JSON.parse(read(path)); } catch { return {}; }
76
+ * ... later ...
77
+ * write(path, JSON.stringify(ourData));
78
+ *
79
+ * An unreadable file becomes an empty object, and the next write makes that
80
+ * permanent. Silently, exit code 0. This shape appeared three times in this
81
+ * repo against three different files, and twice in AIBroker.
82
+ *
83
+ * The distinction that matters is between *missing* and *unreadable*:
84
+ *
85
+ * missing — legitimate first run. Start fresh; writing is safe.
86
+ * unreadable — the file exists and we could not parse it. Those bytes are
87
+ * the only copy of something. Never overwrite them.
88
+ *
89
+ * Collapsing the second into the first is the bug.
90
+ *
91
+ * NOT everything deserves this guard. For a transient buffer — an undelivered
92
+ * message queue, a cache — starting fresh IS the correct recovery, and
93
+ * refusing to write would disable the feature permanently. Use `writeJsonAtomic`
94
+ * alone there: it still prevents a crash from truncating a good file, without
95
+ * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
96
+ */
97
+ /**
98
+ * Read a JSON file, distinguishing "absent" from "damaged".
99
+ *
100
+ * @param path file to read
101
+ * @param label how to name it to the user, e.g. "~/.claude.json"
102
+ * @throws if the file exists but cannot be read or parsed
103
+ */
104
+ function readJsonStrict(path, label = path) {
105
+ if (!existsSync(path)) return {};
106
+ let raw;
107
+ try {
108
+ raw = readFileSync(path, "utf8");
109
+ } catch (e) {
110
+ throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
111
+ }
112
+ try {
113
+ return JSON.parse(raw);
114
+ } catch (e) {
115
+ throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
116
+ }
117
+ }
118
+ /**
119
+ * Leaf values that mean "type word", not data.
120
+ *
121
+ * A config whose top-level keys map to bare type words is a serialized schema,
122
+ * not a configuration — the exact shape found over the live config on
123
+ * 2026-09-18 (399 bytes: `socketPath: string`, `indexIntervalSecs: int`, …).
124
+ * No real config carries three of these at its top level; a dump carries
125
+ * nothing else.
126
+ */
127
+ const SCHEMA_TYPE_WORDS = new Set([
128
+ "string",
129
+ "int",
130
+ "integer",
131
+ "number",
132
+ "bool",
133
+ "boolean",
134
+ "float",
135
+ "double",
136
+ "object",
137
+ "array",
138
+ "any",
139
+ "unknown"
140
+ ]);
141
+ function schemaDumpHits(data) {
142
+ let hits = 0;
143
+ for (const v of Object.values(data)) if (typeof v === "string" && SCHEMA_TYPE_WORDS.has(v.trim())) hits += 1;
144
+ return hits;
145
+ }
146
+ /**
147
+ * Write JSON without risking the existing file.
148
+ *
149
+ * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
150
+ * and renames. Rename is atomic within a filesystem, so a crash mid-write
151
+ * leaves the original intact rather than truncated — which is how these files
152
+ * become corrupt in the first place.
153
+ */
154
+ function writeJsonAtomic(path, data, opts = {}) {
155
+ const { backup = true, label = path } = opts;
156
+ if (schemaDumpHits(data) >= 3) throw new Error(`${label}: refusing to write — the object looks like a type schema, not values (several top-level keys map to bare type words). That is the corruption signature of 2026-09-18: the caller serialized the schema instead of the config. Nothing was written.`);
157
+ const serialized = JSON.stringify(data, null, 2) + "\n";
158
+ const dir = dirname(path);
159
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
160
+ if (backup && existsSync(path)) try {
161
+ copyFileSync(path, `${path}.bak-pai`);
162
+ } catch (e) {
163
+ throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
164
+ }
165
+ const tmp = `${path}.tmp-pai-${process.pid}`;
166
+ try {
167
+ writeFileSync(tmp, serialized, "utf8");
168
+ renameSync(tmp, path);
169
+ } catch (e) {
170
+ try {
171
+ if (existsSync(tmp)) unlinkSync(tmp);
172
+ } catch {}
173
+ throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
174
+ }
175
+ }
176
+
177
+ //#endregion
178
+ //#region src/config/yaml-store.ts
179
+ /**
180
+ * yaml-store.ts — generic, comment-preserving YAML file helpers shared by
181
+ * every PAI config file that has a YAML form (config.yaml, voices.yaml;
182
+ * workers.yaml keeps its own writer in workers-config.ts because it needs
183
+ * provider-specific key-quoting, but its atomic-write tail is this module's
184
+ * writeYamlFileAtomic).
185
+ */
186
+ var YamlStoreError = class extends Error {};
187
+ /**
188
+ * Validate `text` parses as YAML, back up any existing file to `.bak-pai`,
189
+ * then write via temp file + rename (atomic within a filesystem) at mode
190
+ * 0600 — every PAI-owned YAML file can carry secrets, key or no key.
191
+ */
192
+ function writeYamlFileAtomic(path, text, opts = {}) {
193
+ const label = opts.label ?? path;
194
+ const parsed = parseDocument(text);
195
+ if (parsed.errors.length) throw new YamlStoreError(`refusing to write ${label}: ${parsed.errors[0].message} (previous file left unchanged)`);
196
+ const dir = dirname(path);
197
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
198
+ if (existsSync(path)) try {
199
+ copyFileSync(path, `${path}.bak-pai`);
200
+ } catch (e) {
201
+ throw new YamlStoreError(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
202
+ }
203
+ const tmp = `${path}.tmp-pai-${process.pid}`;
204
+ try {
205
+ writeFileSync(tmp, text, {
206
+ encoding: "utf8",
207
+ mode: 384
208
+ });
209
+ renameSync(tmp, path);
210
+ chmodSync(path, 384);
211
+ } catch (e) {
212
+ try {
213
+ if (existsSync(tmp)) unlinkSync(tmp);
214
+ } catch {}
215
+ throw new YamlStoreError(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}`);
216
+ }
217
+ }
218
+ /** Deep-equal for plain JSON-shaped values (objects/arrays/scalars). */
219
+ function deepEqualJson(a, b) {
220
+ return JSON.stringify(a) === JSON.stringify(b);
221
+ }
222
+ function isPlainObject(v) {
223
+ return typeof v === "object" && v !== null && !Array.isArray(v);
224
+ }
225
+ /**
226
+ * Apply the diff between `before` and `after` (arbitrary nested plain JSON
227
+ * objects) onto a YAML Document, touching only the leaves/keys that actually
228
+ * changed — this is what lets hand-written comments elsewhere in the file
229
+ * survive a `set`/`unset`. Arrays and non-object values are replaced
230
+ * wholesale when they differ; only plain objects are diffed key by key.
231
+ */
232
+ function syncPlainObjectIntoYamlDoc(doc, before, after, path = []) {
233
+ if (isPlainObject(after) && isPlainObject(before)) {
234
+ for (const key of Object.keys(after)) syncPlainObjectIntoYamlDoc(doc, before[key], after[key], [...path, key]);
235
+ for (const key of Object.keys(before)) if (!(key in after)) doc.deleteIn([...path, key]);
236
+ return;
237
+ }
238
+ if (after === void 0) {
239
+ if (before !== void 0) doc.deleteIn(path);
240
+ return;
241
+ }
242
+ if (!deepEqualJson(before, after)) doc.setIn(path, after);
243
+ }
244
+ /**
245
+ * Attach a `# text` comment directly above the key at `path` inside a YAML
246
+ * Document — used for the annotated section headers `pai config yaml`
247
+ * writes. No-op if the path does not resolve to a mapping entry (e.g. the
248
+ * key was omitted from the document).
249
+ */
250
+ function setYamlKeyComment(doc, path, text) {
251
+ const parentPath = path.slice(0, -1);
252
+ const lastKey = path[path.length - 1];
253
+ const parent = parentPath.length ? doc.getIn(parentPath, true) : doc.contents;
254
+ if (!(parent instanceof YAMLMap)) return;
255
+ const pair = parent.items.find((p) => p.key?.value === lastKey);
256
+ if (pair) pair.key.commentBefore = ` ${text}`;
257
+ }
258
+
259
+ //#endregion
260
+ //#region src/config/main-config.ts
261
+ /**
262
+ * main-config.ts — the dual-format (config.yaml / config.json) engine behind
263
+ * the main PAI config file. Deliberately has NO dependency on
264
+ * src/daemon/config.ts (which owns path resolution, PaiDaemonConfig and
265
+ * DEFAULTS) so that module can import this one for loadConfig() without a
266
+ * cycle; every path this module touches is passed in explicitly.
267
+ *
268
+ * Precedence, same shape as workers.yaml/workers/config.ts: config.yaml wins
269
+ * when it exists, config.json otherwise. `pai config yaml` (see
270
+ * src/cli/commands/config.ts) performs the one-time JSON→YAML conversion;
271
+ * every writer that goes through readMainConfigRaw/writeMainConfigRaw (see
272
+ * src/daemon/config.ts) keeps working unmodified either way.
273
+ */
274
+ var MainConfigError = class extends Error {};
275
+ /** The config.yaml sibling of a given config.json path (same directory). */
276
+ function yamlSiblingPath(jsonPath) {
277
+ return join(dirname(jsonPath), "config.yaml");
278
+ }
279
+ /** The path a user should be pointed at: config.yaml when it exists (same
280
+ * precedence as readDualFormatConfigRaw), else config.json. */
281
+ function resolvedMainConfigPath(jsonPath, yamlPath = yamlSiblingPath(jsonPath)) {
282
+ return existsSync(yamlPath) ? yamlPath : jsonPath;
283
+ }
284
+ /**
285
+ * Read the raw main config: config.yaml if it exists, else config.json
286
+ * (readJsonStrict — missing is `{}`, damaged throws), matching
287
+ * readWorkersSection's precedence for workers.yaml/JSON.
288
+ */
289
+ function readDualFormatConfigRaw(jsonPath, yamlPath = yamlSiblingPath(jsonPath)) {
290
+ if (existsSync(yamlPath)) {
291
+ let text;
292
+ try {
293
+ text = readFileSync(yamlPath, "utf8");
294
+ } catch (e) {
295
+ throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);
296
+ }
297
+ const doc = parseDocument(text);
298
+ if (doc.errors.length) throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);
299
+ const parsed = doc.toJS();
300
+ if (parsed === null || parsed === void 0) return {};
301
+ if (typeof parsed !== "object" || Array.isArray(parsed)) throw new MainConfigError(`${yamlPath}: top level must be a mapping`);
302
+ return parsed;
303
+ }
304
+ return readJsonStrict(jsonPath, jsonPath);
305
+ }
306
+ /**
307
+ * Write the raw main config back: comment-preserving diff-sync into
308
+ * config.yaml when it exists, else plain writeJsonAtomic to config.json —
309
+ * this is the one seam every writer of the main config (CLI commands,
310
+ * identity, notifications, setup, workers) routes through, so a config.yaml
311
+ * on disk is honored no matter which of them made the change.
312
+ */
313
+ function writeDualFormatConfigRaw(jsonPath, raw, yamlPath = yamlSiblingPath(jsonPath)) {
314
+ if (existsSync(yamlPath)) {
315
+ let text;
316
+ try {
317
+ text = readFileSync(yamlPath, "utf8");
318
+ } catch (e) {
319
+ throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);
320
+ }
321
+ const doc = parseDocument(text);
322
+ if (doc.errors.length) throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);
323
+ syncPlainObjectIntoYamlDoc(doc, doc.toJS() ?? {}, raw);
324
+ try {
325
+ writeYamlFileAtomic(yamlPath, String(doc), { label: yamlPath });
326
+ } catch (e) {
327
+ throw new MainConfigError(e instanceof Error ? e.message : String(e));
328
+ }
329
+ return;
330
+ }
331
+ writeJsonAtomic(jsonPath, raw, { label: jsonPath });
332
+ }
333
+ /**
334
+ * Short annotation for every top-level PaiDaemonConfig field, shown as a `#`
335
+ * comment above the field in the generated config.yaml. Mirrors (and must be
336
+ * kept in sync with) the doc comments on PaiDaemonConfig in
337
+ * src/daemon/config.ts — kept as a separate short-form table rather than
338
+ * parsed from the TS source at runtime.
339
+ */
340
+ const MAIN_CONFIG_SECTION_COMMENTS = {
341
+ socketPath: "Unix Domain Socket path for daemon IPC.",
342
+ indexIntervalSecs: "How often the daemon re-indexes changed files, in seconds.",
343
+ embedIntervalSecs: "How often the daemon runs the embedding pass, in seconds.",
344
+ embedOnStartup: "Run an embed pass 60s after daemon start. Off by default: with a large backlog it makes every restart a CPU storm.",
345
+ maintenanceHour: "Local hour (0-23) to anchor the recurring index/embed cycle to, so maintenance runs in a fixed window.",
346
+ storageBackend: "Storage backend: \"sqlite\" (default) or \"postgres\".",
347
+ postgres: "PostgreSQL connection settings, used when storageBackend is \"postgres\".",
348
+ embeddingModel: "Embedding model name, used for semantic/hybrid search.",
349
+ logLevel: "Daemon log level: debug, info, warn, or error.",
350
+ vaultPath: "Obsidian vault root path for zettelkasten indexing, if any.",
351
+ vaultProjectId: "Registry project_id used for vault chunks in memory_chunks. Default: auto-detected.",
352
+ notifications: "Notification subsystem configuration.",
353
+ search: "Search defaults, applied when an MCP tool or CLI call doesn't specify one.",
354
+ tasks: "Task bus — optional external tracker for cross-session work.",
355
+ identity: "Who \"me\" is — addresses that count as the user's own. Empty by default and never guessed: nothing is self-addressed until this is set.",
356
+ workers: "Non-provider worker settings (pane, routing, tree, cache keepalive, fallback). Providers/classes/mcp_sets live in workers.yaml, not here."
357
+ };
358
+ /** Keys whose leading-underscore string value is a JSON stand-in for a
359
+ * comment (e.g. `"_comment": "..."`, `"_deliverToNote": "..."`). Stripped
360
+ * from the generated YAML and re-attached as a real `#` comment above the
361
+ * nearest remaining sibling key in the same object. */
362
+ function isCommentKey(key) {
363
+ return key.startsWith("_");
364
+ }
365
+ /** Recursively strip `_`-prefixed comment keys out of a plain JSON value,
366
+ * returning the cleaned value plus where each stripped comment belongs. */
367
+ function stripUnderscoreComments(value, path = []) {
368
+ if (Array.isArray(value)) {
369
+ const comments = [];
370
+ return {
371
+ cleaned: value.map((v, i) => {
372
+ const r = stripUnderscoreComments(v, [...path, i]);
373
+ comments.push(...r.comments);
374
+ return r.cleaned;
375
+ }),
376
+ comments
377
+ };
378
+ }
379
+ if (typeof value === "object" && value !== null) {
380
+ const obj = value;
381
+ const cleaned = {};
382
+ const comments = [];
383
+ const localComments = [];
384
+ for (const [k, v] of Object.entries(obj)) {
385
+ if (isCommentKey(k)) {
386
+ if (typeof v === "string" && v.trim()) localComments.push(v.trim());
387
+ continue;
388
+ }
389
+ const r = stripUnderscoreComments(v, [...path, k]);
390
+ cleaned[k] = r.cleaned;
391
+ comments.push(...r.comments);
392
+ }
393
+ const firstKey = Object.keys(cleaned)[0];
394
+ if (localComments.length && firstKey !== void 0) comments.unshift({
395
+ path: [...path, firstKey],
396
+ text: localComments.join(" ")
397
+ });
398
+ return {
399
+ cleaned,
400
+ comments
401
+ };
402
+ }
403
+ return {
404
+ cleaned: value,
405
+ comments: []
406
+ };
407
+ }
408
+ /**
409
+ * `pai config yaml`: convert config.json to config.yaml. Strips `_`-prefixed
410
+ * JSON-comment-workaround keys into real `#` comments, adds a short
411
+ * annotation above every top-level section (MAIN_CONFIG_SECTION_COMMENTS),
412
+ * verifies the generated YAML re-parses to the exact same data before
413
+ * writing anything, then renames the JSON aside to
414
+ * `config.json.migrated-<YYYY-MM-DD>` (never deleted). Refuses when
415
+ * config.yaml already exists unless `force`. `dryRun` computes and returns
416
+ * the would-be YAML text without touching either file.
417
+ */
418
+ function migrateMainConfigToYaml(jsonPath, opts = {}) {
419
+ const yamlPath = opts.yamlPath ?? yamlSiblingPath(jsonPath);
420
+ if (existsSync(yamlPath) && !opts.force) throw new MainConfigError(`${yamlPath} already exists — edit it directly, or pass --force to regenerate it from ${jsonPath}`);
421
+ if (!existsSync(jsonPath)) throw new MainConfigError(`${jsonPath} does not exist — nothing to migrate`);
422
+ const { cleaned, comments } = stripUnderscoreComments(readJsonStrict(jsonPath, jsonPath));
423
+ const cleanedObj = cleaned;
424
+ const doc = new Document(cleanedObj);
425
+ const sectionComments = opts.sectionComments ?? MAIN_CONFIG_SECTION_COMMENTS;
426
+ for (const [key, text] of Object.entries(sectionComments)) if (key in cleanedObj) setYamlKeyComment(doc, [key], text);
427
+ for (const c of comments) setYamlKeyComment(doc, c.path, c.text);
428
+ const yamlText = String(doc);
429
+ if (!deepEqualJson(parseDocument(yamlText).toJS(), cleanedObj)) throw new MainConfigError(`${yamlPath}: generated YAML does not re-parse to the same data as ${jsonPath} — aborting, nothing written`);
430
+ if (opts.dryRun) return {
431
+ yamlPath,
432
+ yamlText,
433
+ backupPath: null,
434
+ dryRun: true
435
+ };
436
+ writeYamlFileAtomic(yamlPath, yamlText, { label: yamlPath });
437
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
438
+ const backupPath = join(dirname(jsonPath), `${basename(jsonPath)}.migrated-${stamp}`);
439
+ renameSync(jsonPath, backupPath);
440
+ return {
441
+ yamlPath,
442
+ yamlText,
443
+ backupPath,
444
+ dryRun: false
445
+ };
446
+ }
447
+
448
+ //#endregion
449
+ //#region src/daemon/config.ts
450
+ /**
451
+ * config.ts — Configuration loader for PAI Daemon
452
+ *
453
+ * Loads config from ~/.claude/pai/config.json (the pre-2026-09-19 location
454
+ * was ~/.config/pai/config.json, briefly ~/.claude/pai.json in between —
455
+ * see paiConfigFilePath/migrateConfigFile).
456
+ * Deep-merges with defaults so partial configs work fine.
457
+ * Expands ~ in path values at runtime.
458
+ */
459
+ /** Derive a per-user Postgres database name: pai_<username> */
460
+ function perUserDbName() {
461
+ return `pai_${userInfo().username.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase()}`;
462
+ }
463
+ /** Derive the per-user connection string */
464
+ function perUserConnectionString() {
465
+ return `postgresql://pai:pai@localhost:5432/${perUserDbName()}`;
466
+ }
467
+ /**
468
+ * idleMinutes=50 sits under the 60-minute ephemeral-1h TTL with margin for
469
+ * scheduler jitter; maxBeats=6 (~5h of coverage at one beat per idle stretch)
470
+ * and activeHours 08:00-22:00 keep an unattended overnight machine from
471
+ * beating all night for a session nobody will return to before the cache
472
+ * would have expired anyway.
473
+ */
474
+ const DEFAULT_SESSIONS_CACHE_KEEPALIVE = {
475
+ enabled: false,
476
+ idleMinutes: 50,
477
+ maxBeats: 6,
478
+ activeHours: "08:00-22:00",
479
+ minContextTokens: 2e4,
480
+ prompt: "keepalive"
481
+ };
482
+ const DEFAULTS = {
483
+ socketPath: paiSocketPath(),
484
+ indexIntervalSecs: 300,
485
+ embedIntervalSecs: 600,
486
+ embedOnStartup: false,
487
+ storageBackend: "sqlite",
488
+ postgres: {
489
+ connectionString: perUserConnectionString(),
490
+ maxConnections: 5,
491
+ connectionTimeoutMs: 5e3
492
+ },
493
+ embeddingModel: "Snowflake/snowflake-arctic-embed-m-v1.5",
494
+ logLevel: "info",
495
+ notifications: DEFAULT_NOTIFICATION_CONFIG,
496
+ tasks: DEFAULT_TASK_CONFIG,
497
+ identity: { selfEmails: [] },
498
+ sessions: { cacheKeepalive: { ...DEFAULT_SESSIONS_CACHE_KEEPALIVE } },
499
+ search: {
500
+ mode: "keyword",
501
+ rerank: true,
502
+ recencyBoostDays: 90,
503
+ defaultLimit: 10,
504
+ snippetLength: 200
505
+ }
506
+ };
507
+ /** Config template — generated at runtime so the DB name is per-user */
508
+ function configTemplate() {
509
+ return `{
510
+ "socketPath": "/tmp/pai.sock",
511
+ "indexIntervalSecs": 300,
512
+ "embedIntervalSecs": 600,
513
+ "storageBackend": "sqlite",
514
+ "postgres": {
515
+ "connectionString": "${perUserConnectionString()}",
516
+ "maxConnections": 5,
517
+ "connectionTimeoutMs": 5000
518
+ },
519
+ "embeddingModel": "Snowflake/snowflake-arctic-embed-m-v1.5",
520
+ "logLevel": "info",
521
+ "vaultPath": "",
522
+ "vaultProjectId": 0,
523
+ "search": {
524
+ "mode": "keyword",
525
+ "rerank": true,
526
+ "recencyBoostDays": 90,
527
+ "defaultLimit": 10,
528
+ "snippetLength": 200
529
+ }
530
+ }
531
+ `;
532
+ }
533
+ /** Expand a leading ~ to the real home directory */
534
+ function expandHome(p) {
535
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(1));
536
+ return p;
537
+ }
538
+ /** Canonical location since 2026-09-19: under the PAI_HOME namespace dir,
539
+ * so nothing PAI writes can collide with a file Claude Code itself owns. */
540
+ const NEW_CONFIG_FILE = paiHomePath("config.json");
541
+ /** Briefly the canonical location between 2026-09-19's two migrations —
542
+ * read during the transition, never written to again. */
543
+ const OLD_CONFIG_FILE = join(homedir(), ".claude", "pai.json");
544
+ /** Where the config lived before 2026-09-19 — read during the transition,
545
+ * never written to once NEW_CONFIG_FILE exists (see `pai config migrate`). */
546
+ const LEGACY_CONFIG_FILE = join(homedir(), ".config", "pai", "config.json");
547
+ /**
548
+ * The path any read/write of the PAI config actually uses: PAI_CONFIG_FILE
549
+ * (tests, power users) first, else the new PAI_HOME location if it exists,
550
+ * else the most recent old location that is actually on disk (printing a
551
+ * one-time notice), else the new location (the target a first write creates).
552
+ */
553
+ function paiConfigFilePath() {
554
+ const override = process.env.PAI_CONFIG_FILE;
555
+ if (override) return override;
556
+ return resolvePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], "pai config migrate");
557
+ }
558
+ const CONFIG_FILE = paiConfigFilePath();
559
+ const CONFIG_DIR = dirname(CONFIG_FILE);
560
+ /** config.yaml — the canonical main config once `pai config yaml` has run,
561
+ * read/written by readMainConfigRaw/writeMainConfigRaw instead of
562
+ * CONFIG_FILE whenever it exists (see src/config/main-config.ts). */
563
+ function paiConfigYamlFilePath() {
564
+ return yamlSiblingPath(CONFIG_FILE);
565
+ }
566
+ /**
567
+ * Read the raw main config object exactly as every non-typed writer
568
+ * (identity, notifications, obsidian, setup, workers, memory settings) needs
569
+ * it: config.yaml when it exists, else CONFIG_FILE. `path` overrides the
570
+ * JSON location (tests, or workers/config.ts's parametrized CONFIG_FILE) —
571
+ * its YAML sibling (same dir, `config.yaml`) is what gets preferred.
572
+ */
573
+ function readMainConfigRaw(path = CONFIG_FILE) {
574
+ try {
575
+ return readDualFormatConfigRaw(path);
576
+ } catch (e) {
577
+ throw e instanceof MainConfigError ? new Error(e.message) : e;
578
+ }
579
+ }
580
+ /**
581
+ * Write the raw main config object back through the same seam: a
582
+ * comment-preserving sync into config.yaml when it exists, else a plain
583
+ * writeJsonAtomic to `path`. Every writer of the main config must call this
584
+ * instead of touching CONFIG_FILE directly, so a config.yaml on disk is
585
+ * honored no matter which of them made the change.
586
+ */
587
+ function writeMainConfigRaw(raw, path = CONFIG_FILE) {
588
+ try {
589
+ writeDualFormatConfigRaw(path, raw);
590
+ } catch (e) {
591
+ throw e instanceof MainConfigError ? new Error(e.message) : e;
592
+ }
593
+ }
594
+ /**
595
+ * `pai config migrate`: move config.json (from ~/.claude/pai.json or
596
+ * ~/.config/pai/config.json, whichever is found) to ~/.claude/pai/config.json
597
+ * byte-for-byte, verify the copy, then rename the old file aside as
598
+ * config.json.migrated-<YYYYMMDD> (never deleted).
599
+ */
600
+ function migrateConfigFile(opts = {}) {
601
+ return migratePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], opts);
602
+ }
603
+ function deepMerge(target, source) {
604
+ const result = { ...target };
605
+ for (const key of Object.keys(source)) {
606
+ const srcVal = source[key];
607
+ if (srcVal === void 0 || srcVal === null) continue;
608
+ const tgtVal = target[key];
609
+ if (typeof srcVal === "object" && !Array.isArray(srcVal) && typeof tgtVal === "object" && tgtVal !== null && !Array.isArray(tgtVal)) result[key] = deepMerge(tgtVal, srcVal);
610
+ else result[key] = srcVal;
611
+ }
612
+ return result;
613
+ }
614
+ /**
615
+ * Load configuration: config.yaml (see paiConfigYamlFilePath) if it exists,
616
+ * else CONFIG_FILE (see paiConfigFilePath), else defaults. Returns defaults
617
+ * deep-merged with any values found in the file.
618
+ */
619
+ function loadConfig() {
620
+ if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) return { ...DEFAULTS };
621
+ let parsed;
622
+ try {
623
+ parsed = readMainConfigRaw();
624
+ } catch (e) {
625
+ process.stderr.write(`[pai-daemon] Could not read config: ${e instanceof Error ? e.message : String(e)}\n`);
626
+ return { ...DEFAULTS };
627
+ }
628
+ if (parsed.obsidianVaultPath && !parsed.vaultPath) {
629
+ parsed.vaultPath = parsed.obsidianVaultPath;
630
+ process.stderr.write(`[pai-daemon] Config: mapped obsidianVaultPath → vaultPath (${parsed.vaultPath})\n`);
631
+ }
632
+ return deepMerge(DEFAULTS, parsed);
633
+ }
634
+ /**
635
+ * Ensure CONFIG_DIR exists and write a default config template to CONFIG_FILE
636
+ * if none exists yet. Call this only from the `serve` command.
637
+ */
638
+ function ensureConfigDir() {
639
+ if (!existsSync(CONFIG_DIR)) {
640
+ mkdirSync(CONFIG_DIR, { recursive: true });
641
+ process.stderr.write(`[pai-daemon] Created config directory: ${CONFIG_DIR}\n`);
642
+ }
643
+ if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) try {
644
+ writeFileSync(CONFIG_FILE, configTemplate(), "utf-8");
645
+ process.stderr.write(`[pai-daemon] Wrote default config to: ${CONFIG_FILE}\n`);
646
+ } catch (e) {
647
+ process.stderr.write(`[pai-daemon] Could not write default config: ${e}\n`);
648
+ }
649
+ }
650
+
651
+ //#endregion
652
+ export { DEFAULT_NOTIFICATION_CONFIG as S, writeYamlFileAtomic as _, ensureConfigDir as a, OWNER_LABEL_PREFIX as b, migrateConfigFile as c, readMainConfigRaw as d, writeMainConfigRaw as f, YamlStoreError as g, resolvedMainConfigPath as h, DEFAULT_SESSIONS_CACHE_KEEPALIVE as i, paiConfigFilePath as l, migrateMainConfigToYaml as m, CONFIG_FILE as n, expandHome as o, MainConfigError as p, DEFAULTS as r, loadConfig as s, CONFIG_DIR as t, paiConfigYamlFilePath as u, readJsonStrict as v, UNROUTED as x, writeJsonAtomic as y };
653
+ //# sourceMappingURL=config-Bn9jpblO.mjs.map