@tekmidian/pai 0.48.0 → 0.50.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 (107) 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-Bgv1lFFA.mjs → program-BX67-4I2.mjs} +536 -260
  51. package/dist/program-BX67-4I2.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 -0
  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/user-prompt/whisper-rules.test.ts +56 -1
  91. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  92. package/statusline-command.sh +81 -6
  93. package/dist/config-DCu8THKi.mjs +0 -228
  94. package/dist/config-DCu8THKi.mjs.map +0 -1
  95. package/dist/config-DeqFqa1N.mjs +0 -3
  96. package/dist/config-DxMZjpvY.mjs.map +0 -1
  97. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  98. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  99. package/dist/factory-47Qf_df7.mjs +0 -3
  100. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  101. package/dist/indexer-C5Le7XE7.mjs +0 -1
  102. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  103. package/dist/program-Bgv1lFFA.mjs.map +0 -1
  104. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  105. package/dist/run-Cls0YGgV.mjs.map +0 -1
  106. package/dist/server-Ch99xt5K.mjs.map +0 -1
  107. package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
@@ -0,0 +1,3 @@
1
+ import { t as createStorageBackend } from "./factory-Bt7TWQje.mjs";
2
+
3
+ export { createStorageBackend };
@@ -86,7 +86,7 @@ const ESCALATE_AFTER_ATTEMPTS = 5;
86
86
  async function notifyBackendDown(attempts, lastError) {
87
87
  try {
88
88
  const { routeNotification } = await import("./router-CremHKTI.mjs");
89
- const { loadConfig } = await import("./config-DeqFqa1N.mjs");
89
+ const { loadConfig } = await import("./config-RjeVGJ7K.mjs");
90
90
  await routeNotification({
91
91
  event: "error",
92
92
  title: "PAI: storage backend unreachable",
@@ -98,7 +98,7 @@ async function notifyBackendDown(attempts, lastError) {
98
98
  async function notifyBackendRecovered(attempts, since) {
99
99
  try {
100
100
  const { routeNotification } = await import("./router-CremHKTI.mjs");
101
- const { loadConfig } = await import("./config-DeqFqa1N.mjs");
101
+ const { loadConfig } = await import("./config-RjeVGJ7K.mjs");
102
102
  const mins = since ? Math.max(1, Math.round((Date.now() - since) / 6e4)) : null;
103
103
  await routeNotification({
104
104
  event: "completion",
@@ -147,4 +147,4 @@ async function createSQLiteBackend() {
147
147
 
148
148
  //#endregion
149
149
  export { getBackendOutage as n, humanDuration as r, createStorageBackend as t };
150
- //# sourceMappingURL=factory-DNYAqjH0.mjs.map
150
+ //# sourceMappingURL=factory-Bt7TWQje.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"factory-DNYAqjH0.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"factory-Bt7TWQje.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":";AA2BA,IAAI,UAAgC;;AAGpC,SAAgB,iBAAiB,QAA6B;AAC5D,WAAU;;;;;;;;;AAUZ,SAAgB,qBAA2B;AACzC,WAAU;;;AAIZ,SAAgB,mBAAyC;AACvD,QAAO;;;;;;;;;AAUT,SAAgB,cAAc,IAAoB;CAChD,MAAM,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,KAAK,IAAO,CAAC;AACjD,QAAO,OAAO,KAAK,GAAG,KAAK,QAAQ,IAAI,OAAO,IAAI,QAAQ,EAAE,CAAC;;;;;;AC/B/D,MAAM,sBAAsB;CAAC;CAAK;CAAO;CAAM;;AAG/C,MAAM,sBAAsB;;;;;;;;AAS5B,eAAsB,qBACpB,QACA,OAA8B,EAAE,EACP;AACzB,KAAI,OAAO,mBAAmB,WAC5B,QAAO,MAAM,gBAAgB,QAAQ,KAAK,mBAAmB,MAAM;AAIrE,QAAO,qBAAqB;;;;;;AAO9B,eAAe,gBACb,QAC0D;CAC1D,MAAM,EAAE,oBAAoB,MAAM,OAAO;CACzC,MAAM,WAAW,OAAO,YAAY,EAAE;CAEtC,IAAI,UAAuD;AAC3D,KAAI;AAEF,QAAM,gBAAgB,eAAe,SAAS;AAE9C,YAAU,IAAI,gBAAgB,SAAS;EACvC,MAAM,MAAM,MAAM,QAAQ,gBAAgB;AAC1C,MAAI,KAAK;AACP,SAAM,QAAQ,OAAO,CAAC,YAAY,GAAG;AACrC,UAAO,EAAE,OAAO,KAAK;;AAEvB,SAAO,EAAE,SAAS;UACX,GAAG;AACV,MAAI,QAAS,OAAM,QAAQ,OAAO,CAAC,YAAY,GAAG;AAClD,SAAO,EAAE,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAAE;;;;;;;;;;;AAYhE,MAAM,0BAA0B;;AAGhC,eAAe,kBAAkB,UAAkB,WAAkC;AACnF,KAAI;EACF,MAAM,EAAE,sBAAsB,MAAM,OAAO;EAC3C,MAAM,EAAE,eAAe,MAAM,OAAO;AACpC,QAAM,kBACJ;GACE,OAAO;GACP,OAAO;GACP,SACE,gCAAgC,SAAS,aAAa,UAAU;GAGnE,EACD,YAAY,CAAC,cACd;SACK;;;AAOV,eAAe,uBACb,UACA,OACe;AACf,KAAI;EACF,MAAM,EAAE,sBAAsB,MAAM,OAAO;EAC3C,MAAM,EAAE,eAAe,MAAM,OAAO;EACpC,MAAM,OAAO,QAAQ,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,KAAK,GAAG,SAAS,IAAO,CAAC,GAAG;AAC9E,QAAM,kBACJ;GACE,OAAO;GACP,OAAO;GACP,SACE,2BAA2B,SAAS,cACnC,OAAO,KAAK,KAAK,aAAa,MAC/B;GACH,EACD,YAAY,CAAC,cACd;SACK;;AAKV,eAAe,gBACb,QACA,aACyB;CACzB,IAAI,UAAU;CACd,IAAI,YAAY;CAChB,IAAI,cAA6B;CACjC,IAAI,YAAY;AAGhB,QAAO,MAAM;AACX;EACA,MAAM,SAAS,MAAM,gBAAgB,OAAO;AAC5C,MAAI,aAAa,QAAQ;AACvB,OAAI,UAAU,EACZ,SAAQ,OAAO,MACb,uDAAuD,QAAQ,eAChE;OAED,SAAQ,OAAO,MAAM,kDAAkD;AAIzE,uBAAoB;AACpB,OAAI,UAAW,CAAK,uBAAuB,SAAS,YAAY;AAChE,UAAO,OAAO;;AAGhB,cAAY,OAAO;AAEnB,MAAI,CAAC,eAAe,UAAU,oBAAoB,OAEhD,OAAM,IAAI,MACR,sCAAsC,QAAQ,aAAa,UAAU,6HAGtE;EAGH,MAAM,UAAU,cACZ,KAAK,IAAI,qBAAqB,MAAQ,KAAK,KAAK,IAAI,UAAU,GAAG,EAAE,CAAC,GACpE,oBAAoB,UAAU;AAElC,UAAQ,OAAO,MACb,sCAAsC,UAAU,WACrC,UAAU,cAAc,KAAK,IAAI,oBAAoB,SAAS,IAAI,MACrE,QAAQ,SACjB;AAOD,mBAAiB;GACf,SAAS;GACT,OAAO,gBAAgB,cAAc,KAAK,KAAK;GAC/C,UAAU;GACV,WAAW,OAAO,UAAU;GAC7B,CAAC;AAKF,MAAI,eAAe,YAAY,2BAA2B,CAAC,WAAW;AACpE,eAAY;AACZ,GAAK,kBAAkB,SAAS,OAAO,UAAU,CAAC;;AAGpD,QAAM,IAAI,SAAS,MAAM,WAAW,GAAG,QAAQ,CAAC;;;AAIpD,eAAe,sBAA+C;CAC5D,MAAM,EAAE,mBAAmB,MAAM,OAAO;CACxC,MAAM,EAAE,kBAAkB,MAAM,OAAO;AAEvC,QAAO,IAAI,cADA,gBAAgB,CACC"}
@@ -1,13 +1,218 @@
1
- import { n as CONFIG_FILE } from "./config-DCu8THKi.mjs";
2
- import { N as readJsonStrict, P as writeJsonAtomic, b as resolveProviderKey, f as isModelCapability, g as providerCostTier, h as nativeAnthropicProvider, i as PROVIDER_TAGS, k as workersYamlPath, m as maskKey, n as DEFAULT_LOG_DIR, o as WorkersConfigError, p as keysDir, r as MODEL_CAPABILITIES, t as ANTHROPIC_NATIVE, u as expandHome, v as readWorkersSection, x as writeWorkersSection, y as resolveModelCapability } from "./config-DxMZjpvY.mjs";
3
- import { A as isLive, D as contextLabel, E as alive, M as loadStatuses, O as contextPercent, T as ageOf, V as shortText, _ as currentTabKey, b as sessionTag, d as readInbox, f as sayToWorker, k as isChatPane, o as parseWorkerReport, p as workerDepth, s as renderReport, w as UNLABELED, x as workerInScope, y as resolveSession } from "./run-Cls0YGgV.mjs";
4
- import { _ as workersLogDir, a as clearCooldown, c as probeQuota, l as quotaSkipThreshold, p as eventsPath, u as resolveTarget } from "./server-Ch99xt5K.mjs";
1
+ import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, l as paiConfigFilePath, m as migrateMainConfigToYaml, n as CONFIG_FILE, r as DEFAULTS, s as loadConfig, u as paiConfigYamlFilePath, v as readJsonStrict, y as writeJsonAtomic } from "./config-Bn9jpblO.mjs";
2
+ import { C as readWorkersSection, D as writeWorkersSection, E as resolveProviderKey, F as workersYamlPath, T as resolveModelCapability, _ as isModelCapability, a as DEFAULT_PANE, b as nativeAnthropicProvider, c as MODEL_CAPABILITIES, d as WorkersConfigError, h as expandHome, i as DEFAULT_LOG_DIR, l as PROVIDER_TAGS, o as DEFAULT_ROUTING, r as DEFAULT_CACHE_KEEPALIVE_SECS, s as DEFAULT_TREE, t as ANTHROPIC_NATIVE, v as keysDir, w as resolveCapability, x as providerCostTier, y as maskKey } from "./config-DYXARgk8.mjs";
3
+ import { A as contextLabel, D as UNLABELED, F as loadStatuses, M as isChatPane, N as isLive, O as ageOf, W as shortText, b as sessionTag, d as workerDepth, h as currentTabKey, j as contextPercent, k as alive, l as readInbox, o as parseWorkerReport, s as renderReport, u as sayToWorker, v as resolveSession, x as workerInScope } from "./run-TChOnMO_.mjs";
4
+ import { f as resolveTarget, h as eventsPath, l as probeQuota, o as clearCooldown, u as quotaSkipThreshold, y as workersLogDir } from "./server-R9UkCn3v.mjs";
5
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, join, relative, resolve } from "node:path";
8
8
  import { execFileSync, spawn } from "node:child_process";
9
+ import { Document } from "yaml";
9
10
  import { createInterface } from "node:readline";
10
11
 
12
+ //#region src/config/main-config-ops.ts
13
+ /**
14
+ * main-config-ops.ts — the `pai config list/get/set/unset` operations shared
15
+ * by the CLI (src/cli/commands/config.ts) and the MCP tools
16
+ * (src/daemon-mcp/tools/config.ts). One implementation, two thin callers, so
17
+ * a change to masking or validation cannot drift between them.
18
+ */
19
+ var MainConfigOpsError = class extends Error {};
20
+ function splitConfigPath(dotted) {
21
+ const segments = dotted.split(".").filter((s) => s.length > 0);
22
+ if (!segments.length) throw new MainConfigOpsError("path is required");
23
+ return segments;
24
+ }
25
+ function getAtPath(root, segments) {
26
+ let cur = root;
27
+ for (const seg of segments) if (cur !== null && typeof cur === "object" && !Array.isArray(cur) && seg in cur) cur = cur[seg];
28
+ else return {
29
+ value: void 0,
30
+ found: false
31
+ };
32
+ return {
33
+ value: cur,
34
+ found: true
35
+ };
36
+ }
37
+ function setAtPath(root, segments, value) {
38
+ const clone = structuredClone(root);
39
+ let cur = clone;
40
+ for (let i = 0; i < segments.length - 1; i++) {
41
+ const seg = segments[i];
42
+ const next = cur[seg];
43
+ if (next === null || typeof next !== "object" || Array.isArray(next)) cur[seg] = {};
44
+ cur = cur[seg];
45
+ }
46
+ cur[segments[segments.length - 1]] = value;
47
+ return clone;
48
+ }
49
+ function deleteAtPath(root, segments) {
50
+ const clone = structuredClone(root);
51
+ let cur = clone;
52
+ for (let i = 0; i < segments.length - 1; i++) {
53
+ const seg = segments[i];
54
+ const next = cur[seg];
55
+ if (next === null || typeof next !== "object" || Array.isArray(next)) return clone;
56
+ cur = next;
57
+ }
58
+ delete cur[segments[segments.length - 1]];
59
+ return clone;
60
+ }
61
+ /** true/false, null, numbers, `[...]`/`{...}` as JSON, else the raw string. */
62
+ function parseConfigValueString(raw) {
63
+ if (raw === "true") return true;
64
+ if (raw === "false") return false;
65
+ if (raw === "null") return null;
66
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
67
+ const trimmed = raw.trim();
68
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) try {
69
+ return JSON.parse(trimmed);
70
+ } catch (e) {
71
+ throw new MainConfigOpsError(`invalid JSON value: ${e instanceof Error ? e.message : String(e)}`);
72
+ }
73
+ return raw;
74
+ }
75
+ const SECRET_SEGMENT_RE = /key|token|secret|password/i;
76
+ /** Paths not caught by SECRET_SEGMENT_RE but whose value routinely embeds a
77
+ * credential (a Postgres URL's userinfo component). */
78
+ const SPECIAL_SECRET_PATHS = new Set(["postgres.connectionString"]);
79
+ function isSecretPath(segments) {
80
+ if (SPECIAL_SECRET_PATHS.has(segments.filter((s) => typeof s === "string").join("."))) return true;
81
+ return segments.some((s) => typeof s === "string" && SECRET_SEGMENT_RE.test(s));
82
+ }
83
+ /** Deep-clone `value`, replacing every string leaf on a secret path with
84
+ * `maskKey`'s `****<last4>` form. Used by every reader (list/get, MCP). */
85
+ function maskSecretsDeep(value, path = []) {
86
+ if (Array.isArray(value)) return value.map((v, i) => maskSecretsDeep(v, [...path, i]));
87
+ if (value !== null && typeof value === "object") {
88
+ const out = {};
89
+ for (const [k, v] of Object.entries(value)) out[k] = maskSecretsDeep(v, [...path, k]);
90
+ return out;
91
+ }
92
+ if (typeof value === "string" && value && isSecretPath(path)) return maskKey(value);
93
+ return value;
94
+ }
95
+ /** The non-provider `workers` sub-object every writer persists to the main
96
+ * config (providers/classes/mcp_sets/active live in workers.yaml instead —
97
+ * see readWorkersSection). Shared by schemaRoot and effectiveConfigRoot. */
98
+ function workersMainConfigShape() {
99
+ const { workers } = readWorkersSection();
100
+ return {
101
+ enabled: workers.enabled,
102
+ pane: workers.pane,
103
+ logDir: workers.logDir,
104
+ routing: workers.routing,
105
+ tree: workers.tree,
106
+ cacheKeepaliveSecs: workers.cacheKeepaliveSecs,
107
+ ...workers.fallback ? { fallback: workers.fallback } : {}
108
+ };
109
+ }
110
+ function schemaRoot() {
111
+ return {
112
+ ...DEFAULTS,
113
+ workers: {
114
+ enabled: false,
115
+ pane: DEFAULT_PANE,
116
+ logDir: DEFAULT_LOG_DIR,
117
+ routing: DEFAULT_ROUTING,
118
+ tree: DEFAULT_TREE,
119
+ cacheKeepaliveSecs: DEFAULT_CACHE_KEEPALIVE_SECS
120
+ }
121
+ };
122
+ }
123
+ /** loadConfig() (typed, defaults-merged) plus the workers subset — the
124
+ * "effective" view `pai config list --all` / `get` resolve against. */
125
+ function effectiveConfigRoot() {
126
+ return {
127
+ ...loadConfig(),
128
+ workers: workersMainConfigShape()
129
+ };
130
+ }
131
+ /**
132
+ * Refuse an unset top-level key (typo protection) unless --force, and — when
133
+ * DEFAULTS names a scalar at this exact path — refuse a value of a different
134
+ * JS type unless --force. Nested unknown keys (e.g. under `tasks.providers`)
135
+ * are always allowed: DEFAULTS does not enumerate every provider shape.
136
+ */
137
+ function validateAgainstSchema(segments, value, opts) {
138
+ const schema = schemaRoot();
139
+ const topKey = segments[0];
140
+ if (!(topKey in schema) && !opts.force) throw new MainConfigOpsError(`unknown top-level config key "${topKey}" (known: ${Object.keys(schema).sort().join(", ")}) — pass --force to set it anyway`);
141
+ const { value: expected, found } = getAtPath(schema, segments);
142
+ if (found && expected !== null && expected !== void 0) {
143
+ const expectedType = Array.isArray(expected) ? "array" : typeof expected;
144
+ const actualType = Array.isArray(value) ? "array" : typeof value;
145
+ if (expectedType !== actualType && !opts.force) throw new MainConfigOpsError(`${segments.join(".")}: expected ${expectedType}, got ${actualType} (pass --force to override)`);
146
+ }
147
+ }
148
+ /** `pai config list` / `config_list`. `all=true` shows the defaults-merged
149
+ * effective config; otherwise only what the file explicitly sets. Secrets
150
+ * are always masked. */
151
+ function listConfigOp(opts = {}) {
152
+ const masked = maskSecretsDeep(opts.all ? effectiveConfigRoot() : readMainConfigRaw());
153
+ return {
154
+ scope: opts.all ? "effective" : "file",
155
+ yaml: String(new Document(masked)),
156
+ data: masked
157
+ };
158
+ }
159
+ /** `pai config get <path>` / `config_get`: resolves against the
160
+ * defaults-merged effective config (a value not set in the file still
161
+ * answers with its default), masked if the path looks like a secret. */
162
+ function getConfigValueOp(dottedPath) {
163
+ const segments = splitConfigPath(dottedPath);
164
+ const { value, found } = getAtPath(effectiveConfigRoot(), segments);
165
+ return {
166
+ found,
167
+ value: found ? maskSecretsDeep(value, segments) : void 0
168
+ };
169
+ }
170
+ /** `pai config get`'s CLI text: a scalar prints as-is, an object/array
171
+ * subtree prints as YAML (matching `config list`) unless `json` is passed —
172
+ * the MCP `config_get` tool returns the structured value directly and never
173
+ * goes through this. */
174
+ function formatConfigGetOutput(value, opts = {}) {
175
+ if (opts.json) return JSON.stringify(value, null, 2);
176
+ if (value !== null && typeof value === "object") return String(new Document(value)).trimEnd();
177
+ return String(value);
178
+ }
179
+ /**
180
+ * `pai config set <path> <value>` / `config_set`: parses `value`, validates
181
+ * its type against DEFAULTS (refusable with `force`), converts config.json
182
+ * to config.yaml first if neither exists yet, then writes through
183
+ * writeMainConfigRaw (comment-preserving when config.yaml exists).
184
+ */
185
+ function setConfigValueOp(dottedPath, rawValue, opts = {}) {
186
+ const segments = splitConfigPath(dottedPath);
187
+ const value = parseConfigValueString(rawValue);
188
+ validateAgainstSchema(segments, value, opts);
189
+ const jsonPath = paiConfigFilePath();
190
+ const yamlPath = paiConfigYamlFilePath();
191
+ let yamlCreated = false;
192
+ if (!existsSync(yamlPath)) {
193
+ if (existsSync(jsonPath)) migrateMainConfigToYaml(jsonPath, {});
194
+ else writeYamlFileAtomic(yamlPath, "{}\n", { label: yamlPath });
195
+ yamlCreated = true;
196
+ }
197
+ writeMainConfigRaw(setAtPath(readMainConfigRaw(), segments, value));
198
+ return {
199
+ yamlCreated,
200
+ yamlPath,
201
+ value
202
+ };
203
+ }
204
+ /** `pai config unset <path>` / `config_unset`: no-ops (existed=false) when
205
+ * the path was never set explicitly — defaults are never "unset". */
206
+ function unsetConfigValueOp(dottedPath) {
207
+ const segments = splitConfigPath(dottedPath);
208
+ const raw = readMainConfigRaw();
209
+ const { found } = getAtPath(raw, segments);
210
+ if (!found) return { existed: false };
211
+ writeMainConfigRaw(deleteAtPath(raw, segments));
212
+ return { existed: true };
213
+ }
214
+
215
+ //#endregion
11
216
  //#region src/workers/specfile.ts
12
217
  /**
13
218
  * specfile.ts — `--spec <path>` reads a worker's prompt from a file (or stdin
@@ -1429,7 +1634,7 @@ function resolveProviderName(workers, name) {
1429
1634
  * (`pai worker model`, MCP worker_model).
1430
1635
  */
1431
1636
  function setProviderModel(name, capability, model, configPath) {
1432
- if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a model capability (from: ${MODEL_CAPABILITIES.join(", ")})`);
1637
+ if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$; well-known: ${MODEL_CAPABILITIES.join(", ")})`);
1433
1638
  const id = model.trim();
1434
1639
  if (!id) throw new WorkersConfigError(`a ${capability} model id must not be empty`);
1435
1640
  const { raw, workers } = readWorkersSection(configPath);
@@ -1496,11 +1701,14 @@ function classTargetText(target) {
1496
1701
  ].join(" ");
1497
1702
  }
1498
1703
  /**
1499
- * `default X fast Y image Z` — every model capability on one line, unset
1500
- * ones shown as "(none)" (they resolve to the default model).
1704
+ * `default X fast Y image Z …` — every well-known capability on one line
1705
+ * (unset ones shown as "(none)"), plus any open-set capability this provider
1706
+ * actually declares (e.g. `vision`), which is only listed when set.
1501
1707
  */
1502
1708
  function modelPrefsText(p) {
1503
- return MODEL_CAPABILITIES.map((c) => `${c} ${p.models[c] ?? "(none)"}`).join(" ");
1709
+ const known = new Set(MODEL_CAPABILITIES);
1710
+ const extra = Object.keys(p.models).filter((c) => !known.has(c)).sort();
1711
+ return [...MODEL_CAPABILITIES, ...extra].map((c) => `${c} ${p.models[c] ?? "(none)"}`).join(" ");
1504
1712
  }
1505
1713
  /**
1506
1714
  * Compact model listing for `pai worker model` / worker_model get: the active
@@ -1551,6 +1759,7 @@ function describeProviders(workers, configPath = CONFIG_FILE) {
1551
1759
  if (p.note) lines.push(` ${p.note}`);
1552
1760
  if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
1553
1761
  if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
1762
+ if (p.engine === "image") lines.push(` engine image (no process spawn — POSTs {url}/images/generations directly)`);
1554
1763
  const cls = classesForProvider(workers, name);
1555
1764
  lines.push(` classes: ${cls.length ? cls.join(", ") : "(none)"}`);
1556
1765
  }
@@ -1560,6 +1769,47 @@ function describeProviders(workers, configPath = CONFIG_FILE) {
1560
1769
  lines.push(`config: ${existsSync(workersYamlPath()) ? workersYamlPath() : configPath}`);
1561
1770
  return lines;
1562
1771
  }
1772
+ /**
1773
+ * Set which provider(s), in order, serve a capability across the whole
1774
+ * config (`pai worker capability <name> <provider>[,<provider>…]`, MCP
1775
+ * worker_capability). resolveCapability walks this list at run time.
1776
+ */
1777
+ function setCapabilityPreference(capability, providers, configPath) {
1778
+ if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$)`);
1779
+ if (!providers.length) throw new WorkersConfigError("at least one provider name is required");
1780
+ const { raw, workers } = readWorkersSection(configPath);
1781
+ for (const name of providers) if (name !== ANTHROPIC_NATIVE && !workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1782
+ workers.capabilities[capability] = providers;
1783
+ writeWorkersSection(raw, workers, configPath);
1784
+ return workers;
1785
+ }
1786
+ function unsetCapabilityPreference(capability, configPath) {
1787
+ const { raw, workers } = readWorkersSection(configPath);
1788
+ if (!(capability in workers.capabilities)) throw new WorkersConfigError(`no capability preference set for "${capability}"`);
1789
+ delete workers.capabilities[capability];
1790
+ writeWorkersSection(raw, workers, configPath);
1791
+ return workers;
1792
+ }
1793
+ /**
1794
+ * `pai worker capability` with no args: every configured `capabilities:`
1795
+ * preference and what it resolves to right now — provider/model/engine, or
1796
+ * `fallback: <provider>/<model>` when nothing on the list actually declares
1797
+ * the capability (see resolveCapability's fellBack flag).
1798
+ */
1799
+ function describeCapabilities(workers) {
1800
+ const names = Object.keys(workers.capabilities);
1801
+ if (!names.length) return ["no capability preferences set — set one with `pai worker capability <name> <provider>[,<provider>…]`"];
1802
+ return names.map((cap) => {
1803
+ const pref = workers.capabilities[cap];
1804
+ try {
1805
+ const r = resolveCapability(workers, cap);
1806
+ const resolved = r.fellBack ? `fallback: ${r.provider}/${r.model}` : `${r.provider}/${r.model} engine ${r.engine}`;
1807
+ return `${cap}: [${pref.join(", ")}] -> ${resolved}`;
1808
+ } catch (e) {
1809
+ return `${cap}: [${pref.join(", ")}] -> unresolved: ${e instanceof Error ? e.message : String(e)}`;
1810
+ }
1811
+ });
1812
+ }
1563
1813
 
1564
1814
  //#endregion
1565
1815
  //#region src/workers/fallback.ts
@@ -1826,5 +2076,5 @@ function fallbackStatusText(s) {
1826
2076
  }
1827
2077
 
1828
2078
  //#endregion
1829
- export { readSpecPrompt as C, statusLineOutput as S, updateProvider as _, addProvider as a, psOutput as b, describeProviders as c, resolveProviderName as d, setClass as f, unsetClass as g, setWorkersEnabled as h, fallbackStatusText as i, modelPrefsText as l, setProviderModel as m, fallbackOn as n, classTargetText as o, setProviderEnabled as p, fallbackStatus as r, describeModels as s, fallbackOff as t, removeProvider as u, useProvider as v, resolveSpecPath as w, replayOutput as x, followWorkers as y };
1830
- //# sourceMappingURL=fallback-IeR2FrFn.mjs.map
2079
+ export { getConfigValueOp as A, psOutput as C, resolveSpecPath as D, readSpecPrompt as E, setConfigValueOp as M, unsetConfigValueOp as N, MainConfigOpsError as O, followWorkers as S, statusLineOutput as T, setWorkersEnabled as _, addProvider as a, updateProvider as b, describeModels as c, removeProvider as d, resolveProviderName as f, setProviderModel as g, setProviderEnabled as h, fallbackStatusText as i, listConfigOp as j, formatConfigGetOutput as k, describeProviders as l, setClass as m, fallbackOn as n, classTargetText as o, setCapabilityPreference as p, fallbackStatus as r, describeCapabilities as s, fallbackOff as t, modelPrefsText as u, unsetCapabilityPreference as v, replayOutput as w, useProvider as x, unsetClass as y };
2080
+ //# sourceMappingURL=fallback-CIGspCSi.mjs.map