@tekmidian/pai 0.42.1 → 0.42.3

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 (68) 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-Bi_F6lXf.mjs → config-_lQI_W08.mjs} +87 -3
  7. package/dist/config-_lQI_W08.mjs.map +1 -0
  8. package/dist/{context-handover-cache--bm6Siim.mjs → context-handover-cache-pNFdBNrU.mjs} +3 -3
  9. package/dist/{context-handover-cache--bm6Siim.mjs.map → context-handover-cache-pNFdBNrU.mjs.map} +1 -1
  10. package/dist/daemon/index.mjs +7 -7
  11. package/dist/{daemon-BOAPyL_b.mjs → daemon-Awt_3UJp.mjs} +7 -7
  12. package/dist/{daemon-DvXih78C.mjs → daemon-Dq8A1uDF.mjs} +587 -368
  13. package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
  14. package/dist/daemon-mcp/index.mjs +5 -5
  15. package/dist/{fallback-8bzmg6No.mjs → fallback-vQPI1qxt.mjs} +14 -12
  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 +64 -1
  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-CqppP_Bw.mjs → main-resolver-BAfOMppW.mjs} +2 -2
  30. package/dist/{main-resolver-Dip2VLB5.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
  31. package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
  32. package/dist/{planner-ogy-Zy4O.mjs → planner-BfEGcEs1.mjs} +5 -5
  33. package/dist/{planner-ogy-Zy4O.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
  34. package/dist/{program-B7N5OIJR.mjs → program-BDmeZP-v.mjs} +17 -11
  35. package/dist/{program-B7N5OIJR.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
  36. package/dist/{chain-DsvSHnO9.mjs → run-CGXbhf2k.mjs} +359 -163
  37. package/dist/run-CGXbhf2k.mjs.map +1 -0
  38. package/dist/{server-DvN-IFIV.mjs → server-D4vAieF4.mjs} +25 -6
  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-19r0cpH4.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
  44. package/dist/{work-queue-worker-DXh_FnT5.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
  45. package/dist/{work-queue-worker-DXh_FnT5.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/provider-abstraction.md +52 -2
  49. package/package.json +1 -1
  50. package/plugins/core/hooks/hooks.json +12 -0
  51. package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
  52. package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
  53. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
  54. package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
  55. package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
  56. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
  57. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
  58. package/statusline-command.sh +149 -72
  59. package/dist/chain-DsvSHnO9.mjs.map +0 -1
  60. package/dist/config-Bi_F6lXf.mjs.map +0 -1
  61. package/dist/daemon-DvXih78C.mjs.map +0 -1
  62. package/dist/fallback-8bzmg6No.mjs.map +0 -1
  63. package/dist/main-resolver-Dip2VLB5.mjs.map +0 -1
  64. package/dist/scope-CfYnjy5R.mjs +0 -360
  65. package/dist/scope-CfYnjy5R.mjs.map +0 -1
  66. package/dist/server-DvN-IFIV.mjs.map +0 -1
  67. package/dist/state-BTa10SOM.mjs.map +0 -1
  68. 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 { h as readWorkersSection } from "./config-Bi_F6lXf.mjs";
9
- import { _ as workersLogDir } from "./server-DvN-IFIV.mjs";
10
- import { _ as loadStatuses, d as alive, h as isChatPane, n as itermForClaudeSession, y as nowStamp } from "./scope-CfYnjy5R.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-DXh_FnT5.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
  /**
@@ -379,368 +514,132 @@ async function runSupervisionTick(logDir, opts = {}) {
379
514
  }
380
515
 
381
516
  //#endregion
382
- //#region src/daemon/daemon/scheduler.ts
517
+ //#region src/observations/store.ts
383
518
  /**
384
- * Index, embed, vault index, and registry scan schedulers for the PAI daemon.
385
- * Exports run* functions (also called on-demand by the IPC handler)
386
- * 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.
387
527
  */
388
- /** Minimum interval between vault index runs (30 minutes). */
389
- const VAULT_INDEX_MIN_INTERVAL_MS = 1800 * 1e3;
528
+ let _tablesEnsured = false;
390
529
  /**
391
- * Run a full index pass. Guards against overlapping runs with indexInProgress.
392
- * 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/).
393
532
  */
394
- async function runIndex() {
395
- if (indexInProgress) {
396
- process.stderr.write("[pai-daemon] Index already in progress, skipping.\n");
397
- return;
398
- }
399
- if (embedInProgress) {
400
- process.stderr.write("[pai-daemon] Embed in progress, deferring index run.\n");
401
- return;
402
- }
403
- setIndexInProgress(true);
404
- const t0 = Date.now();
405
- try {
406
- process.stderr.write("[pai-daemon] Starting scheduled index run...\n");
407
- if (storageBackend.backendType === "sqlite") {
408
- const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
409
- if (storageBackend instanceof SQLiteBackend) {
410
- const { projects, result } = await indexAll(storageBackend.getRawDb(), registryDb);
411
- const elapsed = Date.now() - t0;
412
- setLastIndexTime(Date.now());
413
- process.stderr.write(`[pai-daemon] Index complete: ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
414
- }
415
- } else {
416
- const { indexAllWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
417
- const { projects, result } = await indexAllWithBackend(storageBackend, registryDb);
418
- const elapsed = Date.now() - t0;
419
- setLastIndexTime(Date.now());
420
- process.stderr.write(`[pai-daemon] Index complete (postgres): ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
421
- }
422
- } catch (e) {
423
- const msg = e instanceof Error ? e.message : String(e);
424
- process.stderr.write(`[pai-daemon] Index error: ${msg}\n`);
425
- } finally {
426
- setIndexInProgress(false);
427
- }
428
- }
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
+ `;
429
574
  /**
430
- * Run a vault index pass. Guards against overlapping runs with vaultIndexInProgress.
431
- * 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).
432
578
  */
433
- async function runVaultIndex() {
434
- if (!daemonConfig.vaultPath) return;
435
- if (vaultIndexInProgress) {
436
- process.stderr.write("[pai-daemon] Vault index already in progress, skipping.\n");
437
- return;
438
- }
439
- if (indexInProgress || embedInProgress) {
440
- process.stderr.write("[pai-daemon] Index/embed in progress, deferring vault index.\n");
441
- return;
442
- }
443
- const { lastVaultIndexTime } = await import("./state-RXepvbnO.mjs");
444
- if (lastVaultIndexTime > 0 && Date.now() - lastVaultIndexTime < VAULT_INDEX_MIN_INTERVAL_MS) return;
445
- let vaultProjectId = daemonConfig.vaultProjectId;
446
- if (!vaultProjectId) {
447
- const row = registryDb.prepare("SELECT id FROM projects WHERE root_path = ?").get(daemonConfig.vaultPath);
448
- vaultProjectId = row?.id ?? 999;
449
- if (!row) process.stderr.write("[pai-daemon] Vault not in project registry — using synthetic project ID 999.\n");
450
- }
451
- setVaultIndexInProgress(true);
452
- const t0 = Date.now();
453
- process.stderr.write("[pai-daemon] Starting vault index run...\n");
454
- try {
455
- const { indexVault } = await import("./vault-indexer-mata9S7b.mjs");
456
- const r = await indexVault(storageBackend, vaultProjectId, daemonConfig.vaultPath);
457
- const elapsed = Date.now() - t0;
458
- setLastVaultIndexTime(Date.now());
459
- process.stderr.write(`[pai-daemon] Vault index complete: ${r.filesIndexed} files, ${r.linksExtracted} links, ${r.deadLinksFound} dead, ${r.orphansFound} orphans (${elapsed}ms)\n`);
460
- } catch (e) {
461
- const msg = e instanceof Error ? e.message : String(e);
462
- process.stderr.write(`[pai-daemon] Vault index error: ${msg}\n`);
463
- } finally {
464
- setVaultIndexInProgress(false);
465
- }
579
+ async function ensureObservationTables(pool) {
580
+ if (_tablesEnsured) return;
581
+ await pool.query(SCHEMA_SQL);
582
+ _tablesEnsured = true;
466
583
  }
467
584
  /**
468
- * 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)
469
587
  */
470
- function startIndexScheduler() {
471
- const intervalMs = daemonConfig.indexIntervalSecs * 1e3;
472
- process.stderr.write(`[pai-daemon] Index scheduler: every ${daemonConfig.indexIntervalSecs}s\n`);
473
- const cycle = (label, withEmbed) => runIndex().then(() => runVaultIndex()).then(() => withEmbed ? runEmbed() : void 0).catch((e) => {
474
- process.stderr.write(`[pai-daemon] ${label} index error: ${e}\n`);
475
- });
476
- if (daemonConfig.maintenanceHour === void 0) setTimeout(() => void cycle("Startup", daemonConfig.embedOnStartup), 2e3);
477
- else process.stderr.write("[pai-daemon] Startup index pass skipped (maintenanceHour is set).\n");
478
- const firstDelayMs = msUntilNextAnchor(daemonConfig.maintenanceHour, intervalMs);
479
- 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`);
480
- let timer;
481
- const startTimer = () => {
482
- timer = setInterval(() => void cycle("Scheduled", true), intervalMs);
483
- if (timer.unref) timer.unref();
484
- setIndexSchedulerTimer(timer);
485
- };
486
- const first = setTimeout(() => {
487
- cycle("Scheduled", true);
488
- startTimer();
489
- }, firstDelayMs);
490
- if (first.unref) first.unref();
588
+ function computeContentHash(sessionId, toolName, title) {
589
+ return sha256(sessionId + "\0" + toolName + "\0" + title).slice(0, 16);
491
590
  }
492
591
  /**
493
- * Milliseconds until the next occurrence of `hour`:00 local time.
494
- * 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.
495
594
  */
496
- function msUntilNextAnchor(hour, intervalMs) {
497
- if (hour === void 0 || !Number.isInteger(hour) || hour < 0 || hour > 23) return intervalMs;
498
- const now = /* @__PURE__ */ new Date();
499
- const next = new Date(now);
500
- next.setHours(hour, 0, 0, 0);
501
- if (next <= now) next.setDate(next.getDate() + 1);
502
- 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;
503
622
  }
504
623
  /**
505
- * 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.
506
627
  */
507
- async function runEmbed() {
508
- if (embedInProgress) {
509
- process.stderr.write("[pai-daemon] Embed already in progress, skipping.\n");
510
- return;
511
- }
512
- if (indexInProgress) {
513
- process.stderr.write("[pai-daemon] Index in progress, deferring embed pass.\n");
514
- 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
+ }
515
637
  }
516
- if (storageBackend.backendType !== "postgres") return;
517
- setEmbedInProgress(true);
518
- const t0 = Date.now();
519
- try {
520
- process.stderr.write("[pai-daemon] Starting scheduled embed pass...\n");
521
- const projectNames = /* @__PURE__ */ new Map();
522
- try {
523
- const rows = registryDb.prepare("SELECT id, slug FROM projects WHERE status = 'active'").all();
524
- for (const r of rows) projectNames.set(r.id, r.slug);
525
- } catch {}
526
- const { embedChunksWithBackend } = await import("./indexer-backend-BPTzKFZU.mjs");
527
- const count = await embedChunksWithBackend(storageBackend, () => shutdownRequested, projectNames, { maxMillis: 24e4 });
528
- let vaultEmbedCount = 0;
529
- if (daemonConfig.vaultPath) try {
530
- const { SQLiteBackend } = await import("./sqlite-Cq3gJcq9.mjs");
531
- const { openFederation } = await import("./db-O-cyAPfS.mjs");
532
- const federationDb = openFederation();
533
- const vaultSqliteBackend = new SQLiteBackend(federationDb);
534
- const vaultProjectNames = new Map(projectNames);
535
- if (!vaultProjectNames.has(999)) vaultProjectNames.set(999, "obsidian-vault");
536
- vaultEmbedCount = await embedChunksWithBackend(vaultSqliteBackend, () => shutdownRequested, vaultProjectNames, { maxMillis: 45e3 });
537
- try {
538
- federationDb.close();
539
- } catch {}
540
- if (vaultEmbedCount > 0) process.stderr.write(`[pai-daemon] Vault embed pass complete: ${vaultEmbedCount} vault chunks embedded\n`);
541
- } catch (ve) {
542
- const vmsg = ve instanceof Error ? ve.message : String(ve);
543
- process.stderr.write(`[pai-daemon] Vault embed error: ${vmsg}\n`);
544
- }
545
- const elapsed = Date.now() - t0;
546
- setLastEmbedTime(Date.now());
547
- process.stderr.write(`[pai-daemon] Embed pass complete: ${count} postgres chunks + ${vaultEmbedCount} vault chunks embedded (${elapsed}ms)\n`);
548
- } catch (e) {
549
- const msg = e instanceof Error ? e.message : String(e);
550
- process.stderr.write(`[pai-daemon] Embed error: ${msg}\n`);
551
- } finally {
552
- setEmbedInProgress(false);
553
- }
554
- }
555
- /**
556
- * Start the periodic embed scheduler. Initial run is 60 seconds after startup.
557
- */
558
- function startEmbedScheduler() {
559
- const intervalMs = daemonConfig.embedIntervalSecs * 1e3;
560
- process.stderr.write(`[pai-daemon] Embed scheduler: every ${daemonConfig.embedIntervalSecs}s\n`);
561
- if (daemonConfig.embedOnStartup) setTimeout(() => {
562
- runEmbed().catch((e) => {
563
- process.stderr.write(`[pai-daemon] Startup embed error: ${e}\n`);
564
- });
565
- }, 6e4);
566
- else process.stderr.write("[pai-daemon] Startup embed pass skipped (embedOnStartup=false).\n");
567
- if (daemonConfig.maintenanceHour !== void 0) {
568
- process.stderr.write("[pai-daemon] Standalone embed timer not armed (maintenanceHour is set; embed runs at the end of each anchored index cycle).\n");
569
- return;
570
- }
571
- const timer = setInterval(() => {
572
- runEmbed().catch((e) => {
573
- process.stderr.write(`[pai-daemon] Scheduled embed error: ${e}\n`);
574
- });
575
- }, intervalMs);
576
- if (timer.unref) timer.unref();
577
- setEmbedSchedulerTimer(timer);
578
- }
579
- const REGISTRY_SCAN_INTERVAL_MS = 1800 * 1e3;
580
- const REGISTRY_SCAN_STARTUP_DELAY_MS = 1e4;
581
- /**
582
- * Start the periodic registry scan scheduler.
583
- * First scan runs 10 seconds after startup (enough time for IPC to stabilise).
584
- * Subsequent scans run every 30 minutes.
585
- * Uses the work-queue so the scan runs on the worker thread without blocking IPC.
586
- */
587
- function startRegistryScanScheduler() {
588
- process.stderr.write("[pai-daemon] Registry scan scheduler: every 30min (first in 10s)\n");
589
- setTimeout(() => {
590
- import("./work-queue-worker-19r0cpH4.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
591
- process.stderr.write(`[pai-daemon] Startup registry scan error: ${e}\n`);
592
- });
593
- }, REGISTRY_SCAN_STARTUP_DELAY_MS);
594
- const timer = setInterval(() => {
595
- import("./work-queue-worker-19r0cpH4.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
596
- process.stderr.write(`[pai-daemon] Scheduled registry scan error: ${e}\n`);
597
- });
598
- }, REGISTRY_SCAN_INTERVAL_MS);
599
- if (timer.unref) timer.unref();
600
- }
601
- /** How often the daemon looks at the worker ledger for supervision events. */
602
- const SUPERVISION_TICK_MS = 3e4;
603
- /** First supervision pass waits for the first statuses to exist at all. */
604
- const SUPERVISION_STARTUP_DELAY_MS = 15e3;
605
- /**
606
- * Start the worker supervisor: one tick every 30 s over the workers ledger
607
- * (src/workers/supervision.ts) that pushes finished/failed/stalled events to
608
- * the owning session — the daemon-side replacement for orchestrator polling.
609
- * Daemon code only: no model call has any business being here, and none is
610
- * made. Disabled workers disable the supervisor with it.
611
- */
612
- function startWorkerSupervisor() {
613
- let enabled = false;
614
- try {
615
- enabled = readWorkersSection().workers.enabled;
616
- } catch (e) {
617
- process.stderr.write(`[pai-daemon] Worker supervisor: not started (${e instanceof Error ? e.message : String(e)})\n`);
618
- return;
619
- }
620
- if (!enabled) {
621
- process.stderr.write("[pai-daemon] Worker supervisor: disabled (workers are off)\n");
622
- return;
623
- }
624
- const tick = () => {
625
- runSupervisionTick(workersLogDir(readWorkersSection().workers), { stallMs: stallMinutesFromEnv() * 6e4 }).then((r) => {
626
- for (const ev of r.events) process.stderr.write(`[pai-daemon] Supervision: ${ev.text}\n`);
627
- }).catch((e) => {
628
- process.stderr.write(`[pai-daemon] Supervision tick error: ${e instanceof Error ? e.message : String(e)}\n`);
629
- });
630
- };
631
- const first = setTimeout(tick, SUPERVISION_STARTUP_DELAY_MS);
632
- if (first.unref) first.unref();
633
- const timer = setInterval(tick, SUPERVISION_TICK_MS);
634
- if (timer.unref) timer.unref();
635
- process.stderr.write(`[pai-daemon] Worker supervisor: every ${Math.round(SUPERVISION_TICK_MS / 1e3)}s\n`);
636
- }
637
-
638
- //#endregion
639
- //#region src/observations/store.ts
640
- /**
641
- * store.ts — PostgreSQL persistence for PAI observations.
642
- *
643
- * All functions accept a pg.Pool and are safe to call concurrently.
644
- * Schema is initialized lazily via ensureObservationTables().
645
- *
646
- * Content-hash deduplication: observations with the same hash
647
- * created within a 30-second window are silently dropped to prevent
648
- * duplicate entries from rapid repeated tool calls.
649
- */
650
- let _tablesEnsured = false;
651
- /**
652
- * Inlined schema DDL — avoids runtime file reads that break in bundled code
653
- * (the bundler puts this in a shared chunk whose __dirname differs from src/).
654
- */
655
- const SCHEMA_SQL = `
656
- CREATE TABLE IF NOT EXISTS pai_observations (
657
- id SERIAL PRIMARY KEY,
658
- session_id TEXT NOT NULL,
659
- project_id INTEGER,
660
- project_slug TEXT,
661
- type TEXT NOT NULL CHECK (type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
662
- title TEXT NOT NULL,
663
- narrative TEXT,
664
- tool_name TEXT,
665
- tool_input_summary TEXT,
666
- files_read JSONB DEFAULT '[]'::jsonb,
667
- files_modified JSONB DEFAULT '[]'::jsonb,
668
- concepts JSONB DEFAULT '[]'::jsonb,
669
- content_hash TEXT,
670
- created_at TIMESTAMPTZ DEFAULT NOW()
671
- );
672
-
673
- CREATE INDEX IF NOT EXISTS idx_obs_project ON pai_observations(project_id);
674
- CREATE INDEX IF NOT EXISTS idx_obs_session ON pai_observations(session_id);
675
- CREATE INDEX IF NOT EXISTS idx_obs_type ON pai_observations(type);
676
- CREATE INDEX IF NOT EXISTS idx_obs_created ON pai_observations(created_at DESC);
677
- CREATE INDEX IF NOT EXISTS idx_obs_hash ON pai_observations(content_hash);
678
-
679
- CREATE TABLE IF NOT EXISTS pai_session_summaries (
680
- id SERIAL PRIMARY KEY,
681
- session_id TEXT NOT NULL UNIQUE,
682
- project_id INTEGER,
683
- project_slug TEXT,
684
- request TEXT,
685
- investigated TEXT,
686
- learned TEXT,
687
- completed TEXT,
688
- next_steps TEXT,
689
- observation_count INTEGER DEFAULT 0,
690
- created_at TIMESTAMPTZ DEFAULT NOW()
691
- );
692
-
693
- CREATE INDEX IF NOT EXISTS idx_ss_project ON pai_session_summaries(project_id);
694
- CREATE INDEX IF NOT EXISTS idx_ss_session ON pai_session_summaries(session_id);
695
- `;
696
- /**
697
- * Run schema DDL idempotently against the given pool.
698
- * Uses a module-level flag so subsequent calls are no-ops within the same
699
- * process lifetime (the SQL itself uses IF NOT EXISTS so it is safe to re-run).
700
- */
701
- async function ensureObservationTables(pool) {
702
- if (_tablesEnsured) return;
703
- await pool.query(SCHEMA_SQL);
704
- _tablesEnsured = true;
705
- }
706
- /**
707
- * Compute a 16-character hex content hash for deduplication.
708
- * Hash = SHA256(session_id + tool_name + title).slice(0, 16)
709
- */
710
- function computeContentHash(sessionId, toolName, title) {
711
- return sha256(sessionId + "\0" + toolName + "\0" + title).slice(0, 16);
712
- }
713
- /**
714
- * Insert an observation, skipping duplicates within a 30-second window.
715
- * Returns the inserted row's id, or null if the insert was suppressed.
716
- */
717
- async function storeObservation(pool, obs) {
718
- await ensureObservationTables(pool);
719
- const hash = computeContentHash(obs.session_id, obs.tool_name, obs.title);
720
- const dupCheck = await pool.query(`SELECT id FROM pai_observations
721
- WHERE content_hash = $1
722
- AND session_id = $2
723
- AND created_at >= NOW() - INTERVAL '30 seconds'
724
- LIMIT 1`, [hash, obs.session_id]);
725
- if (dupCheck.rowCount && dupCheck.rowCount > 0) return null;
726
- return (await pool.query(`INSERT INTO pai_observations
727
- (session_id, project_id, project_slug, type, title, narrative,
728
- tool_name, tool_input_summary, files_read, files_modified, concepts, content_hash)
729
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb, $12)
730
- RETURNING id`, [
731
- obs.session_id,
732
- obs.project_id ?? null,
733
- obs.project_slug ?? null,
734
- obs.type,
735
- obs.title,
736
- obs.narrative ?? null,
737
- obs.tool_name,
738
- obs.tool_input_summary ?? null,
739
- JSON.stringify(obs.files_read),
740
- JSON.stringify(obs.files_modified),
741
- JSON.stringify(obs.concepts),
742
- hash
743
- ])).rows[0]?.id ?? null;
638
+ return storeObservation(pool, {
639
+ ...obs,
640
+ project_id,
641
+ project_slug
642
+ });
744
643
  }
745
644
  /**
746
645
  * Filtered query for observations with optional projectId, sessionId, type,
@@ -905,6 +804,335 @@ async function querySkillTelemetry(pool, opts = {}) {
905
804
  LIMIT $${idx}`, params)).rows;
906
805
  }
907
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
+
908
1136
  //#endregion
909
1137
  //#region src/daemon/daemon/dispatcher.ts
910
1138
  /**
@@ -1176,33 +1404,22 @@ async function handleRequest(request, socket) {
1176
1404
  }
1177
1405
  try {
1178
1406
  const p = params;
1179
- let project_id = null;
1180
- let project_slug = null;
1181
- if (p.cwd) {
1182
- 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);
1183
- if (row) {
1184
- project_id = row.id;
1185
- project_slug = row.slug;
1186
- }
1187
- }
1188
- await ensureObservationTables(pool);
1189
1407
  sendResponse(socket, {
1190
1408
  id,
1191
1409
  ok: true,
1192
1410
  result: {
1193
1411
  ok: true,
1194
- id: await storeObservation(pool, {
1412
+ id: await storeObservationWithProject(registryDb, pool, {
1195
1413
  session_id: p.session_id,
1196
- project_id,
1197
- project_slug,
1198
1414
  type: p.type,
1199
1415
  title: p.title,
1200
1416
  narrative: p.narrative ?? null,
1201
1417
  tool_name: p.tool_name,
1202
- tool_input_summary: p.tool_input_summary ?? null,
1418
+ tool_input_summary: p.tool_input_summary ?? void 0,
1203
1419
  files_read: p.files_read ?? [],
1204
1420
  files_modified: p.files_modified ?? [],
1205
- concepts: p.concepts ?? []
1421
+ concepts: p.concepts ?? [],
1422
+ cwd: p.cwd
1206
1423
  })
1207
1424
  }
1208
1425
  });
@@ -1643,7 +1860,7 @@ async function serve(config) {
1643
1860
  process.stderr.write("[pai-daemon] Starting daemon...\n");
1644
1861
  process.stderr.write(`[pai-daemon] Socket: ${config.socketPath}\n`);
1645
1862
  process.stderr.write(`[pai-daemon] Storage backend: ${config.storageBackend}\n`);
1646
- const { notificationConfig } = await import("./state-RXepvbnO.mjs");
1863
+ const { notificationConfig } = await import("./state-CY6I-Hnc.mjs");
1647
1864
  process.stderr.write(`[pai-daemon] Notification mode: ${notificationConfig.mode}\n`);
1648
1865
  try {
1649
1866
  setPriority(process.pid, 10);
@@ -1660,6 +1877,7 @@ async function serve(config) {
1660
1877
  const server = await startIpcServer(config.socketPath);
1661
1878
  startRegistryScanScheduler();
1662
1879
  startWorkerSupervisor();
1880
+ startCacheKeepalive();
1663
1881
  createStorageBackend(config, { waitForPostgres: true }).then((backend) => {
1664
1882
  setStorageBackend(backend);
1665
1883
  process.stderr.write(`[pai-daemon] Federation backend: ${backend.backendType}\n`);
@@ -1678,6 +1896,7 @@ async function serve(config) {
1678
1896
  setShutdownRequested(true);
1679
1897
  if (indexSchedulerTimer) clearInterval(indexSchedulerTimer);
1680
1898
  if (embedSchedulerTimer) clearInterval(embedSchedulerTimer);
1899
+ if (cacheKeepaliveTimer) clearInterval(cacheKeepaliveTimer);
1681
1900
  stopWorker();
1682
1901
  server.close();
1683
1902
  const SHUTDOWN_TIMEOUT_MS = 1e4;
@@ -1708,4 +1927,4 @@ async function serve(config) {
1708
1927
 
1709
1928
  //#endregion
1710
1929
  export { serve as t };
1711
- //# sourceMappingURL=daemon-DvXih78C.mjs.map
1930
+ //# sourceMappingURL=daemon-Dq8A1uDF.mjs.map