@tekmidian/pai 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/{auto-route-BlWOWT4z.mjs → auto-route-BwzmlW3W.mjs} +2 -2
- package/dist/{auto-route-BlWOWT4z.mjs.map → auto-route-BwzmlW3W.mjs.map} +1 -1
- package/dist/{chain-CqlgL7uH.mjs → chain-DhVHVnmT.mjs} +263 -821
- package/dist/chain-DhVHVnmT.mjs.map +1 -0
- package/dist/cli/index.mjs +10 -8
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +10 -8
- package/dist/{clusters-wlK0w41E.mjs → clusters-DRHhfli9.mjs} +1 -1
- package/dist/{clusters-wlK0w41E.mjs.map → clusters-DRHhfli9.mjs.map} +1 -1
- package/dist/{config-B64vFg14.mjs → config-D1G9IFpn.mjs} +50 -3
- package/dist/config-D1G9IFpn.mjs.map +1 -0
- package/dist/{config-C_ErGddD.mjs → config-DD52BPB-.mjs} +1 -1
- package/dist/config-Dl8lT4Lu.mjs +527 -0
- package/dist/config-Dl8lT4Lu.mjs.map +1 -0
- package/dist/{context-handover-cache-BpUojjsi.mjs → context-handover-cache-C6_rs7JT.mjs} +112 -24
- package/dist/context-handover-cache-C6_rs7JT.mjs.map +1 -0
- package/dist/daemon/index.mjs +12 -10
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BSL9sXxm.mjs +22 -0
- package/dist/{daemon-CTkN_Vn8.mjs → daemon-CpdVUnl_.mjs} +26 -26
- package/dist/{daemon-CTkN_Vn8.mjs.map → daemon-CpdVUnl_.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +76 -6
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/detector-BLVOCiJ_.mjs +5 -0
- package/dist/{detector-Bwk_4Pk4.mjs → detector-smpCm7ku.mjs} +1 -1
- package/dist/{detector-Bwk_4Pk4.mjs.map → detector-smpCm7ku.mjs.map} +1 -1
- package/dist/{factory-A9x-T2Kg.mjs → factory-Bc5EyN54.mjs} +7 -7
- package/dist/{factory-A9x-T2Kg.mjs.map → factory-Bc5EyN54.mjs.map} +1 -1
- package/dist/factory-C7ua_3ik.mjs +3 -0
- package/dist/{fallback-DqYhiACU.mjs → fallback-CdXv-Np0.mjs} +139 -49
- package/dist/fallback-CdXv-Np0.mjs.map +1 -0
- package/dist/hooks/context-compression-hook.mjs +136 -22
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/post-compact-inject.mjs +98 -7
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +36 -2
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +36 -2
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +110 -86
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/{indexer-backend-TG64CCQC.mjs → indexer-backend-rohVVKrA.mjs} +1 -1
- package/dist/{indexer-backend-TG64CCQC.mjs.map → indexer-backend-rohVVKrA.mjs.map} +1 -1
- package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C7AVpsyv.mjs} +2 -2
- package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C7AVpsyv.mjs.map} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-B7wq75Pt.mjs → latent-ideas-iNp5s55R.mjs} +2 -2
- package/dist/{latent-ideas-B7wq75Pt.mjs.map → latent-ideas-iNp5s55R.mjs.map} +1 -1
- package/dist/{link-boost-HkG7JWZR.mjs → link-boost-NNzADHX_.mjs} +1 -1
- package/dist/{link-boost-HkG7JWZR.mjs.map → link-boost-NNzADHX_.mjs.map} +1 -1
- package/dist/{main-resolver-DlaLOFBA.mjs → main-resolver-CM1IHbuu.mjs} +78 -75
- package/dist/main-resolver-CM1IHbuu.mjs.map +1 -0
- package/dist/main-resolver-E5WlilhM.mjs +6 -0
- package/dist/{neighborhood-lThN-MaQ.mjs → neighborhood-AGL7oXQ2.mjs} +1 -1
- package/dist/{neighborhood-lThN-MaQ.mjs.map → neighborhood-AGL7oXQ2.mjs.map} +1 -1
- package/dist/{note-context-b6k0mAKi.mjs → note-context-BHwYpSFf.mjs} +1 -1
- package/dist/{note-context-b6k0mAKi.mjs.map → note-context-BHwYpSFf.mjs.map} +1 -1
- package/dist/{planner-DGV8VFib.mjs → planner-Cm3g6fWH.mjs} +5 -3
- package/dist/{planner-DGV8VFib.mjs.map → planner-Cm3g6fWH.mjs.map} +1 -1
- package/dist/{postgres-mW1n7Vi1.mjs → postgres-Dwxe290H.mjs} +1 -1
- package/dist/{postgres-mW1n7Vi1.mjs.map → postgres-Dwxe290H.mjs.map} +1 -1
- package/dist/{program-B-QJXCRb.mjs → program-AR0qlYRg.mjs} +309 -78
- package/dist/program-AR0qlYRg.mjs.map +1 -0
- package/dist/query-feedback-CGcEiGtY.mjs +3 -0
- package/dist/{query-feedback-BV4CcxqS.mjs → query-feedback-DZfjI34w.mjs} +1 -1
- package/dist/{query-feedback-BV4CcxqS.mjs.map → query-feedback-DZfjI34w.mjs.map} +1 -1
- package/dist/router-CAtyVQud.mjs +3 -0
- package/dist/{router-DcHKnEPa.mjs → router-DHracTUC.mjs} +1 -1
- package/dist/{router-DcHKnEPa.mjs.map → router-DHracTUC.mjs.map} +1 -1
- package/dist/server-BOuAOj9b.mjs +386 -0
- package/dist/server-BOuAOj9b.mjs.map +1 -0
- package/dist/skills/Worker/SKILL.md +6 -1
- package/dist/{sources-kLnQsNrW.mjs → sources-ju6AA7lL.mjs} +1 -1
- package/dist/{sources-kLnQsNrW.mjs.map → sources-ju6AA7lL.mjs.map} +1 -1
- package/dist/{sqlite-BenGr3UP.mjs → sqlite-kFZeWCJ2.mjs} +1 -1
- package/dist/{sqlite-BenGr3UP.mjs.map → sqlite-kFZeWCJ2.mjs.map} +1 -1
- package/dist/{state-Ca9F_sZc.mjs → state-DH_mp5Xe.mjs} +1 -1
- package/dist/{state-CAeyOdfq.mjs → state-ahFAbD8b.mjs} +1 -1
- package/dist/{state-CAeyOdfq.mjs.map → state-ahFAbD8b.mjs.map} +1 -1
- package/dist/{themes-BI4GMRP9.mjs → themes-DqhNUUQk.mjs} +1 -1
- package/dist/{themes-BI4GMRP9.mjs.map → themes-DqhNUUQk.mjs.map} +1 -1
- package/dist/{tools-DGcB3o_S.mjs → tools-CMlHa9lZ.mjs} +14 -14
- package/dist/{tools-DGcB3o_S.mjs.map → tools-CMlHa9lZ.mjs.map} +1 -1
- package/dist/{tools-Bp7hj6OW.mjs → tools-COna9vZR.mjs} +1 -1
- package/dist/{trace-bobARFEX.mjs → trace-DTP6k4B_.mjs} +1 -1
- package/dist/{trace-bobARFEX.mjs.map → trace-DTP6k4B_.mjs.map} +1 -1
- package/dist/{vault-indexer-C3OfXTNF.mjs → vault-indexer-YMH22mLU.mjs} +1 -1
- package/dist/{vault-indexer-C3OfXTNF.mjs.map → vault-indexer-YMH22mLU.mjs.map} +1 -1
- package/dist/{work-queue-worker-R7UGIag_.mjs → work-queue-worker-B3nl8yyn.mjs} +23 -26
- package/dist/work-queue-worker-B3nl8yyn.mjs.map +1 -0
- package/dist/work-queue-worker-BM1GcAAq.mjs +14 -0
- package/dist/{zettelkasten-m5QPtb-L.mjs → zettelkasten-DDvKRm2V.mjs} +3 -3
- package/dist/{zettelkasten-m5QPtb-L.mjs.map → zettelkasten-DDvKRm2V.mjs.map} +1 -1
- package/docs/commands/README.md +4 -2
- package/docs/commands/worker.md +48 -5
- package/docs/images/provider-abstraction-live.png +0 -0
- package/docs/images/workers.png +0 -0
- package/docs/launcher-independence.md +70 -0
- package/docs/provider-abstraction.md +87 -0
- package/docs/provider-independence-details.md +330 -0
- package/docs/provider-independence.md +75 -0
- package/docs/worker.md +29 -11
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +6 -1
- package/scripts/build-skill-stubs.mjs +17 -4
- package/src/hooks/ts/lib/context-fill.test.ts +158 -4
- package/src/hooks/ts/lib/context-fill.ts +102 -35
- package/statusline-command.sh +120 -21
- package/dist/chain-CqlgL7uH.mjs.map +0 -1
- package/dist/config-B64vFg14.mjs.map +0 -1
- package/dist/context-handover-cache-BpUojjsi.mjs.map +0 -1
- package/dist/daemon-CmHHmKpg.mjs +0 -20
- package/dist/detector-DO730Zq0.mjs +0 -5
- package/dist/factory-BD-np0Vd.mjs +0 -3
- package/dist/fallback-DqYhiACU.mjs.map +0 -1
- package/dist/main-resolver-D6IImXvF.mjs +0 -4
- package/dist/main-resolver-DlaLOFBA.mjs.map +0 -1
- package/dist/program-B-QJXCRb.mjs.map +0 -1
- package/dist/query-feedback-DSVyHtrG.mjs +0 -3
- package/dist/router-Bk77E7hj.mjs +0 -3
- package/dist/runtime-paths-rni52zHX.mjs +0 -50
- package/dist/runtime-paths-rni52zHX.mjs.map +0 -1
- package/dist/work-queue-worker-DW8lz-Oo.mjs +0 -11
- package/dist/work-queue-worker-R7UGIag_.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detector-
|
|
1
|
+
{"version":3,"file":"detector-smpCm7ku.mjs","names":[],"sources":["../src/topics/detector.ts"],"sourcesContent":["/**\n * Topic shift detection engine.\n *\n * Accepts a context summary (recent conversation text) and determines whether\n * the conversation has drifted away from the currently-routed project.\n *\n * Algorithm:\n * 1. Run keyword memory_search against the context text (no project filter)\n * 2. Score results by project — sum of BM25 scores per project\n * 3. Compare the top-scoring project against the current project\n * 4. If a different project dominates by more than the confidence threshold,\n * report a topic shift.\n *\n * Design decisions:\n * - Keyword search only (no semantic) — fast, no embedding requirement\n * - Works with or without an active daemon (direct DB access path)\n * - Stateless: callers supply currentProject; detector has no session memory\n * - Minimal: returns a plain result object, not MCP content arrays\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { searchMemory, populateSlugs } from \"../memory/search.js\";\nimport type { SearchResult } from \"../memory/search.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicCheckParams {\n /** Recent conversation context (a few sentences or tool call summaries) */\n context: string;\n /** The project slug the session is currently routed to. May be null/empty. */\n currentProject?: string;\n /**\n * Minimum confidence [0,1] to declare a shift. Default: 0.6.\n * Higher = less sensitive, fewer false positives.\n */\n threshold?: number;\n /**\n * Maximum results to draw from memory search (candidates). Default: 20.\n * More candidates = more accurate scoring, slightly slower.\n */\n candidates?: number;\n}\n\nexport interface TopicCheckResult {\n /** Whether a significant topic shift was detected. */\n shifted: boolean;\n /** The project slug the session is currently routed to (echoed from input). */\n currentProject: string | null;\n /** The project slug that best matches the context, or null if no clear match. */\n suggestedProject: string | null;\n /**\n * Confidence score for the suggested project [0,1].\n * Represents the fraction of total score mass held by the top project.\n * 1.0 = all matching chunks belong to one project.\n * 0.5 = two projects are equally matched.\n */\n confidence: number;\n /** Number of memory chunks that contributed to scoring. */\n chunkCount: number;\n /** Top-3 scoring projects with their normalised scores (for debugging). */\n topProjects: Array<{ slug: string; score: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Core algorithm\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether the provided context text best matches a different project\n * than the session's current routing.\n *\n * Works with either a raw SQLite Database or a StorageBackend.\n * For the StorageBackend path, keyword search is used.\n * For the raw Database path (legacy/direct), searchMemory() is called.\n */\nexport async function detectTopicShift(\n registryDb: Database,\n federation: Database | StorageBackend,\n params: TopicCheckParams\n): Promise<TopicCheckResult> {\n const threshold = params.threshold ?? 0.6;\n const candidates = params.candidates ?? 20;\n const currentProject = params.currentProject?.trim() || null;\n\n if (!params.context || params.context.trim().length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // -------------------------------------------------------------------------\n // Run memory search across ALL projects (no project filter)\n // -------------------------------------------------------------------------\n\n let results: SearchResult[];\n\n const isBackend = (x: Database | StorageBackend): x is StorageBackend =>\n \"backendType\" in x;\n\n if (isBackend(federation)) {\n results = await federation.searchKeyword(params.context, {\n maxResults: candidates,\n });\n } else {\n results = searchMemory(federation, params.context, {\n maxResults: candidates,\n });\n }\n\n if (results.length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // Populate project slugs from the registry\n const withSlugs = populateSlugs(results, registryDb);\n\n // -------------------------------------------------------------------------\n // Score projects by summing BM25 scores of matching chunks\n // -------------------------------------------------------------------------\n\n const projectScores = new Map<string, number>();\n\n for (const r of withSlugs) {\n const slug = r.projectSlug;\n if (!slug) continue;\n projectScores.set(slug, (projectScores.get(slug) ?? 0) + r.score);\n }\n\n if (projectScores.size === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: withSlugs.length,\n topProjects: [],\n };\n }\n\n // Sort by total score descending\n const ranked = Array.from(projectScores.entries())\n .sort((a, b) => b[1] - a[1]);\n\n const totalScore = ranked.reduce((sum, [, s]) => sum + s, 0);\n\n // Top-3 for reporting (normalised to [0,1] fraction of total mass)\n const topProjects = ranked.slice(0, 3).map(([slug, score]) => ({\n slug,\n score: totalScore > 0 ? score / totalScore : 0,\n }));\n\n const topSlug = ranked[0][0];\n const topRawScore = ranked[0][1];\n const confidence = totalScore > 0 ? topRawScore / totalScore : 0;\n\n // -------------------------------------------------------------------------\n // Determine if a shift occurred\n // -------------------------------------------------------------------------\n\n // A shift is detected when:\n // 1. confidence >= threshold (the top project dominates)\n // 2. The top project is different from currentProject\n // 3. There is a currentProject to compare against\n // (if no current project, we still return the best match but no \"shift\")\n\n const isDifferent =\n currentProject !== null &&\n topSlug !== currentProject;\n\n const shifted = isDifferent && confidence >= threshold;\n\n return {\n shifted,\n currentProject,\n suggestedProject: topSlug,\n confidence,\n chunkCount: withSlugs.length,\n topProjects,\n };\n}\n"],"mappings":";;;;;;;;;;;AA8EA,eAAsB,iBACpB,YACA,YACA,QAC2B;CAC3B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,iBAAiB,OAAO,gBAAgB,MAAM,IAAI;AAExD,KAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,MAAM,CAAC,WAAW,EACtD,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAOH,IAAI;CAEJ,MAAM,aAAa,MACjB,iBAAiB;AAEnB,KAAI,UAAU,WAAW,CACvB,WAAU,MAAM,WAAW,cAAc,OAAO,SAAS,EACvD,YAAY,YACb,CAAC;KAEF,WAAU,aAAa,YAAY,OAAO,SAAS,EACjD,YAAY,YACb,CAAC;AAGJ,KAAI,QAAQ,WAAW,EACrB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAIH,MAAM,YAAY,cAAc,SAAS,WAAW;CAMpD,MAAM,gCAAgB,IAAI,KAAqB;AAE/C,MAAK,MAAM,KAAK,WAAW;EACzB,MAAM,OAAO,EAAE;AACf,MAAI,CAAC,KAAM;AACX,gBAAc,IAAI,OAAO,cAAc,IAAI,KAAK,IAAI,KAAK,EAAE,MAAM;;AAGnE,KAAI,cAAc,SAAS,EACzB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY,UAAU;EACtB,aAAa,EAAE;EAChB;CAIH,MAAM,SAAS,MAAM,KAAK,cAAc,SAAS,CAAC,CAC/C,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG;CAE9B,MAAM,aAAa,OAAO,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAG5D,MAAM,cAAc,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,YAAY;EAC7D;EACA,OAAO,aAAa,IAAI,QAAQ,aAAa;EAC9C,EAAE;CAEH,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,cAAc,OAAO,GAAG;CAC9B,MAAM,aAAa,aAAa,IAAI,cAAc,aAAa;AAkB/D,QAAO;EACL,SANA,mBAAmB,QACnB,YAAY,kBAEiB,cAAc;EAI3C;EACA,kBAAkB;EAClB;EACA,YAAY,UAAU;EACtB;EACD"}
|
|
@@ -56,7 +56,7 @@ async function createStorageBackend(config, opts = {}) {
|
|
|
56
56
|
* backend on success, or an error string describing why it failed.
|
|
57
57
|
*/
|
|
58
58
|
async function attemptPostgres(config) {
|
|
59
|
-
const { PostgresBackend } = await import("./postgres-
|
|
59
|
+
const { PostgresBackend } = await import("./postgres-Dwxe290H.mjs");
|
|
60
60
|
const pgConfig = config.postgres ?? {};
|
|
61
61
|
let backend = null;
|
|
62
62
|
try {
|
|
@@ -85,8 +85,8 @@ const ESCALATE_AFTER_ATTEMPTS = 5;
|
|
|
85
85
|
/** Tell the user the backend is down, through whatever channels are configured. */
|
|
86
86
|
async function notifyBackendDown(attempts, lastError) {
|
|
87
87
|
try {
|
|
88
|
-
const { routeNotification } = await import("./router-
|
|
89
|
-
const { loadConfig } = await import("./config-
|
|
88
|
+
const { routeNotification } = await import("./router-CAtyVQud.mjs");
|
|
89
|
+
const { loadConfig } = await import("./config-DD52BPB-.mjs");
|
|
90
90
|
await routeNotification({
|
|
91
91
|
event: "error",
|
|
92
92
|
title: "PAI: storage backend unreachable",
|
|
@@ -97,8 +97,8 @@ async function notifyBackendDown(attempts, lastError) {
|
|
|
97
97
|
/** And say when it comes back, so the alert is not left hanging. */
|
|
98
98
|
async function notifyBackendRecovered(attempts, since) {
|
|
99
99
|
try {
|
|
100
|
-
const { routeNotification } = await import("./router-
|
|
101
|
-
const { loadConfig } = await import("./config-
|
|
100
|
+
const { routeNotification } = await import("./router-CAtyVQud.mjs");
|
|
101
|
+
const { loadConfig } = await import("./config-DD52BPB-.mjs");
|
|
102
102
|
const mins = since ? Math.max(1, Math.round((Date.now() - since) / 6e4)) : null;
|
|
103
103
|
await routeNotification({
|
|
104
104
|
event: "completion",
|
|
@@ -141,10 +141,10 @@ async function connectPostgres(config, waitForever) {
|
|
|
141
141
|
}
|
|
142
142
|
async function createSQLiteBackend() {
|
|
143
143
|
const { openFederation } = await import("./db-O-cyAPfS.mjs");
|
|
144
|
-
const { SQLiteBackend } = await import("./sqlite-
|
|
144
|
+
const { SQLiteBackend } = await import("./sqlite-kFZeWCJ2.mjs");
|
|
145
145
|
return new SQLiteBackend(openFederation());
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
//#endregion
|
|
149
149
|
export { getBackendOutage as n, humanDuration as r, createStorageBackend as t };
|
|
150
|
-
//# sourceMappingURL=factory-
|
|
150
|
+
//# sourceMappingURL=factory-Bc5EyN54.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"factory-A9x-T2Kg.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"factory-Bc5EyN54.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as WorkersConfigError, b as readJsonStrict, c as expandHome, d as parseWorkersConfig, f as providerCostTier, g as writeWorkersSection, h as resolveModelCapability, l as isModelCapability, m as readWorkersSection, n as MODEL_CAPABILITIES, p as providerKeyPath, r as PROVIDER_TAGS, t as DEFAULT_LOG_DIR, u as keysDir, x as writeJsonAtomic } from "./config-Dl8lT4Lu.mjs";
|
|
2
|
+
import { _ as workersLogDir, a as clearCooldown, c as probeQuota, f as eventsPath, l as quotaSkipThreshold, u as resolveTarget } from "./server-BOuAOj9b.mjs";
|
|
3
|
+
import { C as ageOf, E as contextPercent, N as shortText, O as loadStatuses, S as UNLABELED, T as contextLabel, b as sessionTag, c as renderReport, f as readInbox, m as workerDepth, p as sayToWorker, s as parseWorkerReport, v as currentTabKey, w as alive, x as workerInScope, y as resolveSession } from "./chain-DhVHVnmT.mjs";
|
|
2
4
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { homedir } from "node:os";
|
|
4
6
|
import { basename, join, relative } from "node:path";
|
|
@@ -159,17 +161,23 @@ function wrapText(text, width) {
|
|
|
159
161
|
function chatScrollRegion(rows) {
|
|
160
162
|
return `\x1b[1;${Math.max(1, rows - 3)}r`;
|
|
161
163
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The separator row between the transcript and the prompt: cleared, then —
|
|
166
|
+
* when the pane's width is known — a dim rule of `cols` box-drawing `─`
|
|
167
|
+
* filling the row. `cols` 0 (unknown) keeps the row blank; `dim` is the
|
|
168
|
+
* pane's colour helper, identity when colours are off.
|
|
169
|
+
*/
|
|
170
|
+
function chatBlankRow(rows, cols = 0, dim = (s) => s) {
|
|
171
|
+
const rule = cols > 0 ? dim("─".repeat(cols)) : "";
|
|
172
|
+
return `\x1b[${Math.max(1, rows - 2)};1H\x1b[K` + rule;
|
|
165
173
|
}
|
|
166
174
|
/**
|
|
167
|
-
* Enter the chat layout: clear the pane, set the scroll region,
|
|
175
|
+
* Enter the chat layout: clear the pane, set the scroll region, draw the
|
|
168
176
|
* separator row and park the cursor at column 3 of the prompt row (rows-1,
|
|
169
177
|
* right after `› `). The ticker owns row `rows`.
|
|
170
178
|
*/
|
|
171
|
-
function chatEnter(rows) {
|
|
172
|
-
return "\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows) + `\x1b[${Math.max(1, rows - 1)};3H`;
|
|
179
|
+
function chatEnter(rows, cols = 0, dim = (s) => s) {
|
|
180
|
+
return "\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows, cols, dim) + `\x1b[${Math.max(1, rows - 1)};3H`;
|
|
173
181
|
}
|
|
174
182
|
/** Leave it: reset the scroll region, show the cursor, drop to the last row. */
|
|
175
183
|
function chatLeave(rows) {
|
|
@@ -359,7 +367,7 @@ function fmtElapsed(secs) {
|
|
|
359
367
|
}
|
|
360
368
|
/**
|
|
361
369
|
* The chat pane's status row (always visible while following):
|
|
362
|
-
* `[glm/glm-5.3] ctx 84k/200k 42% · turns 12 · tools 7 · 4m12s · ⋯ 7s · <intent> · <tool>`
|
|
370
|
+
* `[glm/glm-5.3] ctx 84k/200k (42%) · turns 12 · tools 7 · 4m12s · ⋯ 7s · <intent> · <tool>`
|
|
363
371
|
* — context yellow past 70 %, red past 85 %, dropped when unknown; once the
|
|
364
372
|
* worker finished, the ticker part is replaced by `✓ done` / `✗ failed`.
|
|
365
373
|
*/
|
|
@@ -367,7 +375,7 @@ function chatStatusRow(c, s) {
|
|
|
367
375
|
const parts = [];
|
|
368
376
|
const pct = contextPercent(s);
|
|
369
377
|
if (pct !== null) {
|
|
370
|
-
const label =
|
|
378
|
+
const label = contextLabel(s);
|
|
371
379
|
parts.push(pct > 85 ? c("red", label) : pct > 70 ? c("yellow", label) : label);
|
|
372
380
|
}
|
|
373
381
|
parts.push(`turns ${s.turns}`, `tools ${s.tools}`, fmtElapsed(s.elapsed));
|
|
@@ -393,19 +401,16 @@ function blankBetween(prev, e) {
|
|
|
393
401
|
function intentOf(text) {
|
|
394
402
|
return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
|
|
395
403
|
}
|
|
396
|
-
/** Compact token count: 84k, 200k, 900. */
|
|
397
|
-
function fmtK(n) {
|
|
398
|
-
return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
399
|
-
}
|
|
400
404
|
/**
|
|
401
|
-
* The context meter `ctx 84k/200k (42%)
|
|
402
|
-
*
|
|
403
|
-
*
|
|
405
|
+
* The context meter `ctx 84k/200k (42%)` (contextLabel, shared with the
|
|
406
|
+
* chat status row), yellow from 70 %, red from 85 %. null when the numbers
|
|
407
|
+
* are missing or below `minPct` (the table only shows it past 60; the pane
|
|
408
|
+
* liveness line always shows it).
|
|
404
409
|
*/
|
|
405
410
|
function contextMeter(c, s, minPct = 0) {
|
|
406
411
|
const pct = contextPercent(s);
|
|
407
412
|
if (pct === null || pct <= minPct) return null;
|
|
408
|
-
const label =
|
|
413
|
+
const label = contextLabel(s);
|
|
409
414
|
if (pct > 85) return c("red", label);
|
|
410
415
|
if (pct > 70) return c("yellow", label);
|
|
411
416
|
return label;
|
|
@@ -553,28 +558,49 @@ function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date(),
|
|
|
553
558
|
return lines.join("\n");
|
|
554
559
|
}
|
|
555
560
|
/**
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
561
|
+
* The step of a statusline row: what the worker is doing as a verb, never a
|
|
562
|
+
* raw command. A Bash line trims to its first word (`grep -n -A4 "x" src/` →
|
|
563
|
+
* `grep …` — no `Bash:` prefix, no flags, no quotes); the file tools keep
|
|
564
|
+
* their `Edit: button.ts` shape; free text (what the worker last said) is
|
|
565
|
+
* only shortened. Always ≤ 24 chars.
|
|
560
566
|
*/
|
|
561
|
-
function
|
|
567
|
+
function stepOf(last) {
|
|
568
|
+
const s = String(last ?? "").trim();
|
|
569
|
+
if (s.startsWith("Bash: ")) {
|
|
570
|
+
const cmd = s.slice(6).trim();
|
|
571
|
+
const verb = cmd.split(/\s+/)[0] ?? "";
|
|
572
|
+
return shortText(cmd.length > verb.length ? `${verb} …` : verb, 24);
|
|
573
|
+
}
|
|
574
|
+
return shortText(s, 24);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The statusline bar: `provider ▶N` then one row per running spawned worker —
|
|
578
|
+
* `name · age · step` — and today's ✓/✗ tail. The chat pane contributes
|
|
579
|
+
* nothing but its provider: its age, state and inbox live in `pai worker ps`,
|
|
580
|
+
* not here. Rows are flat (the ↳ depth is ps territory) and oldest first;
|
|
581
|
+
* `#id` joins a name only when two running workers share one label, so ids
|
|
582
|
+
* stay rare enough to read.
|
|
583
|
+
*/
|
|
584
|
+
function renderStatusLine(mine, now = /* @__PURE__ */ new Date()) {
|
|
562
585
|
if (!mine.length) return "";
|
|
563
|
-
const
|
|
586
|
+
const isChat = (s) => s.origin === "chat" || !s.origin && (s.label === UNLABELED || s.label === "(no prompt)") && s.turns === 0;
|
|
587
|
+
const chat = mine.find((s) => isChat(s) && s.state === "running" && alive(s.pid));
|
|
588
|
+
const running = mine.filter((s) => !isChat(s) && s.state === "running" && alive(s.pid)).sort((a, b) => a.started.localeCompare(b.started));
|
|
564
589
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
565
590
|
const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
|
|
566
591
|
const ok = doneToday.filter((s) => s.state === "done").length;
|
|
567
592
|
const bad = doneToday.length - ok;
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
const parts = running.slice(0, 3).map((s) => {
|
|
571
|
-
const meter = contextMeter(c, s, 60);
|
|
572
|
-
const depth = workerDepth(all, s.id);
|
|
573
|
-
const lead = depth > 0 ? " ".repeat(depth - 1) + "↳ " : "";
|
|
574
|
-
const box = inbox[s.id] ? ` ◆${inbox[s.id]}` : "";
|
|
575
|
-
return `${lead}${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}${box}` + (meter ? ` ${meter}` : "");
|
|
593
|
+
const rows = running.slice(0, 3).map((s) => {
|
|
594
|
+
return `${`${running.some((o) => o !== s && o.label === s.label) ? `#${s.id.slice(-4)} ` : ""}${s.label.slice(0, 26)}`} · ${ageOf(s.started, now)} · ${stepOf(s.last)}`;
|
|
576
595
|
});
|
|
577
|
-
|
|
596
|
+
let head;
|
|
597
|
+
if (chat) head = chat.provider;
|
|
598
|
+
else {
|
|
599
|
+
const providers = new Set(running.map((s) => s.provider));
|
|
600
|
+
head = providers.size === 1 ? [...providers][0] : "workers";
|
|
601
|
+
}
|
|
602
|
+
if (running.length) head += ` ▶${running.length}`;
|
|
603
|
+
if (rows.length) head += " | " + rows.join(" | ");
|
|
578
604
|
if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
|
|
579
605
|
return head;
|
|
580
606
|
}
|
|
@@ -677,7 +703,7 @@ function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
|
|
|
677
703
|
}
|
|
678
704
|
const gutter = gutterFor(c, e, tag, offMin);
|
|
679
705
|
const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
|
|
680
|
-
const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
|
|
706
|
+
const lines = blankBetween(s.prev, e) ? [gutter ? gutter.barCont : "", ...body] : body;
|
|
681
707
|
return {
|
|
682
708
|
lines,
|
|
683
709
|
day: day && day !== s.lastDay ? day : null,
|
|
@@ -808,13 +834,19 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
|
|
|
808
834
|
const columns = () => typeof out_.columns === "number" ? out_.columns : null;
|
|
809
835
|
let fill = 0;
|
|
810
836
|
const regionRows = () => Math.max(1, rows - 3);
|
|
837
|
+
const retained = [];
|
|
838
|
+
const RETAIN_CAP = 500;
|
|
811
839
|
/** Every line the pane shows goes through here: plain newline, or a row
|
|
812
840
|
* inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
|
|
813
|
-
const out = (line) => {
|
|
841
|
+
const out = (line, keep = true) => {
|
|
814
842
|
if (!chat) {
|
|
815
843
|
out_.write(line + "\n");
|
|
816
844
|
return;
|
|
817
845
|
}
|
|
846
|
+
if (keep) {
|
|
847
|
+
retained.push(line);
|
|
848
|
+
if (retained.length > RETAIN_CAP) retained.splice(0, retained.length - RETAIN_CAP);
|
|
849
|
+
}
|
|
818
850
|
const r = chatInsertLine(line, fill, regionRows());
|
|
819
851
|
out_.write(r.seq);
|
|
820
852
|
fill = r.fill;
|
|
@@ -1031,9 +1063,17 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
|
|
|
1031
1063
|
}
|
|
1032
1064
|
if (!redrew) drawPrompt();
|
|
1033
1065
|
};
|
|
1066
|
+
const silent = {
|
|
1067
|
+
write: () => true,
|
|
1068
|
+
on: () => silent,
|
|
1069
|
+
once: () => silent,
|
|
1070
|
+
off: () => silent,
|
|
1071
|
+
removeListener: () => silent,
|
|
1072
|
+
emit: () => false
|
|
1073
|
+
};
|
|
1034
1074
|
rlIn = createInterface({
|
|
1035
1075
|
input: in_,
|
|
1036
|
-
output:
|
|
1076
|
+
output: silent,
|
|
1037
1077
|
terminal: terminalIn
|
|
1038
1078
|
});
|
|
1039
1079
|
rlIn.on("line", handleChatLine);
|
|
@@ -1050,13 +1090,14 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
|
|
|
1050
1090
|
rlIn.on("close", () => {
|
|
1051
1091
|
aborted = true;
|
|
1052
1092
|
});
|
|
1053
|
-
out_.write(chatEnter(rows));
|
|
1093
|
+
out_.write(chatEnter(rows, columns() ?? 0, (s) => c("dim", s)));
|
|
1054
1094
|
drawPrompt();
|
|
1055
1095
|
if (terminalIn) in_.on("keypress", () => drawPrompt());
|
|
1056
1096
|
onResize = () => {
|
|
1057
1097
|
if (typeof out_.rows === "number") rows = out_.rows;
|
|
1058
1098
|
fill = 0;
|
|
1059
|
-
out_.write(chatScrollRegion(rows) + chatBlankRow(rows));
|
|
1099
|
+
out_.write("\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows, columns() ?? 0, (s) => c("dim", s)));
|
|
1100
|
+
for (const ln of retained.slice(-regionRows())) out(ln, false);
|
|
1060
1101
|
drawPrompt();
|
|
1061
1102
|
};
|
|
1062
1103
|
out_.on?.("resize", onResize);
|
|
@@ -1201,14 +1242,13 @@ function sleep(ms) {
|
|
|
1201
1242
|
return new Promise((r) => setTimeout(r, ms));
|
|
1202
1243
|
}
|
|
1203
1244
|
/** Workers of this terminal for the status bar; "" when there are none. */
|
|
1204
|
-
function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
|
|
1205
|
-
|
|
1206
|
-
const mine = statuses.filter((s) => {
|
|
1245
|
+
function statusLineOutput(logDir, term, cwd, claudeSession = "", now = /* @__PURE__ */ new Date()) {
|
|
1246
|
+
return renderStatusLine(loadStatuses(logDir).filter((s) => {
|
|
1207
1247
|
const sameScope = term && workerInScope(s, term);
|
|
1248
|
+
const spawnedHere = claudeSession && s.spawnerSession === claudeSession;
|
|
1208
1249
|
const sameDir = cwd && s.cwd.startsWith(cwd);
|
|
1209
|
-
return sameScope || !term && sameDir;
|
|
1210
|
-
});
|
|
1211
|
-
return renderStatusLine(mine, now, makeColor(false), statuses, inboxCounts(logDir, mine));
|
|
1250
|
+
return sameScope || spawnedHere || !term && sameDir;
|
|
1251
|
+
}), now);
|
|
1212
1252
|
}
|
|
1213
1253
|
|
|
1214
1254
|
//#endregion
|
|
@@ -1316,6 +1356,32 @@ function useProvider(name) {
|
|
|
1316
1356
|
writeWorkersSection(raw, workers);
|
|
1317
1357
|
return workers;
|
|
1318
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* The provider a model command targets: the named one, else the active one.
|
|
1361
|
+
* "auto" and an unset active both ask for an explicit name.
|
|
1362
|
+
*/
|
|
1363
|
+
function resolveProviderName(workers, name) {
|
|
1364
|
+
const target = name ?? workers.active ?? "";
|
|
1365
|
+
if (workers.providers[target]) return target;
|
|
1366
|
+
if (!target) throw new WorkersConfigError("no active provider — name one explicitly");
|
|
1367
|
+
if (target === "auto") throw new WorkersConfigError("active is \"auto\" — name a provider explicitly");
|
|
1368
|
+
throw new WorkersConfigError(`no provider named "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Set a provider's model id for a capability — default, fast, image, …
|
|
1372
|
+
* (`pai worker model`, MCP worker_model).
|
|
1373
|
+
*/
|
|
1374
|
+
function setProviderModel(name, capability, model, configPath) {
|
|
1375
|
+
if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a model capability (from: ${MODEL_CAPABILITIES.join(", ")})`);
|
|
1376
|
+
const id = model.trim();
|
|
1377
|
+
if (!id) throw new WorkersConfigError(`a ${capability} model id must not be empty`);
|
|
1378
|
+
const { raw, workers } = readWorkersSection(configPath);
|
|
1379
|
+
const p = workers.providers[name];
|
|
1380
|
+
if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
1381
|
+
p.models[capability] = id;
|
|
1382
|
+
writeWorkersSection(raw, workers, configPath);
|
|
1383
|
+
return workers;
|
|
1384
|
+
}
|
|
1319
1385
|
function setProviderEnabled(name, enabled) {
|
|
1320
1386
|
const { raw, workers } = readWorkersSection();
|
|
1321
1387
|
const p = workers.providers[name];
|
|
@@ -1336,8 +1402,10 @@ function setClass(name, target) {
|
|
|
1336
1402
|
const [provider, alias] = target.split("/");
|
|
1337
1403
|
const p = workers.providers[provider];
|
|
1338
1404
|
if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
1339
|
-
if (alias
|
|
1340
|
-
|
|
1405
|
+
if (alias) {
|
|
1406
|
+
if (!isModelCapability(alias)) throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose: ${MODEL_CAPABILITIES.join(", ")}`);
|
|
1407
|
+
if (alias !== "default" && !p.models[alias]) throw new WorkersConfigError(`provider "${provider}" has no ${alias} model configured`);
|
|
1408
|
+
}
|
|
1341
1409
|
} else if (target.provider) {
|
|
1342
1410
|
if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
1343
1411
|
}
|
|
@@ -1369,6 +1437,28 @@ function classTargetText(target) {
|
|
|
1369
1437
|
...target.order?.length ? [`order [${target.order.join(",")}]`] : []
|
|
1370
1438
|
].join(" ");
|
|
1371
1439
|
}
|
|
1440
|
+
/**
|
|
1441
|
+
* `default X fast Y image Z` — every model capability on one line, unset
|
|
1442
|
+
* ones shown as "(none)" (they resolve to the default model).
|
|
1443
|
+
*/
|
|
1444
|
+
function modelPrefsText(p) {
|
|
1445
|
+
return MODEL_CAPABILITIES.map((c) => `${c} ${p.models[c] ?? "(none)"}`).join(" ");
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Compact model listing for `pai worker model` / worker_model get: the active
|
|
1449
|
+
* provider, then one line per provider with all its capability preferences.
|
|
1450
|
+
*/
|
|
1451
|
+
function describeModels(workers) {
|
|
1452
|
+
const names = Object.keys(workers.providers);
|
|
1453
|
+
if (!names.length) return ["no providers configured — add one with `pai worker providers add <name> …`"];
|
|
1454
|
+
const lines = [`active provider: ${workers.active ?? "(none)"}`];
|
|
1455
|
+
for (const name of names) {
|
|
1456
|
+
const p = workers.providers[name];
|
|
1457
|
+
const active = workers.active === name ? " [active]" : "";
|
|
1458
|
+
lines.push(`${name}${active} ${modelPrefsText(p)}`);
|
|
1459
|
+
}
|
|
1460
|
+
return lines;
|
|
1461
|
+
}
|
|
1372
1462
|
/** Human-readable provider listing (quota probe included when configured). */
|
|
1373
1463
|
function describeProviders(workers) {
|
|
1374
1464
|
const lines = [];
|
|
@@ -1385,7 +1475,7 @@ function describeProviders(workers) {
|
|
|
1385
1475
|
const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
|
|
1386
1476
|
const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
|
|
1387
1477
|
lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
|
|
1388
|
-
lines.push(`
|
|
1478
|
+
lines.push(` ${modelPrefsText(p)}${quotaNote}`);
|
|
1389
1479
|
lines.push(` ${tierTags}`);
|
|
1390
1480
|
if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
|
|
1391
1481
|
else lines.push(` no key file (token "local")`);
|
|
@@ -1454,7 +1544,7 @@ function fallbackEnv(provider) {
|
|
|
1454
1544
|
ANTHROPIC_AUTH_TOKEN: token,
|
|
1455
1545
|
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.default,
|
|
1456
1546
|
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.default,
|
|
1457
|
-
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider
|
|
1547
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: resolveModelCapability(provider, "fast"),
|
|
1458
1548
|
...provider.env,
|
|
1459
1549
|
ENABLE_TOOL_SEARCH: "true",
|
|
1460
1550
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
@@ -1671,5 +1761,5 @@ function fallbackStatusText(s) {
|
|
|
1671
1761
|
}
|
|
1672
1762
|
|
|
1673
1763
|
//#endregion
|
|
1674
|
-
export {
|
|
1675
|
-
//# sourceMappingURL=fallback-
|
|
1764
|
+
export { statusLineOutput as S, updateProvider as _, addProvider as a, psOutput as b, describeProviders as c, resolveProviderName as d, setClass as f, unsetClass as g, setWorkersEnabled as h, fallbackStatusText as i, modelPrefsText as l, setProviderModel as m, fallbackOn as n, classTargetText as o, setProviderEnabled as p, fallbackStatus as r, describeModels as s, fallbackOff as t, removeProvider as u, useProvider as v, replayOutput as x, followWorkers as y };
|
|
1765
|
+
//# sourceMappingURL=fallback-CdXv-Np0.mjs.map
|