@tekmidian/pai 0.42.0 → 0.42.2

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 (70) hide show
  1. package/README.md +1 -1
  2. package/dist/chain-B-DbR82m.mjs +164 -0
  3. package/dist/chain-B-DbR82m.mjs.map +1 -0
  4. package/dist/cli/index.mjs +8 -8
  5. package/dist/cli/program.mjs +8 -8
  6. package/dist/{config-DgzR5sfb.mjs → config-_lQI_W08.mjs} +116 -3
  7. package/dist/config-_lQI_W08.mjs.map +1 -0
  8. package/dist/{context-handover-cache-dUnSZwaA.mjs → context-handover-cache-pNFdBNrU.mjs} +27 -8
  9. package/dist/context-handover-cache-pNFdBNrU.mjs.map +1 -0
  10. package/dist/daemon/index.mjs +7 -7
  11. package/dist/{daemon-CKBkhOsZ.mjs → daemon-Awt_3UJp.mjs} +7 -7
  12. package/dist/{daemon-DsvP8KMy.mjs → daemon-Dq8A1uDF.mjs} +591 -370
  13. package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
  14. package/dist/daemon-mcp/index.mjs +5 -5
  15. package/dist/{fallback-5UJO-RyX.mjs → fallback-vQPI1qxt.mjs} +15 -13
  16. package/dist/fallback-vQPI1qxt.mjs.map +1 -0
  17. package/dist/hooks/mcp-deferred-gate.mjs +286 -0
  18. package/dist/hooks/mcp-deferred-gate.mjs.map +7 -0
  19. package/dist/hooks/mcp-deferred-reminder.mjs +54 -0
  20. package/dist/hooks/mcp-deferred-reminder.mjs.map +7 -0
  21. package/dist/hooks/route-agents-to-worker.mjs +65 -2
  22. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  23. package/dist/hooks/worker-proxy.mjs +64 -1
  24. package/dist/hooks/worker-proxy.mjs.map +2 -2
  25. package/dist/hooks/worker-status-line.mjs +68 -2
  26. package/dist/hooks/worker-status-line.mjs.map +2 -2
  27. package/dist/hooks/worker-supervision.mjs +64 -1
  28. package/dist/hooks/worker-supervision.mjs.map +2 -2
  29. package/dist/{main-resolver-DXGuygFG.mjs → main-resolver-BAfOMppW.mjs} +2 -2
  30. package/dist/{main-resolver-BgIrC-D9.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
  31. package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
  32. package/dist/{planner-BLDJt3ZS.mjs → planner-BfEGcEs1.mjs} +5 -5
  33. package/dist/{planner-BLDJt3ZS.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
  34. package/dist/{program-CruRnLtc.mjs → program-BDmeZP-v.mjs} +17 -11
  35. package/dist/{program-CruRnLtc.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
  36. package/dist/{chain-DGXNoTwc.mjs → run-CGXbhf2k.mjs} +422 -195
  37. package/dist/run-CGXbhf2k.mjs.map +1 -0
  38. package/dist/{server-Cp7tNzNC.mjs → server-D4vAieF4.mjs} +49 -8
  39. package/dist/server-D4vAieF4.mjs.map +1 -0
  40. package/dist/{state-BTa10SOM.mjs → state-BRdB-Bf1.mjs} +7 -2
  41. package/dist/state-BRdB-Bf1.mjs.map +1 -0
  42. package/dist/state-CY6I-Hnc.mjs +3 -0
  43. package/dist/{work-queue-worker-D1p2V-ae.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
  44. package/dist/{work-queue-worker-7Vkli4Yh.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
  45. package/dist/{work-queue-worker-7Vkli4Yh.mjs.map → work-queue-worker-DIT-VLSh.mjs.map} +1 -1
  46. package/docs/cache-keepalive.md +82 -0
  47. package/docs/commands/worker.md +1 -0
  48. package/docs/merge-demo.md +1 -0
  49. package/docs/provider-abstraction.md +52 -2
  50. package/package.json +1 -1
  51. package/plugins/core/hooks/hooks.json +12 -0
  52. package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
  53. package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
  54. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
  55. package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
  56. package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
  57. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
  58. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
  59. package/statusline-command.sh +149 -72
  60. package/dist/chain-DGXNoTwc.mjs.map +0 -1
  61. package/dist/config-DgzR5sfb.mjs.map +0 -1
  62. package/dist/context-handover-cache-dUnSZwaA.mjs.map +0 -1
  63. package/dist/daemon-DsvP8KMy.mjs.map +0 -1
  64. package/dist/fallback-5UJO-RyX.mjs.map +0 -1
  65. package/dist/main-resolver-BgIrC-D9.mjs.map +0 -1
  66. package/dist/scope-NIwCGxRI.mjs +0 -351
  67. package/dist/scope-NIwCGxRI.mjs.map +0 -1
  68. package/dist/server-Cp7tNzNC.mjs.map +0 -1
  69. package/dist/state-BTa10SOM.mjs.map +0 -1
  70. package/dist/state-RXepvbnO.mjs +0 -3
@@ -5,14 +5,14 @@ import { n as indexAll } from "./sync-BWbe8JTg.mjs";
5
5
  import { t as configureEmbeddingModel } from "./embeddings-DOLZnT1X.mjs";
6
6
  import { l as DEFAULT_NOTIFICATION_CONFIG, n as CONFIG_FILE, t as CONFIG_DIR } from "./config-B64vFg14.mjs";
7
7
  import { n as getBackendOutage, t as createStorageBackend } from "./factory-DEb9NmHJ.mjs";
8
- import { m as readWorkersSection } from "./config-DgzR5sfb.mjs";
9
- import { _ as workersLogDir } from "./server-Cp7tNzNC.mjs";
10
- import { d as alive, g as loadStatuses, n as itermForClaudeSession, v as nowStamp } from "./scope-NIwCGxRI.mjs";
11
- import { C as setStorageBackend, D as storageBackend, E as startTime, O as vaultIndexInProgress, S as setStartTime, T as shutdownRequested, _ as setLastIndexTime, a as indexSchedulerTimer, b as setRegistryDb, c as lastVaultIndexTime, d as setDaemonConfig, f as setEmbedInProgress, g as setLastEmbedTime, h as setIndexSchedulerTimer, i as indexInProgress, l as notificationConfig, m as setIndexInProgress, n as embedInProgress, o as lastEmbedTime, p as setEmbedSchedulerTimer, r as embedSchedulerTimer, s as lastIndexTime, t as daemonConfig, u as registryDb, v as setLastVaultIndexTime, w as setVaultIndexInProgress, x as setShutdownRequested, y as setNotificationConfig } from "./state-BTa10SOM.mjs";
8
+ import { _ as readWorkersSection } from "./config-_lQI_W08.mjs";
9
+ import { _ as workersLogDir, m as ledgerPath, p as eventsPath } from "./server-D4vAieF4.mjs";
10
+ import { A as nowStamp, C as alive, D as loadStatus, E as isChatPane, O as loadStatuses, _ as itermForClaudeSession, j as appendLedger, n as runWorker } from "./run-CGXbhf2k.mjs";
11
+ import { A as vaultIndexInProgress, C as setShutdownRequested, D as shutdownRequested, E as setVaultIndexInProgress, O as startTime, S as setRegistryDb, T as setStorageBackend, _ as setIndexSchedulerTimer, a as indexInProgress, b as setLastVaultIndexTime, c as lastIndexTime, d as registryDb, f as setCacheKeepaliveTimer, g as setIndexInProgress, h as setEmbedSchedulerTimer, i as embedSchedulerTimer, k as storageBackend, l as lastVaultIndexTime, m as setEmbedInProgress, n as daemonConfig, o as indexSchedulerTimer, p as setDaemonConfig, r as embedInProgress, s as lastEmbedTime, t as cacheKeepaliveTimer, u as notificationConfig, v as setLastEmbedTime, w as setStartTime, x as setNotificationConfig, y as setLastIndexTime } from "./state-BRdB-Bf1.mjs";
12
12
  import { t as routeNotification } from "./router-DD6D4j31.mjs";
13
13
  import { C as toolProjectInfo, D as toolMemoryGet, O as toolMemorySearch, S as toolProjectHealth, T as toolProjectTodo, a as toolMemoryWakeup, b as toolSessionRoute, i as toolMemoryTaxonomy, n as toolMemoryFeedback, t as toolMemoryKgSearch, v as toolRegistrySearch, w as toolProjectList, x as toolProjectDetect, y as toolSessionList } from "./tools-LlzcV_eK.mjs";
14
14
  import { t as detectTopicShift } from "./detector-C73Nxm94.mjs";
15
- import { a as enqueue, i as stopWorker, n as notifyNewWork, o as getStats, r as startWorker, s as loadQueue } from "./work-queue-worker-7Vkli4Yh.mjs";
15
+ import { a as enqueue, i as stopWorker, n as notifyNewWork, o as getStats, r as startWorker, s as loadQueue } from "./work-queue-worker-DIT-VLSh.mjs";
16
16
  import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { setPriority } from "node:os";
18
18
  import { dirname, join } from "node:path";
@@ -87,6 +87,141 @@ function patchNotificationConfig(patch) {
87
87
  return current;
88
88
  }
89
89
 
90
+ //#endregion
91
+ //#region src/workers/keepalive.ts
92
+ /**
93
+ * keepalive.ts — one cache-keepalive beat, shared by the daemon scheduler and
94
+ * the probe script.
95
+ *
96
+ * The provider (GLM anthropic-compat endpoint) caches the worker prefix
97
+ * implicitly — no cache_control blocks — so a trivial single-turn worker spawn
98
+ * through the very same runWorker path re-arms that cache for the next real
99
+ * worker: identical env, identical tool grants, identical WORKER_CONTRACT_PROMPT
100
+ * by construction, because it IS a worker spawn.
101
+ *
102
+ * Measured 2026-09-18 (docs/cache-keepalive.md): the implicit cache only
103
+ * serves spawns within seconds of the previous identical request and never
104
+ * survived a 60 s beat cadence in the armed proof — hence the knob defaults
105
+ * to off and arming it is the operator's explicit call.
106
+ *
107
+ * Deliberately NOT a hand-built claude invocation: duplicated prefix
108
+ * construction is how this repo has shipped real breakage before (probeResume
109
+ * x3, archiver x2). Everything flows through runWorker.
110
+ */
111
+ /**
112
+ * The trivial prompt every cache beat (and probe) carries. Kept in one place
113
+ * so the heartbeat arms exactly the prefix a verifying probe measures.
114
+ */
115
+ const HEARTBEAT_PROMPT = "Reply with exactly one word: pong.";
116
+ /** The worker class a beat runs as — the cheapest spawn the config defines. */
117
+ const KEEPALIVE_CLASS = "simple";
118
+ /** Worker label of every beat, so `pai worker ps` can tell them apart. */
119
+ const KEEPALIVE_LABEL = "cache-keepalive";
120
+ /** Cadence a keepalive runs at for this config; 0 keeps it off. */
121
+ function keepaliveSecs(workers) {
122
+ return workers.enabled ? workers.cacheKeepaliveSecs : 0;
123
+ }
124
+ /**
125
+ * The LAST `type:"result"` event of a worker transcript, or null when the
126
+ * file is missing, unreadable, or carries no result line (a failed run can
127
+ * end without one).
128
+ */
129
+ function parseLastResultEvent(path) {
130
+ let text;
131
+ try {
132
+ text = readFileSync(path, "utf8");
133
+ } catch {
134
+ return null;
135
+ }
136
+ let found = null;
137
+ for (const line of text.split("\n")) {
138
+ if (!line.startsWith("{")) continue;
139
+ let e;
140
+ try {
141
+ e = JSON.parse(line);
142
+ } catch {
143
+ continue;
144
+ }
145
+ if (e.type === "result") found = e;
146
+ }
147
+ return found;
148
+ }
149
+ /**
150
+ * Metrics of one run from its final result event. Single-turn runs make the
151
+ * cumulative result usage identical to first-turn usage, so a warm cache
152
+ * shows up directly as cache_read_input_tokens > 0.
153
+ */
154
+ function extractBeatMetrics(m, ev) {
155
+ const u = ev?.usage;
156
+ return {
157
+ ...m,
158
+ input_tokens: u?.input_tokens ?? null,
159
+ output_tokens: u?.output_tokens ?? null,
160
+ cache_read_input_tokens: u?.cache_read_input_tokens ?? null,
161
+ cache_creation_input_tokens: u?.cache_creation_input_tokens ?? null,
162
+ duration_api_ms: ev?.duration_api_ms ?? null,
163
+ duration_ms: ev?.duration_ms ?? null,
164
+ num_turns: ev?.num_turns ?? null
165
+ };
166
+ }
167
+ /** True while a beat's worker is still running — the overlap guard reads it. */
168
+ let beatInFlight = false;
169
+ /** `keepalive-<YYYYMMDD-HHMMSS>` — beats are minutes apart, seconds suffice. */
170
+ function beatId(d = /* @__PURE__ */ new Date()) {
171
+ const p = (n) => String(n).padStart(2, "0");
172
+ return `keepalive-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
173
+ }
174
+ /**
175
+ * One heartbeat: a single-turn trivial worker through the exact worker spawn
176
+ * path, its result metrics parsed from the transcript, one WORKER-KEEPALIVE
177
+ * ledger line. Returns null when another beat is still running (overlap
178
+ * guard) — a skipped beat is free, a queue of them is not.
179
+ *
180
+ * The beat runs in the workers logDir: no session renders its status line
181
+ * there, so the spawn adopts no spawnerSession and no iTerm identity, and
182
+ * worker supervision has no owner to notify — heartbeats are infrastructure,
183
+ * not somebody's workers.
184
+ */
185
+ async function runKeepaliveBeat(opts = {}) {
186
+ if (beatInFlight) return null;
187
+ beatInFlight = true;
188
+ try {
189
+ const { workers: config } = readWorkersSection(opts.configPath);
190
+ const logDir = workersLogDir(config);
191
+ const id = opts.id ?? beatId();
192
+ const rc = await runWorker({
193
+ claudeArgs: ["-p", HEARTBEAT_PROMPT],
194
+ className: KEEPALIVE_CLASS,
195
+ label: KEEPALIVE_LABEL,
196
+ noPane: true,
197
+ quiet: true,
198
+ worktreeFlag: false,
199
+ id,
200
+ cwd: logDir
201
+ });
202
+ const status = loadStatus(logDir, id);
203
+ const metrics = extractBeatMetrics({
204
+ id,
205
+ ok: rc === 0,
206
+ rc,
207
+ provider: status?.provider ?? null,
208
+ model: status?.model ?? null
209
+ }, parseLastResultEvent(eventsPath(logDir, id)));
210
+ appendLedger(ledgerPath(logDir), "WORKER-KEEPALIVE", {
211
+ id,
212
+ provider: metrics.provider,
213
+ model: metrics.model,
214
+ input_tokens: metrics.input_tokens,
215
+ cache_read_input_tokens: metrics.cache_read_input_tokens,
216
+ cache_creation_input_tokens: metrics.cache_creation_input_tokens,
217
+ duration_api_ms: metrics.duration_api_ms
218
+ });
219
+ return metrics;
220
+ } finally {
221
+ beatInFlight = false;
222
+ }
223
+ }
224
+
90
225
  //#endregion
91
226
  //#region src/workers/supervision.ts
92
227
  /**
@@ -102,7 +237,9 @@ function patchNotificationConfig(patch) {
102
237
  * failed — the run ended badly (state failed/killed/lost, or rc != 0,
103
238
  * or the runner pid vanished while state was still running)
104
239
  * stalled — state running, runner alive, but no new turn for longer than
105
- * the stall threshold (PAI_WORKER_STALL_MINUTES, default 10)
240
+ * the stall threshold (PAI_WORKER_STALL_MINUTES, default 10);
241
+ * never emitted for the interactive chat pane (origin "chat"),
242
+ * which idles between sessions by design
106
243
  *
107
244
  * Delivery, in order of preference, never both counted as required:
108
245
  * 1. AIBroker `send_to_session` into the orchestrator's terminal — the
@@ -204,7 +341,7 @@ function detectSupervisionEvents(statuses, opts) {
204
341
  else if (!isAlive(s.pid) && ageMs(s.updated, opts.now) > deadGraceMs) {
205
342
  kind = "failed";
206
343
  rc = null;
207
- } else if (ageMs(s.updated, opts.now) > opts.stallMs) {
344
+ } else if (!isChatPane(s) && ageMs(s.updated, opts.now) > opts.stallMs) {
208
345
  kind = "stalled";
209
346
  stalledMin = Math.floor(ageMs(s.updated, opts.now) / 6e4);
210
347
  }
@@ -377,368 +514,132 @@ async function runSupervisionTick(logDir, opts = {}) {
377
514
  }
378
515
 
379
516
  //#endregion
380
- //#region src/daemon/daemon/scheduler.ts
517
+ //#region src/observations/store.ts
381
518
  /**
382
- * Index, embed, vault index, and registry scan schedulers for the PAI daemon.
383
- * Exports run* functions (also called on-demand by the IPC handler)
384
- * and the start* functions invoked once at daemon startup.
519
+ * store.ts — PostgreSQL persistence for PAI observations.
520
+ *
521
+ * All functions accept a pg.Pool and are safe to call concurrently.
522
+ * Schema is initialized lazily via ensureObservationTables().
523
+ *
524
+ * Content-hash deduplication: observations with the same hash
525
+ * created within a 30-second window are silently dropped to prevent
526
+ * duplicate entries from rapid repeated tool calls.
385
527
  */
386
- /** Minimum interval between vault index runs (30 minutes). */
387
- const VAULT_INDEX_MIN_INTERVAL_MS = 1800 * 1e3;
528
+ let _tablesEnsured = false;
388
529
  /**
389
- * Run a full index pass. Guards against overlapping runs with indexInProgress.
390
- * Called both by the scheduler and by the index_now IPC method.
530
+ * Inlined schema DDL — avoids runtime file reads that break in bundled code
531
+ * (the bundler puts this in a shared chunk whose __dirname differs from src/).
391
532
  */
392
- async function runIndex() {
393
- if (indexInProgress) {
394
- process.stderr.write("[pai-daemon] Index already in progress, skipping.\n");
395
- return;
396
- }
397
- if (embedInProgress) {
398
- process.stderr.write("[pai-daemon] Embed in progress, deferring index run.\n");
399
- return;
400
- }
401
- setIndexInProgress(true);
402
- const t0 = Date.now();
403
- try {
404
- process.stderr.write("[pai-daemon] Starting scheduled index run...\n");
405
- if (storageBackend.backendType === "sqlite") {
406
- const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
407
- if (storageBackend instanceof SQLiteBackend) {
408
- const { projects, result } = await indexAll(storageBackend.getRawDb(), registryDb);
409
- const elapsed = Date.now() - t0;
410
- setLastIndexTime(Date.now());
411
- process.stderr.write(`[pai-daemon] Index complete: ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
412
- }
413
- } else {
414
- const { indexAllWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
415
- const { projects, result } = await indexAllWithBackend(storageBackend, registryDb);
416
- const elapsed = Date.now() - t0;
417
- setLastIndexTime(Date.now());
418
- process.stderr.write(`[pai-daemon] Index complete (postgres): ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
419
- }
420
- } catch (e) {
421
- const msg = e instanceof Error ? e.message : String(e);
422
- process.stderr.write(`[pai-daemon] Index error: ${msg}\n`);
423
- } finally {
424
- setIndexInProgress(false);
425
- }
426
- }
533
+ const SCHEMA_SQL = `
534
+ CREATE TABLE IF NOT EXISTS pai_observations (
535
+ id SERIAL PRIMARY KEY,
536
+ session_id TEXT NOT NULL,
537
+ project_id INTEGER,
538
+ project_slug TEXT,
539
+ type TEXT NOT NULL CHECK (type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
540
+ title TEXT NOT NULL,
541
+ narrative TEXT,
542
+ tool_name TEXT,
543
+ tool_input_summary TEXT,
544
+ files_read JSONB DEFAULT '[]'::jsonb,
545
+ files_modified JSONB DEFAULT '[]'::jsonb,
546
+ concepts JSONB DEFAULT '[]'::jsonb,
547
+ content_hash TEXT,
548
+ created_at TIMESTAMPTZ DEFAULT NOW()
549
+ );
550
+
551
+ CREATE INDEX IF NOT EXISTS idx_obs_project ON pai_observations(project_id);
552
+ CREATE INDEX IF NOT EXISTS idx_obs_session ON pai_observations(session_id);
553
+ CREATE INDEX IF NOT EXISTS idx_obs_type ON pai_observations(type);
554
+ CREATE INDEX IF NOT EXISTS idx_obs_created ON pai_observations(created_at DESC);
555
+ CREATE INDEX IF NOT EXISTS idx_obs_hash ON pai_observations(content_hash);
556
+
557
+ CREATE TABLE IF NOT EXISTS pai_session_summaries (
558
+ id SERIAL PRIMARY KEY,
559
+ session_id TEXT NOT NULL UNIQUE,
560
+ project_id INTEGER,
561
+ project_slug TEXT,
562
+ request TEXT,
563
+ investigated TEXT,
564
+ learned TEXT,
565
+ completed TEXT,
566
+ next_steps TEXT,
567
+ observation_count INTEGER DEFAULT 0,
568
+ created_at TIMESTAMPTZ DEFAULT NOW()
569
+ );
570
+
571
+ CREATE INDEX IF NOT EXISTS idx_ss_project ON pai_session_summaries(project_id);
572
+ CREATE INDEX IF NOT EXISTS idx_ss_session ON pai_session_summaries(session_id);
573
+ `;
427
574
  /**
428
- * Run a vault index pass. Guards against overlapping runs with vaultIndexInProgress.
429
- * Skips if no vaultPath is configured, or if project index/embed is in progress.
575
+ * Run schema DDL idempotently against the given pool.
576
+ * Uses a module-level flag so subsequent calls are no-ops within the same
577
+ * process lifetime (the SQL itself uses IF NOT EXISTS so it is safe to re-run).
430
578
  */
431
- async function runVaultIndex() {
432
- if (!daemonConfig.vaultPath) return;
433
- if (vaultIndexInProgress) {
434
- process.stderr.write("[pai-daemon] Vault index already in progress, skipping.\n");
435
- return;
436
- }
437
- if (indexInProgress || embedInProgress) {
438
- process.stderr.write("[pai-daemon] Index/embed in progress, deferring vault index.\n");
439
- return;
440
- }
441
- const { lastVaultIndexTime } = await import("./state-RXepvbnO.mjs");
442
- if (lastVaultIndexTime > 0 && Date.now() - lastVaultIndexTime < VAULT_INDEX_MIN_INTERVAL_MS) return;
443
- let vaultProjectId = daemonConfig.vaultProjectId;
444
- if (!vaultProjectId) {
445
- const row = registryDb.prepare("SELECT id FROM projects WHERE root_path = ?").get(daemonConfig.vaultPath);
446
- vaultProjectId = row?.id ?? 999;
447
- if (!row) process.stderr.write("[pai-daemon] Vault not in project registry — using synthetic project ID 999.\n");
448
- }
449
- setVaultIndexInProgress(true);
450
- const t0 = Date.now();
451
- process.stderr.write("[pai-daemon] Starting vault index run...\n");
452
- try {
453
- const { indexVault } = await import("./vault-indexer-mata9S7b.mjs");
454
- const r = await indexVault(storageBackend, vaultProjectId, daemonConfig.vaultPath);
455
- const elapsed = Date.now() - t0;
456
- setLastVaultIndexTime(Date.now());
457
- process.stderr.write(`[pai-daemon] Vault index complete: ${r.filesIndexed} files, ${r.linksExtracted} links, ${r.deadLinksFound} dead, ${r.orphansFound} orphans (${elapsed}ms)\n`);
458
- } catch (e) {
459
- const msg = e instanceof Error ? e.message : String(e);
460
- process.stderr.write(`[pai-daemon] Vault index error: ${msg}\n`);
461
- } finally {
462
- setVaultIndexInProgress(false);
463
- }
579
+ async function ensureObservationTables(pool) {
580
+ if (_tablesEnsured) return;
581
+ await pool.query(SCHEMA_SQL);
582
+ _tablesEnsured = true;
464
583
  }
465
584
  /**
466
- * Start the periodic index scheduler. Runs an initial pass 2 seconds after startup.
585
+ * Compute a 16-character hex content hash for deduplication.
586
+ * Hash = SHA256(session_id + tool_name + title).slice(0, 16)
467
587
  */
468
- function startIndexScheduler() {
469
- const intervalMs = daemonConfig.indexIntervalSecs * 1e3;
470
- process.stderr.write(`[pai-daemon] Index scheduler: every ${daemonConfig.indexIntervalSecs}s\n`);
471
- const cycle = (label, withEmbed) => runIndex().then(() => runVaultIndex()).then(() => withEmbed ? runEmbed() : void 0).catch((e) => {
472
- process.stderr.write(`[pai-daemon] ${label} index error: ${e}\n`);
473
- });
474
- if (daemonConfig.maintenanceHour === void 0) setTimeout(() => void cycle("Startup", daemonConfig.embedOnStartup), 2e3);
475
- else process.stderr.write("[pai-daemon] Startup index pass skipped (maintenanceHour is set).\n");
476
- const firstDelayMs = msUntilNextAnchor(daemonConfig.maintenanceHour, intervalMs);
477
- if (daemonConfig.maintenanceHour !== void 0) process.stderr.write(`[pai-daemon] Index scheduler anchored to ${String(daemonConfig.maintenanceHour).padStart(2, "0")}:00 local (next pass in ${Math.round(firstDelayMs / 6e4)} min)\n`);
478
- let timer;
479
- const startTimer = () => {
480
- timer = setInterval(() => void cycle("Scheduled", true), intervalMs);
481
- if (timer.unref) timer.unref();
482
- setIndexSchedulerTimer(timer);
483
- };
484
- const first = setTimeout(() => {
485
- cycle("Scheduled", true);
486
- startTimer();
487
- }, firstDelayMs);
488
- if (first.unref) first.unref();
588
+ function computeContentHash(sessionId, toolName, title) {
589
+ return sha256(sessionId + "\0" + toolName + "\0" + title).slice(0, 16);
489
590
  }
490
591
  /**
491
- * Milliseconds until the next occurrence of `hour`:00 local time.
492
- * Falls back to the plain interval when no anchor hour is configured.
592
+ * Insert an observation, skipping duplicates within a 30-second window.
593
+ * Returns the inserted row's id, or null if the insert was suppressed.
493
594
  */
494
- function msUntilNextAnchor(hour, intervalMs) {
495
- if (hour === void 0 || !Number.isInteger(hour) || hour < 0 || hour > 23) return intervalMs;
496
- const now = /* @__PURE__ */ new Date();
497
- const next = new Date(now);
498
- next.setHours(hour, 0, 0, 0);
499
- if (next <= now) next.setDate(next.getDate() + 1);
500
- return next.getTime() - now.getTime();
595
+ async function storeObservation(pool, obs) {
596
+ await ensureObservationTables(pool);
597
+ const hash = computeContentHash(obs.session_id, obs.tool_name, obs.title);
598
+ const dupCheck = await pool.query(`SELECT id FROM pai_observations
599
+ WHERE content_hash = $1
600
+ AND session_id = $2
601
+ AND created_at >= NOW() - INTERVAL '30 seconds'
602
+ LIMIT 1`, [hash, obs.session_id]);
603
+ if (dupCheck.rowCount && dupCheck.rowCount > 0) return null;
604
+ return (await pool.query(`INSERT INTO pai_observations
605
+ (session_id, project_id, project_slug, type, title, narrative,
606
+ tool_name, tool_input_summary, files_read, files_modified, concepts, content_hash)
607
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb, $12)
608
+ RETURNING id`, [
609
+ obs.session_id,
610
+ obs.project_id ?? null,
611
+ obs.project_slug ?? null,
612
+ obs.type,
613
+ obs.title,
614
+ obs.narrative ?? null,
615
+ obs.tool_name,
616
+ obs.tool_input_summary ?? null,
617
+ JSON.stringify(obs.files_read),
618
+ JSON.stringify(obs.files_modified),
619
+ JSON.stringify(obs.concepts),
620
+ hash
621
+ ])).rows[0]?.id ?? null;
501
622
  }
502
623
  /**
503
- * Run an embedding pass for all unembedded chunks (Postgres backend only).
624
+ * Attribute an observation to the active project covering `cwd`, then store
625
+ * it. One shared sequence for every daemon-side writer (IPC handler, cache
626
+ * keepalive) so the lookup query exists exactly once.
504
627
  */
505
- async function runEmbed() {
506
- if (embedInProgress) {
507
- process.stderr.write("[pai-daemon] Embed already in progress, skipping.\n");
508
- return;
509
- }
510
- if (indexInProgress) {
511
- process.stderr.write("[pai-daemon] Index in progress, deferring embed pass.\n");
512
- return;
628
+ async function storeObservationWithProject(db, pool, obs) {
629
+ let project_id = null;
630
+ let project_slug = null;
631
+ if (obs.cwd) {
632
+ const row = db.prepare("SELECT id, slug FROM projects WHERE status = 'active' AND ? LIKE root_path || '%' ORDER BY length(root_path) DESC LIMIT 1").get(obs.cwd);
633
+ if (row) {
634
+ project_id = row.id;
635
+ project_slug = row.slug;
636
+ }
513
637
  }
514
- if (storageBackend.backendType !== "postgres") return;
515
- setEmbedInProgress(true);
516
- const t0 = Date.now();
517
- try {
518
- process.stderr.write("[pai-daemon] Starting scheduled embed pass...\n");
519
- const projectNames = /* @__PURE__ */ new Map();
520
- try {
521
- const rows = registryDb.prepare("SELECT id, slug FROM projects WHERE status = 'active'").all();
522
- for (const r of rows) projectNames.set(r.id, r.slug);
523
- } catch {}
524
- const { embedChunksWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
525
- const count = await embedChunksWithBackend(storageBackend, () => shutdownRequested, projectNames, { maxMillis: 24e4 });
526
- let vaultEmbedCount = 0;
527
- if (daemonConfig.vaultPath) try {
528
- const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
529
- const { openFederation } = await import("./db-O-cyAPfS.mjs");
530
- const federationDb = openFederation();
531
- const vaultSqliteBackend = new SQLiteBackend(federationDb);
532
- const vaultProjectNames = new Map(projectNames);
533
- if (!vaultProjectNames.has(999)) vaultProjectNames.set(999, "obsidian-vault");
534
- vaultEmbedCount = await embedChunksWithBackend(vaultSqliteBackend, () => shutdownRequested, vaultProjectNames, { maxMillis: 45e3 });
535
- try {
536
- federationDb.close();
537
- } catch {}
538
- if (vaultEmbedCount > 0) process.stderr.write(`[pai-daemon] Vault embed pass complete: ${vaultEmbedCount} vault chunks embedded\n`);
539
- } catch (ve) {
540
- const vmsg = ve instanceof Error ? ve.message : String(ve);
541
- process.stderr.write(`[pai-daemon] Vault embed error: ${vmsg}\n`);
542
- }
543
- const elapsed = Date.now() - t0;
544
- setLastEmbedTime(Date.now());
545
- process.stderr.write(`[pai-daemon] Embed pass complete: ${count} postgres chunks + ${vaultEmbedCount} vault chunks embedded (${elapsed}ms)\n`);
546
- } catch (e) {
547
- const msg = e instanceof Error ? e.message : String(e);
548
- process.stderr.write(`[pai-daemon] Embed error: ${msg}\n`);
549
- } finally {
550
- setEmbedInProgress(false);
551
- }
552
- }
553
- /**
554
- * Start the periodic embed scheduler. Initial run is 60 seconds after startup.
555
- */
556
- function startEmbedScheduler() {
557
- const intervalMs = daemonConfig.embedIntervalSecs * 1e3;
558
- process.stderr.write(`[pai-daemon] Embed scheduler: every ${daemonConfig.embedIntervalSecs}s\n`);
559
- if (daemonConfig.embedOnStartup) setTimeout(() => {
560
- runEmbed().catch((e) => {
561
- process.stderr.write(`[pai-daemon] Startup embed error: ${e}\n`);
562
- });
563
- }, 6e4);
564
- else process.stderr.write("[pai-daemon] Startup embed pass skipped (embedOnStartup=false).\n");
565
- if (daemonConfig.maintenanceHour !== void 0) {
566
- process.stderr.write("[pai-daemon] Standalone embed timer not armed (maintenanceHour is set; embed runs at the end of each anchored index cycle).\n");
567
- return;
568
- }
569
- const timer = setInterval(() => {
570
- runEmbed().catch((e) => {
571
- process.stderr.write(`[pai-daemon] Scheduled embed error: ${e}\n`);
572
- });
573
- }, intervalMs);
574
- if (timer.unref) timer.unref();
575
- setEmbedSchedulerTimer(timer);
576
- }
577
- const REGISTRY_SCAN_INTERVAL_MS = 1800 * 1e3;
578
- const REGISTRY_SCAN_STARTUP_DELAY_MS = 1e4;
579
- /**
580
- * Start the periodic registry scan scheduler.
581
- * First scan runs 10 seconds after startup (enough time for IPC to stabilise).
582
- * Subsequent scans run every 30 minutes.
583
- * Uses the work-queue so the scan runs on the worker thread without blocking IPC.
584
- */
585
- function startRegistryScanScheduler() {
586
- process.stderr.write("[pai-daemon] Registry scan scheduler: every 30min (first in 10s)\n");
587
- setTimeout(() => {
588
- import("./work-queue-worker-D1p2V-ae.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
589
- process.stderr.write(`[pai-daemon] Startup registry scan error: ${e}\n`);
590
- });
591
- }, REGISTRY_SCAN_STARTUP_DELAY_MS);
592
- const timer = setInterval(() => {
593
- import("./work-queue-worker-D1p2V-ae.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
594
- process.stderr.write(`[pai-daemon] Scheduled registry scan error: ${e}\n`);
595
- });
596
- }, REGISTRY_SCAN_INTERVAL_MS);
597
- if (timer.unref) timer.unref();
598
- }
599
- /** How often the daemon looks at the worker ledger for supervision events. */
600
- const SUPERVISION_TICK_MS = 3e4;
601
- /** First supervision pass waits for the first statuses to exist at all. */
602
- const SUPERVISION_STARTUP_DELAY_MS = 15e3;
603
- /**
604
- * Start the worker supervisor: one tick every 30 s over the workers ledger
605
- * (src/workers/supervision.ts) that pushes finished/failed/stalled events to
606
- * the owning session — the daemon-side replacement for orchestrator polling.
607
- * Daemon code only: no model call has any business being here, and none is
608
- * made. Disabled workers disable the supervisor with it.
609
- */
610
- function startWorkerSupervisor() {
611
- let enabled = false;
612
- try {
613
- enabled = readWorkersSection().workers.enabled;
614
- } catch (e) {
615
- process.stderr.write(`[pai-daemon] Worker supervisor: not started (${e instanceof Error ? e.message : String(e)})\n`);
616
- return;
617
- }
618
- if (!enabled) {
619
- process.stderr.write("[pai-daemon] Worker supervisor: disabled (workers are off)\n");
620
- return;
621
- }
622
- const tick = () => {
623
- runSupervisionTick(workersLogDir(readWorkersSection().workers), { stallMs: stallMinutesFromEnv() * 6e4 }).then((r) => {
624
- for (const ev of r.events) process.stderr.write(`[pai-daemon] Supervision: ${ev.text}\n`);
625
- }).catch((e) => {
626
- process.stderr.write(`[pai-daemon] Supervision tick error: ${e instanceof Error ? e.message : String(e)}\n`);
627
- });
628
- };
629
- const first = setTimeout(tick, SUPERVISION_STARTUP_DELAY_MS);
630
- if (first.unref) first.unref();
631
- const timer = setInterval(tick, SUPERVISION_TICK_MS);
632
- if (timer.unref) timer.unref();
633
- process.stderr.write(`[pai-daemon] Worker supervisor: every ${Math.round(SUPERVISION_TICK_MS / 1e3)}s\n`);
634
- }
635
-
636
- //#endregion
637
- //#region src/observations/store.ts
638
- /**
639
- * store.ts — PostgreSQL persistence for PAI observations.
640
- *
641
- * All functions accept a pg.Pool and are safe to call concurrently.
642
- * Schema is initialized lazily via ensureObservationTables().
643
- *
644
- * Content-hash deduplication: observations with the same hash
645
- * created within a 30-second window are silently dropped to prevent
646
- * duplicate entries from rapid repeated tool calls.
647
- */
648
- let _tablesEnsured = false;
649
- /**
650
- * Inlined schema DDL — avoids runtime file reads that break in bundled code
651
- * (the bundler puts this in a shared chunk whose __dirname differs from src/).
652
- */
653
- const SCHEMA_SQL = `
654
- CREATE TABLE IF NOT EXISTS pai_observations (
655
- id SERIAL PRIMARY KEY,
656
- session_id TEXT NOT NULL,
657
- project_id INTEGER,
658
- project_slug TEXT,
659
- type TEXT NOT NULL CHECK (type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
660
- title TEXT NOT NULL,
661
- narrative TEXT,
662
- tool_name TEXT,
663
- tool_input_summary TEXT,
664
- files_read JSONB DEFAULT '[]'::jsonb,
665
- files_modified JSONB DEFAULT '[]'::jsonb,
666
- concepts JSONB DEFAULT '[]'::jsonb,
667
- content_hash TEXT,
668
- created_at TIMESTAMPTZ DEFAULT NOW()
669
- );
670
-
671
- CREATE INDEX IF NOT EXISTS idx_obs_project ON pai_observations(project_id);
672
- CREATE INDEX IF NOT EXISTS idx_obs_session ON pai_observations(session_id);
673
- CREATE INDEX IF NOT EXISTS idx_obs_type ON pai_observations(type);
674
- CREATE INDEX IF NOT EXISTS idx_obs_created ON pai_observations(created_at DESC);
675
- CREATE INDEX IF NOT EXISTS idx_obs_hash ON pai_observations(content_hash);
676
-
677
- CREATE TABLE IF NOT EXISTS pai_session_summaries (
678
- id SERIAL PRIMARY KEY,
679
- session_id TEXT NOT NULL UNIQUE,
680
- project_id INTEGER,
681
- project_slug TEXT,
682
- request TEXT,
683
- investigated TEXT,
684
- learned TEXT,
685
- completed TEXT,
686
- next_steps TEXT,
687
- observation_count INTEGER DEFAULT 0,
688
- created_at TIMESTAMPTZ DEFAULT NOW()
689
- );
690
-
691
- CREATE INDEX IF NOT EXISTS idx_ss_project ON pai_session_summaries(project_id);
692
- CREATE INDEX IF NOT EXISTS idx_ss_session ON pai_session_summaries(session_id);
693
- `;
694
- /**
695
- * Run schema DDL idempotently against the given pool.
696
- * Uses a module-level flag so subsequent calls are no-ops within the same
697
- * process lifetime (the SQL itself uses IF NOT EXISTS so it is safe to re-run).
698
- */
699
- async function ensureObservationTables(pool) {
700
- if (_tablesEnsured) return;
701
- await pool.query(SCHEMA_SQL);
702
- _tablesEnsured = true;
703
- }
704
- /**
705
- * Compute a 16-character hex content hash for deduplication.
706
- * Hash = SHA256(session_id + tool_name + title).slice(0, 16)
707
- */
708
- function computeContentHash(sessionId, toolName, title) {
709
- return sha256(sessionId + "\0" + toolName + "\0" + title).slice(0, 16);
710
- }
711
- /**
712
- * Insert an observation, skipping duplicates within a 30-second window.
713
- * Returns the inserted row's id, or null if the insert was suppressed.
714
- */
715
- async function storeObservation(pool, obs) {
716
- await ensureObservationTables(pool);
717
- const hash = computeContentHash(obs.session_id, obs.tool_name, obs.title);
718
- const dupCheck = await pool.query(`SELECT id FROM pai_observations
719
- WHERE content_hash = $1
720
- AND session_id = $2
721
- AND created_at >= NOW() - INTERVAL '30 seconds'
722
- LIMIT 1`, [hash, obs.session_id]);
723
- if (dupCheck.rowCount && dupCheck.rowCount > 0) return null;
724
- return (await pool.query(`INSERT INTO pai_observations
725
- (session_id, project_id, project_slug, type, title, narrative,
726
- tool_name, tool_input_summary, files_read, files_modified, concepts, content_hash)
727
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb, $12)
728
- RETURNING id`, [
729
- obs.session_id,
730
- obs.project_id ?? null,
731
- obs.project_slug ?? null,
732
- obs.type,
733
- obs.title,
734
- obs.narrative ?? null,
735
- obs.tool_name,
736
- obs.tool_input_summary ?? null,
737
- JSON.stringify(obs.files_read),
738
- JSON.stringify(obs.files_modified),
739
- JSON.stringify(obs.concepts),
740
- hash
741
- ])).rows[0]?.id ?? null;
638
+ return storeObservation(pool, {
639
+ ...obs,
640
+ project_id,
641
+ project_slug
642
+ });
742
643
  }
743
644
  /**
744
645
  * Filtered query for observations with optional projectId, sessionId, type,
@@ -903,6 +804,335 @@ async function querySkillTelemetry(pool, opts = {}) {
903
804
  LIMIT $${idx}`, params)).rows;
904
805
  }
905
806
 
807
+ //#endregion
808
+ //#region src/daemon/daemon/scheduler.ts
809
+ /**
810
+ * Index, embed, vault index, and registry scan schedulers for the PAI daemon.
811
+ * Exports run* functions (also called on-demand by the IPC handler)
812
+ * and the start* functions invoked once at daemon startup.
813
+ */
814
+ /** Minimum interval between vault index runs (30 minutes). */
815
+ const VAULT_INDEX_MIN_INTERVAL_MS = 1800 * 1e3;
816
+ /**
817
+ * Run a full index pass. Guards against overlapping runs with indexInProgress.
818
+ * Called both by the scheduler and by the index_now IPC method.
819
+ */
820
+ async function runIndex() {
821
+ if (indexInProgress) {
822
+ process.stderr.write("[pai-daemon] Index already in progress, skipping.\n");
823
+ return;
824
+ }
825
+ if (embedInProgress) {
826
+ process.stderr.write("[pai-daemon] Embed in progress, deferring index run.\n");
827
+ return;
828
+ }
829
+ setIndexInProgress(true);
830
+ const t0 = Date.now();
831
+ try {
832
+ process.stderr.write("[pai-daemon] Starting scheduled index run...\n");
833
+ if (storageBackend.backendType === "sqlite") {
834
+ const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
835
+ if (storageBackend instanceof SQLiteBackend) {
836
+ const { projects, result } = await indexAll(storageBackend.getRawDb(), registryDb);
837
+ const elapsed = Date.now() - t0;
838
+ setLastIndexTime(Date.now());
839
+ process.stderr.write(`[pai-daemon] Index complete: ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
840
+ }
841
+ } else {
842
+ const { indexAllWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
843
+ const { projects, result } = await indexAllWithBackend(storageBackend, registryDb);
844
+ const elapsed = Date.now() - t0;
845
+ setLastIndexTime(Date.now());
846
+ process.stderr.write(`[pai-daemon] Index complete (postgres): ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
847
+ }
848
+ } catch (e) {
849
+ const msg = e instanceof Error ? e.message : String(e);
850
+ process.stderr.write(`[pai-daemon] Index error: ${msg}\n`);
851
+ } finally {
852
+ setIndexInProgress(false);
853
+ }
854
+ }
855
+ /**
856
+ * Run a vault index pass. Guards against overlapping runs with vaultIndexInProgress.
857
+ * Skips if no vaultPath is configured, or if project index/embed is in progress.
858
+ */
859
+ async function runVaultIndex() {
860
+ if (!daemonConfig.vaultPath) return;
861
+ if (vaultIndexInProgress) {
862
+ process.stderr.write("[pai-daemon] Vault index already in progress, skipping.\n");
863
+ return;
864
+ }
865
+ if (indexInProgress || embedInProgress) {
866
+ process.stderr.write("[pai-daemon] Index/embed in progress, deferring vault index.\n");
867
+ return;
868
+ }
869
+ const { lastVaultIndexTime } = await import("./state-CY6I-Hnc.mjs");
870
+ if (lastVaultIndexTime > 0 && Date.now() - lastVaultIndexTime < VAULT_INDEX_MIN_INTERVAL_MS) return;
871
+ let vaultProjectId = daemonConfig.vaultProjectId;
872
+ if (!vaultProjectId) {
873
+ const row = registryDb.prepare("SELECT id FROM projects WHERE root_path = ?").get(daemonConfig.vaultPath);
874
+ vaultProjectId = row?.id ?? 999;
875
+ if (!row) process.stderr.write("[pai-daemon] Vault not in project registry — using synthetic project ID 999.\n");
876
+ }
877
+ setVaultIndexInProgress(true);
878
+ const t0 = Date.now();
879
+ process.stderr.write("[pai-daemon] Starting vault index run...\n");
880
+ try {
881
+ const { indexVault } = await import("./vault-indexer-mata9S7b.mjs");
882
+ const r = await indexVault(storageBackend, vaultProjectId, daemonConfig.vaultPath);
883
+ const elapsed = Date.now() - t0;
884
+ setLastVaultIndexTime(Date.now());
885
+ process.stderr.write(`[pai-daemon] Vault index complete: ${r.filesIndexed} files, ${r.linksExtracted} links, ${r.deadLinksFound} dead, ${r.orphansFound} orphans (${elapsed}ms)\n`);
886
+ } catch (e) {
887
+ const msg = e instanceof Error ? e.message : String(e);
888
+ process.stderr.write(`[pai-daemon] Vault index error: ${msg}\n`);
889
+ } finally {
890
+ setVaultIndexInProgress(false);
891
+ }
892
+ }
893
+ /**
894
+ * Start the periodic index scheduler. Runs an initial pass 2 seconds after startup.
895
+ */
896
+ function startIndexScheduler() {
897
+ const intervalMs = daemonConfig.indexIntervalSecs * 1e3;
898
+ process.stderr.write(`[pai-daemon] Index scheduler: every ${daemonConfig.indexIntervalSecs}s\n`);
899
+ const cycle = (label, withEmbed) => runIndex().then(() => runVaultIndex()).then(() => withEmbed ? runEmbed() : void 0).catch((e) => {
900
+ process.stderr.write(`[pai-daemon] ${label} index error: ${e}\n`);
901
+ });
902
+ if (daemonConfig.maintenanceHour === void 0) setTimeout(() => void cycle("Startup", daemonConfig.embedOnStartup), 2e3);
903
+ else process.stderr.write("[pai-daemon] Startup index pass skipped (maintenanceHour is set).\n");
904
+ const firstDelayMs = msUntilNextAnchor(daemonConfig.maintenanceHour, intervalMs);
905
+ if (daemonConfig.maintenanceHour !== void 0) process.stderr.write(`[pai-daemon] Index scheduler anchored to ${String(daemonConfig.maintenanceHour).padStart(2, "0")}:00 local (next pass in ${Math.round(firstDelayMs / 6e4)} min)\n`);
906
+ let timer;
907
+ const startTimer = () => {
908
+ timer = setInterval(() => void cycle("Scheduled", true), intervalMs);
909
+ if (timer.unref) timer.unref();
910
+ setIndexSchedulerTimer(timer);
911
+ };
912
+ const first = setTimeout(() => {
913
+ cycle("Scheduled", true);
914
+ startTimer();
915
+ }, firstDelayMs);
916
+ if (first.unref) first.unref();
917
+ }
918
+ /**
919
+ * Milliseconds until the next occurrence of `hour`:00 local time.
920
+ * Falls back to the plain interval when no anchor hour is configured.
921
+ */
922
+ function msUntilNextAnchor(hour, intervalMs) {
923
+ if (hour === void 0 || !Number.isInteger(hour) || hour < 0 || hour > 23) return intervalMs;
924
+ const now = /* @__PURE__ */ new Date();
925
+ const next = new Date(now);
926
+ next.setHours(hour, 0, 0, 0);
927
+ if (next <= now) next.setDate(next.getDate() + 1);
928
+ return next.getTime() - now.getTime();
929
+ }
930
+ /**
931
+ * Run an embedding pass for all unembedded chunks (Postgres backend only).
932
+ */
933
+ async function runEmbed() {
934
+ if (embedInProgress) {
935
+ process.stderr.write("[pai-daemon] Embed already in progress, skipping.\n");
936
+ return;
937
+ }
938
+ if (indexInProgress) {
939
+ process.stderr.write("[pai-daemon] Index in progress, deferring embed pass.\n");
940
+ return;
941
+ }
942
+ if (storageBackend.backendType !== "postgres") return;
943
+ setEmbedInProgress(true);
944
+ const t0 = Date.now();
945
+ try {
946
+ process.stderr.write("[pai-daemon] Starting scheduled embed pass...\n");
947
+ const projectNames = /* @__PURE__ */ new Map();
948
+ try {
949
+ const rows = registryDb.prepare("SELECT id, slug FROM projects WHERE status = 'active'").all();
950
+ for (const r of rows) projectNames.set(r.id, r.slug);
951
+ } catch {}
952
+ const { embedChunksWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
953
+ const count = await embedChunksWithBackend(storageBackend, () => shutdownRequested, projectNames, { maxMillis: 24e4 });
954
+ let vaultEmbedCount = 0;
955
+ if (daemonConfig.vaultPath) try {
956
+ const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
957
+ const { openFederation } = await import("./db-O-cyAPfS.mjs");
958
+ const federationDb = openFederation();
959
+ const vaultSqliteBackend = new SQLiteBackend(federationDb);
960
+ const vaultProjectNames = new Map(projectNames);
961
+ if (!vaultProjectNames.has(999)) vaultProjectNames.set(999, "obsidian-vault");
962
+ vaultEmbedCount = await embedChunksWithBackend(vaultSqliteBackend, () => shutdownRequested, vaultProjectNames, { maxMillis: 45e3 });
963
+ try {
964
+ federationDb.close();
965
+ } catch {}
966
+ if (vaultEmbedCount > 0) process.stderr.write(`[pai-daemon] Vault embed pass complete: ${vaultEmbedCount} vault chunks embedded\n`);
967
+ } catch (ve) {
968
+ const vmsg = ve instanceof Error ? ve.message : String(ve);
969
+ process.stderr.write(`[pai-daemon] Vault embed error: ${vmsg}\n`);
970
+ }
971
+ const elapsed = Date.now() - t0;
972
+ setLastEmbedTime(Date.now());
973
+ process.stderr.write(`[pai-daemon] Embed pass complete: ${count} postgres chunks + ${vaultEmbedCount} vault chunks embedded (${elapsed}ms)\n`);
974
+ } catch (e) {
975
+ const msg = e instanceof Error ? e.message : String(e);
976
+ process.stderr.write(`[pai-daemon] Embed error: ${msg}\n`);
977
+ } finally {
978
+ setEmbedInProgress(false);
979
+ }
980
+ }
981
+ /**
982
+ * Start the periodic embed scheduler. Initial run is 60 seconds after startup.
983
+ */
984
+ function startEmbedScheduler() {
985
+ const intervalMs = daemonConfig.embedIntervalSecs * 1e3;
986
+ process.stderr.write(`[pai-daemon] Embed scheduler: every ${daemonConfig.embedIntervalSecs}s\n`);
987
+ if (daemonConfig.embedOnStartup) setTimeout(() => {
988
+ runEmbed().catch((e) => {
989
+ process.stderr.write(`[pai-daemon] Startup embed error: ${e}\n`);
990
+ });
991
+ }, 6e4);
992
+ else process.stderr.write("[pai-daemon] Startup embed pass skipped (embedOnStartup=false).\n");
993
+ if (daemonConfig.maintenanceHour !== void 0) {
994
+ process.stderr.write("[pai-daemon] Standalone embed timer not armed (maintenanceHour is set; embed runs at the end of each anchored index cycle).\n");
995
+ return;
996
+ }
997
+ const timer = setInterval(() => {
998
+ runEmbed().catch((e) => {
999
+ process.stderr.write(`[pai-daemon] Scheduled embed error: ${e}\n`);
1000
+ });
1001
+ }, intervalMs);
1002
+ if (timer.unref) timer.unref();
1003
+ setEmbedSchedulerTimer(timer);
1004
+ }
1005
+ const REGISTRY_SCAN_INTERVAL_MS = 1800 * 1e3;
1006
+ const REGISTRY_SCAN_STARTUP_DELAY_MS = 1e4;
1007
+ /**
1008
+ * Start the periodic registry scan scheduler.
1009
+ * First scan runs 10 seconds after startup (enough time for IPC to stabilise).
1010
+ * Subsequent scans run every 30 minutes.
1011
+ * Uses the work-queue so the scan runs on the worker thread without blocking IPC.
1012
+ */
1013
+ function startRegistryScanScheduler() {
1014
+ process.stderr.write("[pai-daemon] Registry scan scheduler: every 30min (first in 10s)\n");
1015
+ setTimeout(() => {
1016
+ import("./work-queue-worker-AEmfVE0F.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
1017
+ process.stderr.write(`[pai-daemon] Startup registry scan error: ${e}\n`);
1018
+ });
1019
+ }, REGISTRY_SCAN_STARTUP_DELAY_MS);
1020
+ const timer = setInterval(() => {
1021
+ import("./work-queue-worker-AEmfVE0F.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
1022
+ process.stderr.write(`[pai-daemon] Scheduled registry scan error: ${e}\n`);
1023
+ });
1024
+ }, REGISTRY_SCAN_INTERVAL_MS);
1025
+ if (timer.unref) timer.unref();
1026
+ }
1027
+ /** How often the daemon looks at the worker ledger for supervision events. */
1028
+ const SUPERVISION_TICK_MS = 3e4;
1029
+ /** First supervision pass waits for the first statuses to exist at all. */
1030
+ const SUPERVISION_STARTUP_DELAY_MS = 15e3;
1031
+ /**
1032
+ * Start the worker supervisor: one tick every 30 s over the workers ledger
1033
+ * (src/workers/supervision.ts) that pushes finished/failed/stalled events to
1034
+ * the owning session — the daemon-side replacement for orchestrator polling.
1035
+ * Daemon code only: no model call has any business being here, and none is
1036
+ * made. Disabled workers disable the supervisor with it.
1037
+ */
1038
+ function startWorkerSupervisor() {
1039
+ let enabled = false;
1040
+ try {
1041
+ enabled = readWorkersSection().workers.enabled;
1042
+ } catch (e) {
1043
+ process.stderr.write(`[pai-daemon] Worker supervisor: not started (${e instanceof Error ? e.message : String(e)})\n`);
1044
+ return;
1045
+ }
1046
+ if (!enabled) {
1047
+ process.stderr.write("[pai-daemon] Worker supervisor: disabled (workers are off)\n");
1048
+ return;
1049
+ }
1050
+ const tick = () => {
1051
+ runSupervisionTick(workersLogDir(readWorkersSection().workers), { stallMs: stallMinutesFromEnv() * 6e4 }).then((r) => {
1052
+ for (const ev of r.events) process.stderr.write(`[pai-daemon] Supervision: ${ev.text}\n`);
1053
+ }).catch((e) => {
1054
+ process.stderr.write(`[pai-daemon] Supervision tick error: ${e instanceof Error ? e.message : String(e)}\n`);
1055
+ });
1056
+ };
1057
+ const first = setTimeout(tick, SUPERVISION_STARTUP_DELAY_MS);
1058
+ if (first.unref) first.unref();
1059
+ const timer = setInterval(tick, SUPERVISION_TICK_MS);
1060
+ if (timer.unref) timer.unref();
1061
+ process.stderr.write(`[pai-daemon] Worker supervisor: every ${Math.round(SUPERVISION_TICK_MS / 1e3)}s\n`);
1062
+ }
1063
+ /** First beat waits a while after boot; the daemon start is itself a warm request. */
1064
+ const KEEPALIVE_STARTUP_DELAY_MS = 6e4;
1065
+ /**
1066
+ * Start the cache keepalive: every workers.cacheKeepaliveSecs one trivial
1067
+ * single-turn worker (src/workers/keepalive.ts) re-arms the provider's
1068
+ * implicit prompt cache so real worker spawns start warm. Needs no storage
1069
+ * backend — it starts with the IPC server, not after the federation connect.
1070
+ * 0 (or workers off) keeps it off. A failed beat is logged and swallowed.
1071
+ */
1072
+ function startCacheKeepalive(opts = {}) {
1073
+ let workers;
1074
+ try {
1075
+ workers = readWorkersSection(opts.configPath).workers;
1076
+ } catch (e) {
1077
+ process.stderr.write(`[pai-daemon] Cache keepalive: not started (${e instanceof Error ? e.message : String(e)})\n`);
1078
+ return;
1079
+ }
1080
+ if (!workers.enabled) {
1081
+ process.stderr.write("[pai-daemon] Cache keepalive: disabled (workers are off)\n");
1082
+ return;
1083
+ }
1084
+ const secs = keepaliveSecs(workers);
1085
+ if (!secs) {
1086
+ process.stderr.write("[pai-daemon] Cache keepalive: disabled (cacheKeepaliveSecs is 0)\n");
1087
+ return;
1088
+ }
1089
+ let notedNoPostgres = false;
1090
+ const sqliteNote = () => {
1091
+ const pool = storageBackend?.getPool?.();
1092
+ if (pool) return pool;
1093
+ if (!notedNoPostgres) {
1094
+ notedNoPostgres = true;
1095
+ process.stderr.write("[pai-daemon] Cache keepalive: observation rows need Postgres — ledger line only\n");
1096
+ }
1097
+ return null;
1098
+ };
1099
+ const beat = async () => {
1100
+ const m = await (opts.beat ?? runKeepaliveBeat)();
1101
+ if (!m) return;
1102
+ const pool = sqliteNote();
1103
+ if (!pool) return;
1104
+ try {
1105
+ await storeObservationWithProject(registryDb, pool, {
1106
+ session_id: m.id,
1107
+ type: "change",
1108
+ title: `cache keepalive ${m.ok ? "ok" : "failed"} (cache_read=${m.cache_read_input_tokens}, api_ms=${m.duration_api_ms})`,
1109
+ tool_name: "cache-keepalive",
1110
+ narrative: `beat ${m.id}: input=${m.input_tokens} cache_read=${m.cache_read_input_tokens} cache_creation=${m.cache_creation_input_tokens} duration_api_ms=${m.duration_api_ms}`,
1111
+ tool_input_summary: void 0,
1112
+ files_read: [],
1113
+ files_modified: [],
1114
+ concepts: []
1115
+ });
1116
+ } catch (e) {
1117
+ process.stderr.write(`[pai-daemon] Cache keepalive observation error: ${e instanceof Error ? e.message : String(e)}\n`);
1118
+ }
1119
+ };
1120
+ const first = setTimeout(() => {
1121
+ beat().catch((e) => {
1122
+ process.stderr.write(`[pai-daemon] Cache keepalive beat error: ${e instanceof Error ? e.message : String(e)}\n`);
1123
+ });
1124
+ }, KEEPALIVE_STARTUP_DELAY_MS);
1125
+ if (first.unref) first.unref();
1126
+ const timer = setInterval(() => {
1127
+ beat().catch((e) => {
1128
+ process.stderr.write(`[pai-daemon] Cache keepalive beat error: ${e instanceof Error ? e.message : String(e)}\n`);
1129
+ });
1130
+ }, secs * 1e3);
1131
+ if (timer.unref) timer.unref();
1132
+ setCacheKeepaliveTimer(timer);
1133
+ process.stderr.write(`[pai-daemon] Cache keepalive: every ${secs}s\n`);
1134
+ }
1135
+
906
1136
  //#endregion
907
1137
  //#region src/daemon/daemon/dispatcher.ts
908
1138
  /**
@@ -1174,33 +1404,22 @@ async function handleRequest(request, socket) {
1174
1404
  }
1175
1405
  try {
1176
1406
  const p = params;
1177
- let project_id = null;
1178
- let project_slug = null;
1179
- if (p.cwd) {
1180
- const row = registryDb.prepare("SELECT id, slug FROM projects WHERE status = 'active' AND ? LIKE root_path || '%' ORDER BY length(root_path) DESC LIMIT 1").get(p.cwd);
1181
- if (row) {
1182
- project_id = row.id;
1183
- project_slug = row.slug;
1184
- }
1185
- }
1186
- await ensureObservationTables(pool);
1187
1407
  sendResponse(socket, {
1188
1408
  id,
1189
1409
  ok: true,
1190
1410
  result: {
1191
1411
  ok: true,
1192
- id: await storeObservation(pool, {
1412
+ id: await storeObservationWithProject(registryDb, pool, {
1193
1413
  session_id: p.session_id,
1194
- project_id,
1195
- project_slug,
1196
1414
  type: p.type,
1197
1415
  title: p.title,
1198
1416
  narrative: p.narrative ?? null,
1199
1417
  tool_name: p.tool_name,
1200
- tool_input_summary: p.tool_input_summary ?? null,
1418
+ tool_input_summary: p.tool_input_summary ?? void 0,
1201
1419
  files_read: p.files_read ?? [],
1202
1420
  files_modified: p.files_modified ?? [],
1203
- concepts: p.concepts ?? []
1421
+ concepts: p.concepts ?? [],
1422
+ cwd: p.cwd
1204
1423
  })
1205
1424
  }
1206
1425
  });
@@ -1641,7 +1860,7 @@ async function serve(config) {
1641
1860
  process.stderr.write("[pai-daemon] Starting daemon...\n");
1642
1861
  process.stderr.write(`[pai-daemon] Socket: ${config.socketPath}\n`);
1643
1862
  process.stderr.write(`[pai-daemon] Storage backend: ${config.storageBackend}\n`);
1644
- const { notificationConfig } = await import("./state-RXepvbnO.mjs");
1863
+ const { notificationConfig } = await import("./state-CY6I-Hnc.mjs");
1645
1864
  process.stderr.write(`[pai-daemon] Notification mode: ${notificationConfig.mode}\n`);
1646
1865
  try {
1647
1866
  setPriority(process.pid, 10);
@@ -1658,6 +1877,7 @@ async function serve(config) {
1658
1877
  const server = await startIpcServer(config.socketPath);
1659
1878
  startRegistryScanScheduler();
1660
1879
  startWorkerSupervisor();
1880
+ startCacheKeepalive();
1661
1881
  createStorageBackend(config, { waitForPostgres: true }).then((backend) => {
1662
1882
  setStorageBackend(backend);
1663
1883
  process.stderr.write(`[pai-daemon] Federation backend: ${backend.backendType}\n`);
@@ -1676,6 +1896,7 @@ async function serve(config) {
1676
1896
  setShutdownRequested(true);
1677
1897
  if (indexSchedulerTimer) clearInterval(indexSchedulerTimer);
1678
1898
  if (embedSchedulerTimer) clearInterval(embedSchedulerTimer);
1899
+ if (cacheKeepaliveTimer) clearInterval(cacheKeepaliveTimer);
1679
1900
  stopWorker();
1680
1901
  server.close();
1681
1902
  const SHUTDOWN_TIMEOUT_MS = 1e4;
@@ -1706,4 +1927,4 @@ async function serve(config) {
1706
1927
 
1707
1928
  //#endregion
1708
1929
  export { serve as t };
1709
- //# sourceMappingURL=daemon-DsvP8KMy.mjs.map
1930
+ //# sourceMappingURL=daemon-Dq8A1uDF.mjs.map