@tekmidian/pai 0.59.0 → 0.60.1

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 (129) hide show
  1. package/.claude-plugin/plugin.json +8 -33
  2. package/PLUGIN-ARCHITECTURE.md +4 -6
  3. package/dist/{aibroker-client-B8Wtasez.mjs → aibroker-client-BYG-GtAg.mjs} +1 -1
  4. package/dist/{chain-Cltx0NjY.mjs → chain-CGyAIHo4.mjs} +4 -4
  5. package/dist/{chain-Cltx0NjY.mjs.map → chain-CGyAIHo4.mjs.map} +1 -1
  6. package/dist/cli/index.mjs +12 -12
  7. package/dist/cli/program.mjs +12 -12
  8. package/dist/{config-D1omG_UU.mjs → config-CQCmH_gV.mjs} +17 -4
  9. package/dist/{config-D1omG_UU.mjs.map → config-CQCmH_gV.mjs.map} +1 -1
  10. package/dist/{config-DCYWFaEi.mjs → config-DjXq_W42.mjs} +1 -1
  11. package/dist/{context-handover-cache-Dt4HW1Ro.mjs → context-handover-cache-BzM7Qudn.mjs} +4 -4
  12. package/dist/{context-handover-cache-Dt4HW1Ro.mjs.map → context-handover-cache-BzM7Qudn.mjs.map} +1 -1
  13. package/dist/daemon/index.mjs +12 -12
  14. package/dist/{daemon-CmqJfeb9.mjs → daemon-B_TodsIS.mjs} +97 -23
  15. package/dist/daemon-B_TodsIS.mjs.map +1 -0
  16. package/dist/daemon-jviL3VVJ.mjs +19 -0
  17. package/dist/daemon-mcp/index.mjs +9 -9
  18. package/dist/daemon-mcp/index.mjs.map +1 -1
  19. package/dist/{factory-C9wGUe30.mjs → factory-DW7BCJJT.mjs} +1 -1
  20. package/dist/{factory-DiuntvwG.mjs → factory-EUUhSSoq.mjs} +7 -8
  21. package/dist/factory-EUUhSSoq.mjs.map +1 -0
  22. package/dist/{fallback-B_2ozQHw.mjs → fallback-CjdakQVu.mjs} +80 -9
  23. package/dist/fallback-CjdakQVu.mjs.map +1 -0
  24. package/dist/hooks/block-sleep-poll.mjs +7590 -0
  25. package/dist/hooks/block-sleep-poll.mjs.map +7 -0
  26. package/dist/hooks/capture-all-events.mjs +8 -4
  27. package/dist/hooks/capture-all-events.mjs.map +2 -2
  28. package/dist/hooks/capture-session-summary.mjs.map +1 -1
  29. package/dist/hooks/capture-tool-output.mjs.map +1 -1
  30. package/dist/hooks/cleanup-session-files.mjs.map +1 -1
  31. package/dist/hooks/context-compression-hook.mjs +6 -6
  32. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  33. package/dist/hooks/initialize-session.mjs.map +1 -1
  34. package/dist/hooks/load-core-context.mjs.map +1 -1
  35. package/dist/hooks/load-project-context.mjs.map +2 -2
  36. package/dist/hooks/post-compact-inject.mjs +1 -1
  37. package/dist/hooks/post-compact-inject.mjs.map +2 -2
  38. package/dist/hooks/route-agents-to-worker.mjs +5 -4
  39. package/dist/hooks/route-agents-to-worker.mjs.map +3 -3
  40. package/dist/hooks/security-validator.mjs +5 -4
  41. package/dist/hooks/security-validator.mjs.map +3 -3
  42. package/dist/hooks/stop-hook.mjs.map +1 -1
  43. package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
  44. package/dist/hooks/whisper-rules.mjs +5 -4
  45. package/dist/hooks/whisper-rules.mjs.map +3 -3
  46. package/dist/hooks/worker-proxy.mjs +5 -4
  47. package/dist/hooks/worker-proxy.mjs.map +3 -3
  48. package/dist/hooks/worker-status-line.mjs +10 -7
  49. package/dist/hooks/worker-status-line.mjs.map +3 -3
  50. package/dist/hooks/worker-supervision.mjs +5 -4
  51. package/dist/hooks/worker-supervision.mjs.map +3 -3
  52. package/dist/{main-resolver-DYNxxNwJ.mjs → main-resolver-BFQfU5xo.mjs} +100 -21
  53. package/dist/main-resolver-BFQfU5xo.mjs.map +1 -0
  54. package/dist/main-resolver-VMBENifT.mjs +7 -0
  55. package/dist/{planner-DsZZEZCA.mjs → planner-DN967Lvm.mjs} +83 -30
  56. package/dist/planner-DN967Lvm.mjs.map +1 -0
  57. package/dist/{program-B5miXqQa.mjs → program-BnsjB57Q.mjs} +278 -92
  58. package/dist/program-BnsjB57Q.mjs.map +1 -0
  59. package/dist/router-C5hPk9YP.mjs +3 -0
  60. package/dist/{router-BdxdsAvs.mjs → router-DjFZIoDl.mjs} +2 -1
  61. package/dist/router-DjFZIoDl.mjs.map +1 -0
  62. package/dist/{run-XaA-UPdW.mjs → run-FeM1_UVa.mjs} +102 -18
  63. package/dist/run-FeM1_UVa.mjs.map +1 -0
  64. package/dist/{run-env-DyTWgZC9.mjs → run-env-CEHTeIUM.mjs} +2 -2
  65. package/dist/{run-env-DyTWgZC9.mjs.map → run-env-CEHTeIUM.mjs.map} +1 -1
  66. package/dist/{server-CzqHGbKt.mjs → server-CekGOJcp.mjs} +2 -2
  67. package/dist/{server-CzqHGbKt.mjs.map → server-CekGOJcp.mjs.map} +1 -1
  68. package/dist/{session-keepalive-DDsEo81Q.mjs → session-keepalive-Bc9aT678.mjs} +5 -5
  69. package/dist/{session-keepalive-DDsEo81Q.mjs.map → session-keepalive-Bc9aT678.mjs.map} +1 -1
  70. package/dist/{tools-Diro2s8J.mjs → tools-qXyHFwNj.mjs} +2 -2
  71. package/dist/{tools-Diro2s8J.mjs.map → tools-qXyHFwNj.mjs.map} +1 -1
  72. package/dist/{tools-KgL1rkPD.mjs → tools-r-7ZG8B5.mjs} +3 -3
  73. package/dist/{wakeup-D0uvqLsE.mjs → wakeup-C48hcN6O.mjs} +2 -2
  74. package/dist/wakeup-C48hcN6O.mjs.map +1 -0
  75. package/dist/{work-queue-worker-DL9lu8sN.mjs → work-queue-worker-BL4dq5P7.mjs} +2 -2
  76. package/dist/{work-queue-worker-DL9lu8sN.mjs.map → work-queue-worker-BL4dq5P7.mjs.map} +1 -1
  77. package/dist/work-queue-worker-DVOVcEnO.mjs +11 -0
  78. package/docker/init.sql +1 -1
  79. package/docs/commands/README.md +4 -4
  80. package/docs/commands/daemon.md +6 -6
  81. package/docs/commands/session.md +2 -2
  82. package/docs/commands/worker.md +5 -2
  83. package/docs/workers-config.md +5 -1
  84. package/package.json +1 -1
  85. package/plugins/context-preservation/hooks/hooks.json +38 -31
  86. package/plugins/context-preservation/plugin.json +1 -4
  87. package/plugins/core/hooks/hooks.json +51 -46
  88. package/plugins/core/plugin.json +1 -4
  89. package/plugins/creative/plugin.json +1 -5
  90. package/plugins/observability/hooks/hooks.json +87 -72
  91. package/plugins/observability/plugin.json +1 -5
  92. package/plugins/productivity/hooks/hooks.json +22 -19
  93. package/plugins/productivity/plugin.json +2 -5
  94. package/plugins/semantic-search/plugin.json +1 -16
  95. package/plugins/ui/hooks/hooks.json +17 -14
  96. package/plugins/ui/plugin.json +1 -5
  97. package/plugins/zettelkasten/plugin.json +1 -14
  98. package/src/hooks/session-autosave.sh +4 -2
  99. package/src/hooks/session-stop.sh +2 -2
  100. package/src/hooks/ts/capture-all-events.ts +3 -3
  101. package/src/hooks/ts/lib/metadata-extraction.ts +3 -2
  102. package/src/hooks/ts/lib/pai-paths.test.ts +1 -1
  103. package/src/hooks/ts/lib/sleep-poll-gate.test.ts +62 -0
  104. package/src/hooks/ts/lib/sleep-poll-gate.ts +77 -0
  105. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +1 -1
  106. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +1 -1
  107. package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
  108. package/src/hooks/ts/pre-tool-use/block-sleep-poll.test.ts +44 -0
  109. package/src/hooks/ts/pre-tool-use/block-sleep-poll.ts +50 -0
  110. package/src/hooks/ts/session-end/capture-session-summary.ts +1 -1
  111. package/src/hooks/ts/session-start/initialize-session.ts +2 -2
  112. package/src/hooks/ts/session-start/load-core-context.ts +2 -2
  113. package/src/hooks/ts/session-start/load-project-context.ts +1 -1
  114. package/src/hooks/ts/stop/stop-hook.ts +1 -1
  115. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +1 -1
  116. package/statusline-command.sh +26 -11
  117. package/dist/daemon-B6hx1Ysz.mjs +0 -19
  118. package/dist/daemon-CmqJfeb9.mjs.map +0 -1
  119. package/dist/factory-DiuntvwG.mjs.map +0 -1
  120. package/dist/fallback-B_2ozQHw.mjs.map +0 -1
  121. package/dist/main-resolver-DYNxxNwJ.mjs.map +0 -1
  122. package/dist/main-resolver-Df65bPAi.mjs +0 -7
  123. package/dist/planner-DsZZEZCA.mjs.map +0 -1
  124. package/dist/program-B5miXqQa.mjs.map +0 -1
  125. package/dist/router-BXOf_e9e.mjs +0 -3
  126. package/dist/router-BdxdsAvs.mjs.map +0 -1
  127. package/dist/run-XaA-UPdW.mjs.map +0 -1
  128. package/dist/wakeup-D0uvqLsE.mjs.map +0 -1
  129. package/dist/work-queue-worker-BY7xn4pw.mjs +0 -11
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wakeup-C48hcN6O.mjs","names":[],"sources":["../src/cli/commands/detect.ts","../src/memory/wakeup.ts"],"sourcesContent":["/**\n * Project detection logic for PAI.\n *\n * detectProject(cwd) — given a filesystem path, returns the best matching\n * project from the registry:\n * 1. Exact path match\n * 2. Longest parent match (project whose root_path is an ancestor of cwd)\n *\n * Exported for use by the CLI `pai project detect` command and the MCP\n * `project_detect` tool.\n */\n\nimport { resolve } from \"node:path\";\nimport { getRegistryBackend } from \"../../storage/factory.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DetectedProject {\n id: number;\n slug: string;\n display_name: string;\n root_path: string;\n encoded_dir: string;\n type: string;\n status: string;\n session_count: number;\n last_session_date: string | null;\n match_type: \"exact\" | \"parent\";\n /** Only set when match_type is 'parent' — the portion of cwd below root_path */\n relative_path: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Core detection function\n// ---------------------------------------------------------------------------\n\n/**\n * Detect which registered project a filesystem path belongs to.\n *\n * @param cwd Absolute path to detect (defaults to process.cwd())\n * @returns The best matching project, or null if no match\n */\nexport async function detectProject(cwd?: string): Promise<DetectedProject | null> {\n const target = resolve(cwd ?? process.cwd());\n const backend = await getRegistryBackend();\n\n // Load all active projects ordered by root_path length descending\n // so the longest (most specific) match wins in a linear scan.\n const projects = await backend.listProjectsByPathLengthDesc({ excludeArchived: true });\n\n let matched: (typeof projects)[number] | null = null;\n let matchType: \"exact\" | \"parent\" = \"exact\";\n\n for (const p of projects) {\n const root = resolve(p.root_path);\n if (target === root) {\n matched = p;\n matchType = \"exact\";\n break;\n }\n if (!matched && target.startsWith(root + \"/\")) {\n matched = p;\n matchType = \"parent\";\n // Keep scanning — a longer root_path match might exist (but shouldn't\n // since we sorted by length desc). Safety break anyway once found.\n break;\n }\n }\n\n if (!matched) return null;\n\n // Enrich with session stats\n const sessionCount = await backend.countSessionsForProject(matched.id);\n const lastDate = await backend.getMostRecentSessionDate(matched.id);\n\n const relative =\n matchType === \"parent\"\n ? target.slice(resolve(matched.root_path).length + 1)\n : null;\n\n return {\n id: matched.id,\n slug: matched.slug,\n display_name: matched.display_name,\n root_path: matched.root_path,\n encoded_dir: matched.encoded_dir,\n type: matched.type,\n status: matched.status,\n session_count: sessionCount,\n last_session_date: lastDate,\n match_type: matchType,\n relative_path: relative,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format a DetectedProject for human-readable CLI output.\n */\nexport function formatDetection(d: DetectedProject): string {\n const lines: string[] = [\n `slug: ${d.slug}`,\n `display_name: ${d.display_name}`,\n `root_path: ${d.root_path}`,\n `type: ${d.type}`,\n `status: ${d.status}`,\n `match: ${d.match_type}${d.relative_path ? ` (+${d.relative_path})` : \"\"}`,\n `sessions: ${d.session_count}`,\n ];\n if (d.last_session_date) {\n lines.push(`last_session: ${d.last_session_date}`);\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * Format a DetectedProject as JSON for machine consumption.\n */\nexport function formatDetectionJson(d: DetectedProject): string {\n return JSON.stringify(\n {\n slug: d.slug,\n display_name: d.display_name,\n root_path: d.root_path,\n encoded_dir: d.encoded_dir,\n type: d.type,\n status: d.status,\n match_type: d.match_type,\n relative_path: d.relative_path,\n session_count: d.session_count,\n last_session_date: d.last_session_date,\n },\n null,\n 2\n );\n}\n","/**\n * Wake-up context system — progressive context loading inspired by mempalace.\n *\n * Layers:\n * L0 Identity (~100 tokens) — user identity from ~/.pai/identity.txt. Always loaded.\n * L1 Essential Story (~500-800t) — top session notes for the project, key lines extracted.\n * L2 On-Demand — triggered by topic queries (handled by memory_search).\n * L3 Deep Search — unlimited federated memory search (memory_search tool).\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { paiHomePath, migratePaiFile, type MigrateFileResult } from \"../config/pai-home.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Maximum tokens for the L1 essential story block. Approx 4 chars/token.\n * Measured 2026-09-20: 800 tokens was mostly stale bullets. Reduced to 300.\n */\nconst L1_TOKEN_BUDGET = 300;\nconst L1_CHAR_BUDGET = L1_TOKEN_BUDGET * 4; // ~1600 chars\n\n/** Maximum session notes to scan when building L1. Reduced from 10 to 3 (2026-09-20). */\nconst L1_MAX_NOTES = 3;\n\n/** Sections to extract from session notes (in priority order). */\nconst EXTRACT_SECTIONS = [\n \"Work Done\",\n \"Key Decisions\",\n \"Next Steps\",\n \"Checkpoint\",\n];\n\n/** Identity file location. */\nconst IDENTITY_FILE = join(homedir(), \".pai\", \"identity.txt\");\n\n// ---------------------------------------------------------------------------\n// L0: Identity\n// ---------------------------------------------------------------------------\n\n/**\n * Load L0 identity. Prefers the PAI_HOME location (identity.txt migrated by\n * `pai config migrate`), falling back to the legacy ~/.pai/identity.txt so a\n * not-yet-migrated file keeps working. Returns \"\" if neither exists. Never\n * throws.\n */\nexport function loadL0Identity(\n opts: { newPath?: string; legacyPath?: string } = {}\n): string {\n const newPath = opts.newPath ?? paiHomePath(\"identity.txt\");\n const legacyPath = opts.legacyPath ?? IDENTITY_FILE;\n const path = existsSync(newPath) ? newPath : legacyPath;\n if (!existsSync(path)) return \"\";\n try {\n return readFileSync(path, \"utf-8\").trim();\n } catch {\n return \"\";\n }\n}\n\n/**\n * Move ~/.pai/identity.txt into PAI_HOME, following the same copy /\n * verify-byte-identical / rename-aside semantics as every other per-user\n * file (see migratePaiFile). `from`/`to` are overridable for tests.\n */\nexport function migrateIdentityFile(\n opts: { dryRun?: boolean; from?: string; to?: string } = {}\n): MigrateFileResult {\n const toPath = opts.to ?? paiHomePath(\"identity.txt\");\n const fromPath = opts.from ?? IDENTITY_FILE;\n return migratePaiFile(toPath, [fromPath], { dryRun: opts.dryRun });\n}\n\n// ---------------------------------------------------------------------------\n// L1: Essential Story\n// ---------------------------------------------------------------------------\n\n/**\n * Find the Notes directory for a project given its root_path from the registry.\n * Checks local Notes/ first, then central ~/.claude/projects/... path.\n */\nfunction findNotesDirForProject(rootPath: string): string | null {\n // Check local Notes directories first\n const localCandidates = [\n join(rootPath, \"Notes\"),\n join(rootPath, \"notes\"),\n join(rootPath, \".claude\", \"Notes\"),\n ];\n for (const p of localCandidates) {\n if (existsSync(p)) return p;\n }\n\n // Fall back to central ~/.claude/projects/{encoded}/Notes\n const encoded = rootPath\n .replace(/\\//g, \"-\")\n .replace(/\\./g, \"-\")\n .replace(/ /g, \"-\");\n const centralNotes = join(\n homedir(),\n \".claude\",\n \"projects\",\n encoded,\n \"Notes\"\n );\n if (existsSync(centralNotes)) return centralNotes;\n\n return null;\n}\n\n/**\n * Recursively find all .md session note files in a Notes directory.\n * Handles both flat layout (Notes/*.md) and month-subdirectory layout\n * (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename\n * (note numbers are monotonically increasing, so lexicographic = newest-last,\n * so we reverse).\n */\nfunction findSessionNotes(notesDir: string): string[] {\n const result: string[] = [];\n\n const scanDir = (dir: string) => {\n if (!existsSync(dir)) return;\n let entries: Array<{ name: string; isDir: boolean }>;\n try {\n entries = readdirSync(dir, { withFileTypes: true })\n .map((e) => ({ name: e.name, isDir: e.isDirectory() }));\n } catch {\n return;\n }\n\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDir) {\n // Recurse into YYYY/MM subdirectories\n scanDir(fullPath);\n } else if (entry.name.match(/^\\d{3,4}[\\s_-].*\\.md$/)) {\n result.push(fullPath);\n }\n }\n };\n\n scanDir(notesDir);\n\n // Sort newest-first by the DATE in the filename, not the note number.\n //\n // The note number is only monotonic within one uninterrupted numbering run.\n // It restarts — per month directory, and after a registry merge renumbers a\n // project — so \"highest number\" and \"most recent\" diverge. Observed live:\n // `0184 - 2026-02-22` outranked `0008 - 2026-08-01`, and the wake-up context\n // handed a resumed session five-month-old material as its recent history.\n //\n // `NNNN - YYYY-MM-DD - Title.md` is the enforced layout, so the date is the\n // reliable key. Number breaks ties within a day; mtime is the last resort\n // for notes that predate the naming convention.\n const dateOf = (p: string): string =>\n basename(p).match(/(\\d{4}-\\d{2}-\\d{2})/)?.[1] ?? \"\";\n const numberOf = (p: string): number =>\n parseInt(basename(p).match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const mtimeOf = (p: string): number => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return 0;\n }\n };\n\n result.sort((a, b) => {\n const dateA = dateOf(a);\n const dateB = dateOf(b);\n // Undated notes sort last rather than winning on an empty string.\n if (dateA !== dateB) {\n if (!dateA) return 1;\n if (!dateB) return -1;\n return dateB.localeCompare(dateA);\n }\n const byNumber = numberOf(b) - numberOf(a);\n if (byNumber !== 0) return byNumber;\n return mtimeOf(b) - mtimeOf(a);\n });\n\n return result;\n}\n\n/**\n * Extract the most important lines from a session note.\n * Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.\n * Returns a condensed string under maxChars.\n */\nfunction normalizeLine(line: string): string {\n return line\n .replace(/^(- \\[[ x]\\] |- |\\* |\\d+\\.\\s)/, \"\")\n .trim()\n .replace(/\\s+/g, \" \");\n}\n\nfunction extractKeyLines(\n content: string,\n maxChars: number,\n seen: Set<string> = new Set(),\n seenLabels: Set<string> = new Set()\n): string {\n const lines = content.split(\"\\n\");\n const selected: string[] = [];\n let inTargetSection = false;\n let currentSection = \"\";\n let charCount = 0;\n // Heading text seen since the last line was emitted. Buffered rather than\n // pushed immediately: a heading with no surviving lines under it (every\n // line deduped against an earlier note) must not appear at all.\n let pendingLabel: string | null = null;\n\n // First pass: collect lines from priority sections\n for (const line of lines) {\n // Detect section headers\n const h2Match = line.match(/^## (.+)$/);\n const h3Match = line.match(/^### (.+)$/);\n if (h2Match) {\n currentSection = h2Match[1];\n inTargetSection = EXTRACT_SECTIONS.some((s) =>\n currentSection.toLowerCase().includes(s.toLowerCase())\n );\n pendingLabel = null;\n continue;\n }\n if (h3Match) {\n // Checkpoints / sub-sections — buffer as a candidate label.\n if (inTargetSection) {\n pendingLabel = h3Match[1];\n }\n continue;\n }\n\n if (!inTargetSection) continue;\n\n // Skip blank lines and HTML comments\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"<!--\") || trimmed === \"---\") continue;\n\n // Include checkbox items, bold text, and plain text lines\n if (\n trimmed.startsWith(\"- \") ||\n trimmed.startsWith(\"* \") ||\n trimmed.match(/^\\d+\\./) ||\n trimmed.startsWith(\"**\")\n ) {\n const normalized = normalizeLine(trimmed);\n if (seen.has(normalized)) continue;\n\n let labelText = \"\";\n let labelKey = \"\";\n if (pendingLabel !== null) {\n labelKey = pendingLabel.toLowerCase().trim();\n if (!seenLabels.has(labelKey)) {\n labelText = `[${pendingLabel}]`;\n }\n }\n\n if (charCount + labelText.length + trimmed.length + 2 > maxChars) break;\n\n if (labelText) {\n selected.push(labelText);\n seenLabels.add(labelKey);\n charCount += labelText.length + 1;\n }\n pendingLabel = null;\n\n seen.add(normalized);\n selected.push(trimmed);\n charCount += trimmed.length + 1;\n }\n }\n\n return selected.join(\"\\n\");\n}\n\n/**\n * Build the L1 essential story block.\n *\n * Reads the most recent session notes for the project and extracts the key\n * lines (Work Done, Key Decisions, Next Steps) within the token budget.\n *\n * @param rootPath The project root path (from the registry).\n * @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).\n * @returns Formatted L1 block, or empty string if no notes found.\n */\nexport function buildL1EssentialStory(\n rootPath: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const charBudget = tokenBudget * 4;\n const notesDir = findNotesDirForProject(rootPath);\n if (!notesDir) return \"\";\n\n const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);\n if (noteFiles.length === 0) return \"\";\n\n const sections: string[] = [];\n const seen = new Set<string>();\n const seenLabels = new Set<string>();\n let remaining = charBudget;\n\n for (const noteFile of noteFiles) {\n if (remaining <= 50) break;\n\n let content: string;\n try {\n content = readFileSync(noteFile, \"utf-8\");\n } catch {\n continue;\n }\n\n // Extract the note date and title from the filename\n const name = basename(noteFile);\n const titleMatch = name.match(/^\\d+ - (\\d{4}-\\d{2}-\\d{2}) - (.+)\\.md$/);\n const dateLabel = titleMatch ? titleMatch[1] : \"\";\n const titleLabel = titleMatch\n ? titleMatch[2]\n : name.replace(/^\\d+ - /, \"\").replace(/\\.md$/, \"\");\n\n // Skip if nothing useful extracted from this note\n const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);\n const extracted = extractKeyLines(content, perNoteChars, seen, seenLabels);\n // A note whose every extracted line survived only as a label (all its\n // real lines deduped against an earlier, newer note) contributes nothing\n // — including its title.\n const hasContentLine = extracted\n .split(\"\\n\")\n .some((l) => !/^\\[.*\\]$/.test(l));\n if (!extracted || !hasContentLine) continue;\n\n const noteBlock = `[${dateLabel} - ${titleLabel}]\\n${extracted}`;\n sections.push(noteBlock);\n remaining -= noteBlock.length + 1;\n }\n\n if (sections.length === 0) return \"\";\n\n return sections.join(\"\\n\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Combined: buildWakeupContext\n// ---------------------------------------------------------------------------\n\n/**\n * Build the combined wake-up context block (L0 + L1).\n *\n * Returns a formatted string suitable for injection as a system-reminder,\n * or an empty string if both layers are empty.\n *\n * @param rootPath Project root path for L1 note lookup. Optional.\n * @param tokenBudget L1 token budget. Default 800.\n * @param opts.skipStory Skip the L1 essential story. Set when a handover\n * checkpoint was already injected — the story re-summarises the same note\n * the handover came from, at lower quality.\n */\nexport function buildWakeupContext(\n rootPath?: string,\n tokenBudget = L1_TOKEN_BUDGET,\n opts: { skipStory?: boolean } = {}\n): string {\n const identity = loadL0Identity();\n const essentialStory = rootPath && !opts.skipStory\n ? buildL1EssentialStory(rootPath, tokenBudget)\n : \"\";\n\n if (!identity && !essentialStory) return \"\";\n\n const parts: string[] = [];\n\n if (identity) {\n parts.push(`## L0 Identity\\n\\n${identity}`);\n }\n\n if (essentialStory) {\n parts.push(`## L1 Essential Story\\n\\n${essentialStory}`);\n }\n\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { A as archiveSessionFilesToSessionsDir, D as appendCheckpoint, E as addWorkToSessionNote, F as performScan, O as finalizeSessionNote, _ as spawnSummarizer, a as enqueue, b as updateTodoContinue, f as contentToText$2, h as handleSessionSummary, i as dequeue, j as findNotesDir, k as getCurrentNotePath, l as markCompleted, m as getGitContext, n as writeContextHandoverCache, p as findLatestJsonl, r as cleanup, s as hasPendingOrProcessingOfType, t as readContextHandoverCache, u as markFailed, y as sessionIdFromTranscript } from "./context-handover-cache-Dt4HW1Ro.mjs";
1
+ import { A as archiveSessionFilesToSessionsDir, D as appendCheckpoint, E as addWorkToSessionNote, F as performScan, O as finalizeSessionNote, _ as spawnSummarizer, a as enqueue, b as updateTodoContinue, f as contentToText$2, h as handleSessionSummary, i as dequeue, j as findNotesDir, k as getCurrentNotePath, l as markCompleted, m as getGitContext, n as writeContextHandoverCache, p as findLatestJsonl, r as cleanup, s as hasPendingOrProcessingOfType, t as readContextHandoverCache, u as markFailed, y as sessionIdFromTranscript } from "./context-handover-cache-BzM7Qudn.mjs";
2
2
  import { d as registryBackend, k as storageBackend } from "./state-n13uPK5B.mjs";
3
3
  import { t as detectTopicShift } from "./detector-3g7XuU1r.mjs";
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -564,4 +564,4 @@ function extractWorkFromTranscript(lines) {
564
564
 
565
565
  //#endregion
566
566
  export { stopWorker as i, notifyNewWork as n, startWorker as r, enqueueRegistryScan as t };
567
- //# sourceMappingURL=work-queue-worker-DL9lu8sN.mjs.map
567
+ //# sourceMappingURL=work-queue-worker-BL4dq5P7.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"work-queue-worker-DL9lu8sN.mjs","names":["contentToText","contentToText"],"sources":["../src/daemon/templates/context-handover-prompt.ts","../src/daemon/context-handover-worker.ts","../src/daemon/topic-detect-worker.ts","../src/daemon/work-queue-worker.ts"],"sourcesContent":["/**\n * context-handover-prompt.ts — prompt template for the threshold-triggered\n * pre-compaction handover.\n *\n * This is NOT the session-note summary (session-summary-prompt.ts already\n * does that well, and Claude Code's own native compaction summary is also\n * genuinely detailed on file paths and issue numbers). This exists for the\n * one thing neither of those reliably carries: the reasoning behind what\n * happened, which compresses to bullets or vanishes outright once a native\n * compaction fires.\n *\n * FRAMING (lifted from a working example of this exact mechanism, read\n * before writing this prompt): the handover is a DIFF against everything\n * already durable, not a summary of the session. The successor can read\n * git, the tracker, and the notes — so this must not repeat what is\n * recoverable there. What cannot be recovered is what only ever existed in\n * the conversation: reasoning, rejected alternatives, negative results and\n * what they cost, traps discovered but invisible in the repo, and specific\n * values/ids/paths that would otherwise have to be dug up again.\n *\n * Length is not a target. A short session earns a short handover; every\n * line in a long one must be something the successor could not have\n * obtained anywhere else.\n */\n\nexport interface HandoverPromptParams {\n /** Interleaved user + assistant turns, oldest first, each prefixed with\n * who said it — this prompt needs the assistant's reasoning, not just\n * what the user asked for. */\n turns: string[];\n /** Git log output for the session period, if any. */\n gitLog: string;\n cwd: string;\n /** The previous cached handover's summary text, if this session already\n * produced one (the warmup handover, when this call is the refresh).\n * Passed through so the new handover can carry it forward rather than\n * silently dropping everything it already captured — see the \"pointer\n * to the previous handover, kept rather than superseded\" structure. */\n previousHandover?: string;\n}\n\nexport function buildContextHandoverPrompt(params: HandoverPromptParams): string {\n const { turns, gitLog, cwd, previousHandover } = params;\n\n const turnsSection = turns.length > 0\n ? turns.join(\"\\n\\n\")\n : \"(No turns extracted)\";\n\n const gitSection = gitLog.trim() || \"(No git commits during this session)\";\n\n const previousSection = previousHandover\n ? `\\nA PREVIOUS HANDOVER already exists for this session (from an earlier, \\\nlower threshold). Carry it forward — start your response with a one-line \\\npointer to it (\"Carries forward from the previous handover, which stays for \\\nits detail.\") and then write only what has happened SINCE it that a compact \\\nwould also lose. Do not re-derive or repeat what it already covered.\\n\\n\\\nPREVIOUS HANDOVER:\\n${previousHandover}\\n`\n : \"\";\n\n return `This session is approaching a context-window compaction. Write the \\\nhandover that would let a successor continue WITHOUT re-reading this \\\nconversation and WITHOUT asking the user to repeat themselves.\n\nProject directory: ${cwd}\n${previousSection}\nSCOPING RULE — the single most important instruction here: this is a DIFF \\\nagainst everything already durable, not a summary of the session. Assume \\\nthe successor can and will read git history, the issue tracker, and any \\\nnotes files themselves. Do NOT restate anything recoverable there — no file \\\ninventories, no \"implemented X\" restatements, no chronology of commands run. \\\nWrite down ONLY what existed exclusively in this conversation and would \\\notherwise be gone: the reasoning, not the result.\n\nWrite ONLY the sections below that this session actually has content for — \\\nomit a section entirely rather than writing \"none\" or padding it:\n\nWHERE THIS SESSION ENDED — the concrete state right now: what is verified \\\nworking (tests green, tree clean, etc.) versus merely attempted, any branch \\\nor working-tree state that isn't obvious from git status alone (e.g. a \\\ndetached HEAD, an intentionally uncommitted change, a stash).\n\nWHAT CLOSED — each item paired with the evidence that closed it, not just \\\nthe claim that it did.\n\nWHAT IS FILED AND STILL OPEN — each with its next concrete step or who/what \\\nit is waiting on.\n\nDECISIONS & REASONING — choices actually made, each with why this way and \\\nnot another way.\n\nREJECTED ALTERNATIVES — approaches considered and set aside, and why. An \\\nalternative without a reason is not worth recording.\n\nNEGATIVE RESULTS — something investigated that turned out NOT to be the \\\nanswer, kept WITH the reasoning that ruled it out (e.g. \"X looked like the \\\ncause — it wasn't; the actual mechanism was Y\"). Without the reasoning \\\nattached, a successor re-investigates the same dead end.\n\nTRAPS — anything discovered that is invisible from the repo alone and would \\\notherwise bite a successor immediately (a required manual step, an ordering \\\nconstraint, a state that looks fine but isn't).\n\nCRITICAL VALUES — any specific id, path, URL, credential name, version \\\nnumber, or configuration value that appeared in conversation and whose loss \\\nwould force someone to go find it again.\n\nTHE FEW THINGS THAT MATTER MOST, IF NOTHING ELSE IS READ — at most 3 bullets. \\\nForces a priority judgement; do not pad this to fill space.\n\nFormat your response as markdown with those section headers (only the ones \\\nyou have content for), no other structure.\n\n---\n\nCONVERSATION:\n${turnsSection}\n\nGIT COMMITS:\n${gitSection}`;\n}\n","/**\n * context-handover-worker.ts — the threshold-triggered pre-compaction\n * handover.\n *\n * `session-summary-worker.ts` already spawns a model to write session notes,\n * but only from the PreCompact hook itself — which fires at the compaction\n * boundary, far too late to be injected INTO that same compaction's\n * post-compact context. This worker runs earlier, off a token-count\n * threshold (see ../hooks/ts/lib/context-fill.ts), so a model-written\n * handover already exists in a cache file by the time compaction happens.\n *\n * Deliberately narrow: this does not write a session note, does not touch\n * the session-summary cooldown file, and does not run KG extraction. It\n * writes one thing — a cache file the PreCompact hook can read — because its\n * only job is to exist before compaction does, not to duplicate what\n * session-summary-worker already does well.\n *\n * The prompt itself (see templates/context-handover-prompt.ts) treats the\n * handover as a DIFF against everything already durable — git, the tracker,\n * the notes — not a session summary, and a refresh run carries the previous\n * cached handover forward rather than overwriting it.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\n\nimport {\n findLatestJsonl,\n getGitContext,\n spawnSummarizer,\n contentToText,\n} from \"./session-summary-worker.js\";\nimport { buildContextHandoverPrompt } from \"./templates/context-handover-prompt.js\";\nimport {\n readContextHandoverCache,\n writeContextHandoverCache,\n type HandoverThreshold,\n} from \"../hooks/ts/lib/context-handover-cache.js\";\n\nexport type { HandoverThreshold } from \"../hooks/ts/lib/context-handover-cache.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ContextHandoverPayload {\n cwd: string;\n sessionId: string;\n transcriptPath?: string;\n threshold: HandoverThreshold;\n /** True when this fired via the \"no time for a clean crossing\" path\n * (see isImmediate in context-fill.ts) — logged, not otherwise acted on. */\n urgent?: boolean;\n}\n\n/** How much of the tail of the transcript to feed the summarizer. Generous —\n * this runs at most twice per session, not on every tool call. */\nconst MAX_TURN_CHARS = 150_000;\n\n/** Max turns to include (a hard floor even if MAX_TURN_CHARS is not hit). */\nconst MAX_TURNS = 120;\n\n// ---------------------------------------------------------------------------\n// Transcript → turns\n// ---------------------------------------------------------------------------\n\n/**\n * Interleaved \"User:\"/\"Assistant:\" turns, oldest-kept-from-the-end so a long\n * transcript is truncated to its most recent content rather than its\n * earliest. Unlike session-summary-worker's extractFromJsonl (which keeps\n * only user messages, because a session note doesn't need the assistant's\n * own reasoning) this keeps BOTH sides — the whole point of this prompt is\n * the assistant's reasoning, which lives in assistant turns.\n */\nfunction extractTurns(jsonlPath: string): string[] {\n let raw: string;\n try {\n raw = readFileSync(jsonlPath, \"utf-8\");\n } catch {\n return [];\n }\n\n if (raw.length > MAX_TURN_CHARS) {\n const truncPoint = raw.indexOf(\"\\n\", raw.length - MAX_TURN_CHARS);\n raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_TURN_CHARS);\n }\n\n const turns: string[] = [];\n for (const line of raw.trim().split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: Record<string, unknown>;\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3 && !text.startsWith(\"<system-reminder>\")) {\n turns.push(`User: ${text.slice(0, 2000)}`);\n }\n } else if (entry.type === \"assistant\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3) {\n turns.push(`Assistant: ${text.slice(0, 4000)}`);\n }\n }\n }\n\n return turns.slice(-MAX_TURNS);\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `context-handover` work item.\n *\n * THROWS on every failure path (missing input, no transcript, no turns, an\n * empty/failed summarizer run) rather than logging and returning silently.\n * That used to be a deliberate choice — avoid the work queue's own retry\n * piling up redundant LLM calls — but it made a real failure invisible: a\n * daemon restart mid-spawn (session 77084e72-...) left no cache, no error\n * anywhere the work queue's own stats could show, and a trigger-side marker\n * that (at the time) had already been set to \"done\". Throwing here makes\n * work-queue-worker.ts call markFailed(), which logs prominently AND is\n * visible via `work_queue_stats` / `pai daemon status` — not just a stderr\n * line that can scroll away. It is a second, faster (seconds-to-minutes\n * backoff) retry path alongside the primary one in context-handover-\n * trigger.ts (which retries on its own ~5-minute cadence based on whether a\n * cache actually appeared); the two are complementary, not redundant — a\n * failed cache write costs nothing to retry twice.\n */\nexport async function handleContextHandover(payload: ContextHandoverPayload): Promise<void> {\n const { cwd, sessionId, transcriptPath, threshold, urgent } = payload;\n\n if (!cwd || !sessionId) {\n throw new Error(\"[context-handover] payload missing cwd or sessionId\");\n }\n\n process.stderr.write(\n `[context-handover] Starting for ${cwd} (session=${sessionId}, threshold=${threshold}` +\n `${urgent ? \", urgent\" : \"\"}).\\n`\n );\n\n let jsonlPath: string | null = transcriptPath && existsSync(transcriptPath) ? transcriptPath : null;\n if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);\n if (!jsonlPath) {\n throw new Error(`[context-handover] No transcript found for ${cwd} (session=${sessionId})`);\n }\n\n const turns = extractTurns(jsonlPath);\n if (turns.length === 0) {\n throw new Error(`[context-handover] No turns extracted from ${jsonlPath} (session=${sessionId})`);\n }\n\n const gitLog = await getGitContext(cwd);\n\n // Carry the previous handover forward (see buildContextHandoverPrompt's\n // scoping rule) rather than silently overwriting it — a refresh at the\n // second threshold should not lose what the warmup handover already\n // captured.\n const previous = readContextHandoverCache(sessionId);\n const prompt = buildContextHandoverPrompt({\n turns,\n gitLog,\n cwd,\n previousHandover: previous?.summary,\n });\n process.stderr.write(`[context-handover] Sending ${prompt.length} char prompt to sonnet...\\n`);\n\n const summary = await spawnSummarizer(prompt, \"sonnet\");\n if (!summary || !summary.trim()) {\n // No cache write on this path — an existing cache from an earlier\n // successful run is left untouched rather than clobbered with nothing.\n throw new Error(`[context-handover] sonnet produced no output (session=${sessionId})`);\n }\n\n writeContextHandoverCache({\n sessionId,\n cwd,\n threshold,\n generatedAt: new Date().toISOString(),\n model: \"sonnet\",\n summary: summary.trim(),\n });\n\n process.stderr.write(\n `[context-handover] Wrote cache for session ${sessionId} (${summary.length} chars).\\n`\n );\n}\n","/**\n * topic-detect-worker.ts — Topic shift detection for session note splitting\n *\n * Processes `topic-detect` work items by:\n * 1. Extracting recent user messages from the JSONL transcript\n * 2. Running the BM25-based topic shift detector against the PAI memory DB\n * 3. If a shift is detected, recording a topic boundary marker\n *\n * The actual note splitting is handled by session-summary-worker.ts when it\n * processes the next `session-summary` work item — it uses the TOPIC: line\n * from the summarizer to decide whether to create a new note.\n *\n * This worker provides an additional signal: project-level topic shift\n * (e.g., conversation moved from project A to project B). The session\n * summary worker handles intra-project topic shifts (e.g., from \"dark mode\"\n * to \"keyboard IPC\" within the same project).\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nimport { detectTopicShift } from \"../topics/detector.js\";\nimport { registryBackend, storageBackend } from \"./daemon/state.js\";\nimport {\n findNotesDir,\n getCurrentNotePath,\n appendCheckpoint,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicDetectPayload {\n /** Working directory of the session. */\n cwd: string;\n /** Recent conversation context (extracted user messages). */\n context?: string;\n /** The project slug the session is currently routed to. */\n currentProject?: string;\n /** Path to the JSONL transcript (optional — used to extract context if not provided). */\n transcriptPath?: string;\n /** Session ID (for logging). */\n sessionId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// JSONL context extraction (lightweight — just last few user messages)\n// ---------------------------------------------------------------------------\n\nconst MAX_CONTEXT_MESSAGES = 5;\nconst MAX_CONTEXT_CHARS = 2000;\n\n/**\n * Extract recent user messages from a JSONL transcript for topic detection.\n * Takes only the last few messages to represent the current topic.\n */\nfunction extractRecentContext(jsonlPath: string): string {\n try {\n const raw = readFileSync(jsonlPath, \"utf-8\");\n // Read from the end — last 50KB should be more than enough\n const tail = raw.length > 50_000 ? raw.slice(-50_000) : raw;\n const lines = tail.trim().split(\"\\n\");\n\n const messages: string[] = [];\n\n for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {\n const line = lines[i].trim();\n if (!line) continue;\n\n try {\n const entry = JSON.parse(line) as Record<string, unknown>;\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n if (msg?.content) {\n const text = contentToText(msg.content);\n if (text && text.length > 3) {\n messages.unshift(text.slice(0, 500));\n }\n }\n }\n } catch { /* skip invalid JSON */ }\n }\n\n return messages.join(\"\\n\\n\").slice(0, MAX_CONTEXT_CHARS);\n } catch {\n return \"\";\n }\n}\n\n/** Convert Claude content (string or content block array) to plain text. */\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Topic boundary file — signals to session-summary-worker\n// ---------------------------------------------------------------------------\n\nconst TOPIC_BOUNDARY_FILE = \"topic-boundary.json\";\n\ninterface TopicBoundary {\n timestamp: string;\n previousProject: string | null;\n suggestedProject: string | null;\n confidence: number;\n context: string;\n}\n\n/**\n * Write a topic boundary marker into the Notes directory.\n * The session-summary-worker checks for this file and uses it as an\n * additional signal that a new note should be created.\n */\nfunction writeTopicBoundary(\n cwd: string,\n boundary: TopicBoundary\n): void {\n try {\n const notesInfo = findNotesDir(cwd);\n const boundaryPath = join(notesInfo.path, TOPIC_BOUNDARY_FILE);\n writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), \"utf-8\");\n process.stderr.write(\n `[topic-detect] Wrote topic boundary marker: ${boundaryPath}\\n`\n );\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `topic-detect` work item.\n *\n * Called by work-queue-worker.ts. Throws on fatal errors so the work queue\n * retry logic handles them.\n */\nexport async function handleTopicDetect(payload: TopicDetectPayload): Promise<void> {\n const { cwd, currentProject, transcriptPath, sessionId } = payload;\n\n if (!cwd) {\n throw new Error(\"topic-detect payload missing cwd\");\n }\n\n process.stderr.write(\n `[topic-detect] Starting for ${cwd}` +\n `${currentProject ? ` (project=${currentProject})` : \"\"}` +\n `${sessionId ? ` (session=${sessionId})` : \"\"}\\n`\n );\n\n // Check that daemon state is available\n if (!registryBackend || !storageBackend) {\n process.stderr.write(\n \"[topic-detect] Registry backend or storage backend not available — skipping.\\n\"\n );\n return;\n }\n\n // Extract context from payload or transcript\n let context = payload.context || \"\";\n\n if (!context && transcriptPath && existsSync(transcriptPath)) {\n context = extractRecentContext(transcriptPath);\n }\n\n if (!context || context.trim().length < 10) {\n process.stderr.write(\n \"[topic-detect] Insufficient context for topic detection — skipping.\\n\"\n );\n return;\n }\n\n process.stderr.write(\n `[topic-detect] Context: ${context.length} chars, checking against memory...\\n`\n );\n\n // Run the BM25-based topic shift detector\n const result = await detectTopicShift(registryBackend, storageBackend, {\n context,\n currentProject,\n threshold: 0.6,\n candidates: 20,\n });\n\n process.stderr.write(\n `[topic-detect] Result: shifted=${result.shifted}, ` +\n `suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, ` +\n `chunks=${result.chunkCount}\\n`\n );\n\n if (result.topProjects.length > 0) {\n process.stderr.write(\n `[topic-detect] Top projects: ${result.topProjects.map(\n (p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`\n ).join(\", \")}\\n`\n );\n }\n\n if (result.shifted) {\n // Record the topic boundary\n writeTopicBoundary(cwd, {\n timestamp: new Date().toISOString(),\n previousProject: result.currentProject,\n suggestedProject: result.suggestedProject,\n confidence: result.confidence,\n context: context.slice(0, 200),\n });\n\n // Also append a checkpoint to the current session note\n try {\n const notesInfo = findNotesDir(cwd);\n const notePath = getCurrentNotePath(notesInfo.path);\n if (notePath) {\n appendCheckpoint(\n notePath,\n `Topic shift detected: conversation moved from **${result.currentProject}** ` +\n `to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). ` +\n `A new session note will be created for the new topic.`\n );\n }\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\\n`);\n }\n }\n\n process.stderr.write(\"[topic-detect] Done.\\n\");\n}\n","/**\n * work-queue-worker.ts — Daemon worker loop for the persistent work queue\n *\n * Runs every 5 seconds to drain the queue.\n * Handles 'session-end' work items by reading the transcript, extracting\n * work summaries, updating the session note, and updating TODO.md.\n * Handles 'session-summary' items by spawning Haiku for AI-powered note generation.\n * Handles 'context-handover' items by spawning Sonnet to write the\n * threshold-triggered pre-compaction reasoning handover (see\n * context-handover-worker.ts and hooks/ts/lib/context-fill.ts).\n * Handles 'registry-scan' items by running performScan() against the registry DB.\n *\n * Handles 'topic-detect' items by running BM25-based topic shift detection.\n * Other item types (note-update, todo-update) are stubs — they log and\n * complete immediately, ready for future expansion.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\n\nimport {\n dequeue,\n enqueue,\n markCompleted,\n markFailed,\n cleanup,\n getStats,\n hasPendingOrProcessingOfType,\n type WorkItem,\n} from \"./work-queue.js\";\n\nimport {\n handleSessionSummary,\n type SessionSummaryPayload,\n} from \"./session-summary-worker.js\";\n\nimport {\n handleContextHandover,\n type ContextHandoverPayload,\n} from \"./context-handover-worker.js\";\n\nimport {\n handleTopicDetect,\n type TopicDetectPayload,\n} from \"./topic-detect-worker.js\";\n\n// Registry scan — called from handleRegistryScan()\nimport { performScan } from \"../cli/commands/registry/scan.js\";\nimport { registryBackend } from \"./daemon/state.js\";\n\n// Hooks lib imports — resolving through the compiled JS path.\n// These are the same utilities used by stop-hook.ts.\nimport {\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n finalizeSessionNote,\n updateTodoContinue, sessionIdFromTranscript,\n archiveSessionFilesToSessionsDir,\n type WorkItem as NoteWorkItem,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst WORKER_INTERVAL_MS = 5_000;\nconst HOUSEKEEPING_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes\n\n// ---------------------------------------------------------------------------\n// Timers (stored so shutdown can clear them)\n// ---------------------------------------------------------------------------\n\nlet workerTimer: ReturnType<typeof setInterval> | null = null;\nlet housekeepingTimer: ReturnType<typeof setInterval> | null = null;\nlet _immediateSignal = false;\n\n// ---------------------------------------------------------------------------\n// Worker loop\n// ---------------------------------------------------------------------------\n\n/** Start the background worker and housekeeping timers. */\nexport function startWorker(): void {\n process.stderr.write(\"[work-queue-worker] Starting worker loop.\\n\");\n\n workerTimer = setInterval(async () => {\n try {\n await processNextItem();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Uncaught error in worker loop: ${e}\\n`);\n }\n }, WORKER_INTERVAL_MS);\n\n housekeepingTimer = setInterval(() => {\n try {\n cleanup();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Housekeeping error: ${e}\\n`);\n }\n }, HOUSEKEEPING_INTERVAL_MS);\n\n process.stderr.write(\"[work-queue-worker] Worker started (interval=5s, housekeeping=10min).\\n\");\n}\n\n/** Stop the worker timers gracefully. */\nexport function stopWorker(): void {\n if (workerTimer !== null) {\n clearInterval(workerTimer);\n workerTimer = null;\n }\n if (housekeepingTimer !== null) {\n clearInterval(housekeepingTimer);\n housekeepingTimer = null;\n }\n process.stderr.write(\"[work-queue-worker] Worker stopped.\\n\");\n}\n\n/**\n * Signal that new work has been enqueued.\n * The worker will run on its next tick — we don't need to reset the timer\n * since 5 s is fast enough. The flag allows future optimisations.\n */\nexport function notifyNewWork(): void {\n _immediateSignal = true;\n}\n\n// ---------------------------------------------------------------------------\n// Item processor (sequential — one item per tick)\n// ---------------------------------------------------------------------------\n\nasync function processNextItem(): Promise<void> {\n const item = dequeue();\n if (!item) return;\n\n process.stderr.write(\n `[work-queue-worker] Processing ${item.type} (id=${item.id}, attempt=${item.attempts}).\\n`\n );\n\n try {\n switch (item.type) {\n case \"session-end\":\n await handleSessionEnd(item);\n break;\n\n case \"session-summary\":\n await handleSessionSummary(item.payload as unknown as SessionSummaryPayload);\n break;\n\n case \"context-handover\":\n await handleContextHandover(item.payload as unknown as ContextHandoverPayload);\n break;\n\n case \"topic-detect\":\n await handleTopicDetect(item.payload as unknown as TopicDetectPayload);\n break;\n\n case \"registry-scan\":\n await handleRegistryScan();\n break;\n\n case \"note-update\":\n case \"todo-update\":\n // Stubs — log and complete\n process.stderr.write(\n `[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\\n`\n );\n break;\n\n default:\n throw new Error(`Unknown work item type: ${(item as WorkItem).type}`);\n }\n\n markCompleted(item.id);\n process.stderr.write(`[work-queue-worker] Completed ${item.type} (id=${item.id}).\\n`);\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n markFailed(item.id, msg);\n }\n}\n\n// ---------------------------------------------------------------------------\n// session-end handler\n// ---------------------------------------------------------------------------\n\n/**\n * Process a 'session-end' work item.\n *\n * Expected payload:\n * transcriptPath: string — absolute path to the .jsonl transcript\n * cwd: string — working directory of the session\n * message?: string — COMPLETED: line extracted by the hook (optional)\n */\nasync function handleSessionEnd(item: WorkItem): Promise<void> {\n const { transcriptPath, cwd, message: hookMessage } = item.payload as {\n transcriptPath: string;\n cwd: string;\n message?: string;\n };\n\n if (!transcriptPath) throw new Error(\"session-end payload missing transcriptPath\");\n if (!cwd) throw new Error(\"session-end payload missing cwd\");\n\n // Read transcript\n let transcript: string;\n try {\n transcript = readFileSync(transcriptPath, \"utf-8\");\n } catch (e) {\n throw new Error(`Could not read transcript at ${transcriptPath}: ${e}`);\n }\n\n const lines = transcript.trim().split(\"\\n\");\n\n // Extract work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n\n // Determine completion message\n let message = hookMessage ?? \"\";\n if (!message) {\n const lastEntry = tryParseJson(lines[lines.length - 1]) as\n | { type?: string; message?: { content?: unknown } }\n | null;\n if (lastEntry?.type === \"assistant\" && lastEntry.message?.content) {\n const content = contentToText(lastEntry.message.content);\n const m = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (m) {\n message = m[1].trim().replace(/\\*+/g, \"\").replace(/\\[.*?\\]/g, \"\").trim();\n }\n }\n }\n\n // Find notes directory and current note\n const notesInfo = findNotesDir(cwd);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // Add work items to session note\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n process.stderr.write(\n `[work-queue-worker] Added ${workItems.length} work item(s) to note.\\n`\n );\n } else if (message) {\n addWorkToSessionNote(currentNotePath, [{ title: message, completed: true }]);\n process.stderr.write(\"[work-queue-worker] Added completion message to note.\\n\");\n }\n\n // Finalize the note\n const summary = message || \"Session completed.\";\n finalizeSessionNote(currentNotePath, summary);\n process.stderr.write(\n `[work-queue-worker] Finalized session note: ${basename(currentNotePath)}.\\n`\n );\n\n // Update TODO.md ## Continue section.\n //\n // A session that produced neither work items nor a completion message has\n // no handover to write. Claiming the slot anyway hands the next session a\n // block naming a session that did nothing — see the 2026-08-04 incident\n // recorded in AIBroker's Notes. applyContinue now refuses such a write on\n // its own; this skips building it in the first place, so the \"last session\"\n // line keeps pointing at the last session that actually did something.\n if (workItems.length === 0 && !message) {\n process.stderr.write(\n \"[work-queue-worker] Nothing to hand over — leaving TODO.md ## Continue intact.\\n\"\n );\n } else {\n try {\n const stateLines: string[] = [];\n stateLines.push(`Working directory: ${cwd}`);\n if (workItems.length > 0) {\n stateLines.push(\"\", \"Work completed:\");\n for (const wi of workItems.slice(0, 5)) {\n stateLines.push(`- ${wi.title}`);\n }\n }\n if (message) {\n stateLines.push(\"\", `Last completed: ${message}`);\n }\n updateTodoContinue(\n cwd,\n basename(currentNotePath),\n stateLines.join(\"\\n\"),\n \"session-end\",\n // Same reasoning as the stop hook: the transcript filename is the\n // session's real identity, and the note title is not.\n sessionIdFromTranscript(transcriptPath)\n );\n } catch (todoError) {\n // Non-fatal — log and continue\n process.stderr.write(\n `[work-queue-worker] Could not update TODO.md: ${todoError}\\n`\n );\n }\n }\n } else {\n process.stderr.write(\n \"[work-queue-worker] No current session note found — skipping note update.\\n\"\n );\n }\n\n // Move session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const archivedCount = archiveSessionFilesToSessionsDir(transcriptDir);\n if (archivedCount > 0) {\n process.stderr.write(\n `[work-queue-worker] Archived ${archivedCount} session file(s) to sessions/.\\n`\n );\n }\n } catch (moveError) {\n // Non-fatal\n process.stderr.write(`[work-queue-worker] Could not move session files: ${moveError}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// registry-scan handler\n// ---------------------------------------------------------------------------\n\n/**\n * Run performScan() against the registry DB.\n *\n * Debounce: if another registry-scan job is already pending in the queue,\n * we still run this one (dequeue already picked it), but enqueueRegistryScan()\n * checks before enqueuing so duplicates rarely reach here.\n */\nasync function handleRegistryScan(): Promise<void> {\n if (!registryBackend) {\n throw new Error(\"registry-scan: registryBackend not initialized yet\");\n }\n\n const t0 = Date.now();\n process.stderr.write(\"[work-queue-worker] Running registry scan...\\n\");\n\n const result = await performScan();\n const elapsed = Date.now() - t0;\n\n process.stderr.write(\n `[work-queue-worker] Registry scan complete: ` +\n `${result.projectsScanned} projects (${result.projectsNew} new, ${result.projectsUpdated} updated), ` +\n `${result.sessionsScanned} sessions (${result.sessionsNew} new) in ${elapsed}ms.\\n`\n );\n\n if (result.skipped.length > 0) {\n process.stderr.write(\n `[work-queue-worker] Registry scan: ${result.skipped.length} project(s) skipped (path not found).\\n`\n );\n }\n}\n\n/**\n * Enqueue a registry-scan work item — debounced.\n * If a registry-scan item is already pending or processing, skip enqueue.\n */\nexport function enqueueRegistryScan(): void {\n // Debounce: skip if already queued or processing\n if (hasPendingOrProcessingOfType(\"registry-scan\")) {\n process.stderr.write(\"[work-queue-worker] Registry scan already pending/processing — skipping duplicate enqueue.\\n\");\n return;\n }\n\n enqueue({\n type: \"registry-scan\",\n priority: 5, // lowest priority — runs after session-end, session-summary\n payload: {},\n });\n}\n\n// ---------------------------------------------------------------------------\n// Transcript parsing helpers (mirrors stop-hook.ts logic)\n// ---------------------------------------------------------------------------\n\nfunction tryParseJson(line: string): Record<string, unknown> | null {\n try {\n return JSON.parse(line) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\nfunction extractWorkFromTranscript(lines: string[]): NoteWorkItem[] {\n const workItems: NoteWorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n for (const line of lines) {\n const entry = tryParseJson(line);\n if (!entry || entry.type !== \"assistant\") continue;\n\n const msg = entry.message as Record<string, unknown> | undefined;\n if (!msg?.content) continue;\n\n const content = contentToText(msg.content);\n\n // SUMMARY: line (preferred)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n const details: string[] = [];\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n const actionLines = actionsMatch[1]\n .split(\"\\n\")\n .map((l) =>\n l.replace(/^[-*•]\\s*/, \"\").replace(/^\\d+\\.\\s*/, \"\").trim()\n )\n .filter((l) => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3));\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true,\n });\n }\n }\n\n // COMPLETED: line (fallback)\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1]\n .trim()\n .replace(/\\*+/g, \"\")\n .replace(/\\[.*?\\]/g, \"\")\n .trim();\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({ title: completed, completed: true });\n }\n }\n }\n\n return workItems;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"work-queue-worker-BL4dq5P7.mjs","names":["contentToText","contentToText"],"sources":["../src/daemon/templates/context-handover-prompt.ts","../src/daemon/context-handover-worker.ts","../src/daemon/topic-detect-worker.ts","../src/daemon/work-queue-worker.ts"],"sourcesContent":["/**\n * context-handover-prompt.ts — prompt template for the threshold-triggered\n * pre-compaction handover.\n *\n * This is NOT the session-note summary (session-summary-prompt.ts already\n * does that well, and Claude Code's own native compaction summary is also\n * genuinely detailed on file paths and issue numbers). This exists for the\n * one thing neither of those reliably carries: the reasoning behind what\n * happened, which compresses to bullets or vanishes outright once a native\n * compaction fires.\n *\n * FRAMING (lifted from a working example of this exact mechanism, read\n * before writing this prompt): the handover is a DIFF against everything\n * already durable, not a summary of the session. The successor can read\n * git, the tracker, and the notes — so this must not repeat what is\n * recoverable there. What cannot be recovered is what only ever existed in\n * the conversation: reasoning, rejected alternatives, negative results and\n * what they cost, traps discovered but invisible in the repo, and specific\n * values/ids/paths that would otherwise have to be dug up again.\n *\n * Length is not a target. A short session earns a short handover; every\n * line in a long one must be something the successor could not have\n * obtained anywhere else.\n */\n\nexport interface HandoverPromptParams {\n /** Interleaved user + assistant turns, oldest first, each prefixed with\n * who said it — this prompt needs the assistant's reasoning, not just\n * what the user asked for. */\n turns: string[];\n /** Git log output for the session period, if any. */\n gitLog: string;\n cwd: string;\n /** The previous cached handover's summary text, if this session already\n * produced one (the warmup handover, when this call is the refresh).\n * Passed through so the new handover can carry it forward rather than\n * silently dropping everything it already captured — see the \"pointer\n * to the previous handover, kept rather than superseded\" structure. */\n previousHandover?: string;\n}\n\nexport function buildContextHandoverPrompt(params: HandoverPromptParams): string {\n const { turns, gitLog, cwd, previousHandover } = params;\n\n const turnsSection = turns.length > 0\n ? turns.join(\"\\n\\n\")\n : \"(No turns extracted)\";\n\n const gitSection = gitLog.trim() || \"(No git commits during this session)\";\n\n const previousSection = previousHandover\n ? `\\nA PREVIOUS HANDOVER already exists for this session (from an earlier, \\\nlower threshold). Carry it forward — start your response with a one-line \\\npointer to it (\"Carries forward from the previous handover, which stays for \\\nits detail.\") and then write only what has happened SINCE it that a compact \\\nwould also lose. Do not re-derive or repeat what it already covered.\\n\\n\\\nPREVIOUS HANDOVER:\\n${previousHandover}\\n`\n : \"\";\n\n return `This session is approaching a context-window compaction. Write the \\\nhandover that would let a successor continue WITHOUT re-reading this \\\nconversation and WITHOUT asking the user to repeat themselves.\n\nProject directory: ${cwd}\n${previousSection}\nSCOPING RULE — the single most important instruction here: this is a DIFF \\\nagainst everything already durable, not a summary of the session. Assume \\\nthe successor can and will read git history, the issue tracker, and any \\\nnotes files themselves. Do NOT restate anything recoverable there — no file \\\ninventories, no \"implemented X\" restatements, no chronology of commands run. \\\nWrite down ONLY what existed exclusively in this conversation and would \\\notherwise be gone: the reasoning, not the result.\n\nWrite ONLY the sections below that this session actually has content for — \\\nomit a section entirely rather than writing \"none\" or padding it:\n\nWHERE THIS SESSION ENDED — the concrete state right now: what is verified \\\nworking (tests green, tree clean, etc.) versus merely attempted, any branch \\\nor working-tree state that isn't obvious from git status alone (e.g. a \\\ndetached HEAD, an intentionally uncommitted change, a stash).\n\nWHAT CLOSED — each item paired with the evidence that closed it, not just \\\nthe claim that it did.\n\nWHAT IS FILED AND STILL OPEN — each with its next concrete step or who/what \\\nit is waiting on.\n\nDECISIONS & REASONING — choices actually made, each with why this way and \\\nnot another way.\n\nREJECTED ALTERNATIVES — approaches considered and set aside, and why. An \\\nalternative without a reason is not worth recording.\n\nNEGATIVE RESULTS — something investigated that turned out NOT to be the \\\nanswer, kept WITH the reasoning that ruled it out (e.g. \"X looked like the \\\ncause — it wasn't; the actual mechanism was Y\"). Without the reasoning \\\nattached, a successor re-investigates the same dead end.\n\nTRAPS — anything discovered that is invisible from the repo alone and would \\\notherwise bite a successor immediately (a required manual step, an ordering \\\nconstraint, a state that looks fine but isn't).\n\nCRITICAL VALUES — any specific id, path, URL, credential name, version \\\nnumber, or configuration value that appeared in conversation and whose loss \\\nwould force someone to go find it again.\n\nTHE FEW THINGS THAT MATTER MOST, IF NOTHING ELSE IS READ — at most 3 bullets. \\\nForces a priority judgement; do not pad this to fill space.\n\nFormat your response as markdown with those section headers (only the ones \\\nyou have content for), no other structure.\n\n---\n\nCONVERSATION:\n${turnsSection}\n\nGIT COMMITS:\n${gitSection}`;\n}\n","/**\n * context-handover-worker.ts — the threshold-triggered pre-compaction\n * handover.\n *\n * `session-summary-worker.ts` already spawns a model to write session notes,\n * but only from the PreCompact hook itself — which fires at the compaction\n * boundary, far too late to be injected INTO that same compaction's\n * post-compact context. This worker runs earlier, off a token-count\n * threshold (see ../hooks/ts/lib/context-fill.ts), so a model-written\n * handover already exists in a cache file by the time compaction happens.\n *\n * Deliberately narrow: this does not write a session note, does not touch\n * the session-summary cooldown file, and does not run KG extraction. It\n * writes one thing — a cache file the PreCompact hook can read — because its\n * only job is to exist before compaction does, not to duplicate what\n * session-summary-worker already does well.\n *\n * The prompt itself (see templates/context-handover-prompt.ts) treats the\n * handover as a DIFF against everything already durable — git, the tracker,\n * the notes — not a session summary, and a refresh run carries the previous\n * cached handover forward rather than overwriting it.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\n\nimport {\n findLatestJsonl,\n getGitContext,\n spawnSummarizer,\n contentToText,\n} from \"./session-summary-worker.js\";\nimport { buildContextHandoverPrompt } from \"./templates/context-handover-prompt.js\";\nimport {\n readContextHandoverCache,\n writeContextHandoverCache,\n type HandoverThreshold,\n} from \"../hooks/ts/lib/context-handover-cache.js\";\n\nexport type { HandoverThreshold } from \"../hooks/ts/lib/context-handover-cache.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ContextHandoverPayload {\n cwd: string;\n sessionId: string;\n transcriptPath?: string;\n threshold: HandoverThreshold;\n /** True when this fired via the \"no time for a clean crossing\" path\n * (see isImmediate in context-fill.ts) — logged, not otherwise acted on. */\n urgent?: boolean;\n}\n\n/** How much of the tail of the transcript to feed the summarizer. Generous —\n * this runs at most twice per session, not on every tool call. */\nconst MAX_TURN_CHARS = 150_000;\n\n/** Max turns to include (a hard floor even if MAX_TURN_CHARS is not hit). */\nconst MAX_TURNS = 120;\n\n// ---------------------------------------------------------------------------\n// Transcript → turns\n// ---------------------------------------------------------------------------\n\n/**\n * Interleaved \"User:\"/\"Assistant:\" turns, oldest-kept-from-the-end so a long\n * transcript is truncated to its most recent content rather than its\n * earliest. Unlike session-summary-worker's extractFromJsonl (which keeps\n * only user messages, because a session note doesn't need the assistant's\n * own reasoning) this keeps BOTH sides — the whole point of this prompt is\n * the assistant's reasoning, which lives in assistant turns.\n */\nfunction extractTurns(jsonlPath: string): string[] {\n let raw: string;\n try {\n raw = readFileSync(jsonlPath, \"utf-8\");\n } catch {\n return [];\n }\n\n if (raw.length > MAX_TURN_CHARS) {\n const truncPoint = raw.indexOf(\"\\n\", raw.length - MAX_TURN_CHARS);\n raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_TURN_CHARS);\n }\n\n const turns: string[] = [];\n for (const line of raw.trim().split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: Record<string, unknown>;\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3 && !text.startsWith(\"<system-reminder>\")) {\n turns.push(`User: ${text.slice(0, 2000)}`);\n }\n } else if (entry.type === \"assistant\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3) {\n turns.push(`Assistant: ${text.slice(0, 4000)}`);\n }\n }\n }\n\n return turns.slice(-MAX_TURNS);\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `context-handover` work item.\n *\n * THROWS on every failure path (missing input, no transcript, no turns, an\n * empty/failed summarizer run) rather than logging and returning silently.\n * That used to be a deliberate choice — avoid the work queue's own retry\n * piling up redundant LLM calls — but it made a real failure invisible: a\n * daemon restart mid-spawn (session 77084e72-...) left no cache, no error\n * anywhere the work queue's own stats could show, and a trigger-side marker\n * that (at the time) had already been set to \"done\". Throwing here makes\n * work-queue-worker.ts call markFailed(), which logs prominently AND is\n * visible via `work_queue_stats` / `pai daemon status` — not just a stderr\n * line that can scroll away. It is a second, faster (seconds-to-minutes\n * backoff) retry path alongside the primary one in context-handover-\n * trigger.ts (which retries on its own ~5-minute cadence based on whether a\n * cache actually appeared); the two are complementary, not redundant — a\n * failed cache write costs nothing to retry twice.\n */\nexport async function handleContextHandover(payload: ContextHandoverPayload): Promise<void> {\n const { cwd, sessionId, transcriptPath, threshold, urgent } = payload;\n\n if (!cwd || !sessionId) {\n throw new Error(\"[context-handover] payload missing cwd or sessionId\");\n }\n\n process.stderr.write(\n `[context-handover] Starting for ${cwd} (session=${sessionId}, threshold=${threshold}` +\n `${urgent ? \", urgent\" : \"\"}).\\n`\n );\n\n let jsonlPath: string | null = transcriptPath && existsSync(transcriptPath) ? transcriptPath : null;\n if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);\n if (!jsonlPath) {\n throw new Error(`[context-handover] No transcript found for ${cwd} (session=${sessionId})`);\n }\n\n const turns = extractTurns(jsonlPath);\n if (turns.length === 0) {\n throw new Error(`[context-handover] No turns extracted from ${jsonlPath} (session=${sessionId})`);\n }\n\n const gitLog = await getGitContext(cwd);\n\n // Carry the previous handover forward (see buildContextHandoverPrompt's\n // scoping rule) rather than silently overwriting it — a refresh at the\n // second threshold should not lose what the warmup handover already\n // captured.\n const previous = readContextHandoverCache(sessionId);\n const prompt = buildContextHandoverPrompt({\n turns,\n gitLog,\n cwd,\n previousHandover: previous?.summary,\n });\n process.stderr.write(`[context-handover] Sending ${prompt.length} char prompt to sonnet...\\n`);\n\n const summary = await spawnSummarizer(prompt, \"sonnet\");\n if (!summary || !summary.trim()) {\n // No cache write on this path — an existing cache from an earlier\n // successful run is left untouched rather than clobbered with nothing.\n throw new Error(`[context-handover] sonnet produced no output (session=${sessionId})`);\n }\n\n writeContextHandoverCache({\n sessionId,\n cwd,\n threshold,\n generatedAt: new Date().toISOString(),\n model: \"sonnet\",\n summary: summary.trim(),\n });\n\n process.stderr.write(\n `[context-handover] Wrote cache for session ${sessionId} (${summary.length} chars).\\n`\n );\n}\n","/**\n * topic-detect-worker.ts — Topic shift detection for session note splitting\n *\n * Processes `topic-detect` work items by:\n * 1. Extracting recent user messages from the JSONL transcript\n * 2. Running the BM25-based topic shift detector against the PAI memory DB\n * 3. If a shift is detected, recording a topic boundary marker\n *\n * The actual note splitting is handled by session-summary-worker.ts when it\n * processes the next `session-summary` work item — it uses the TOPIC: line\n * from the summarizer to decide whether to create a new note.\n *\n * This worker provides an additional signal: project-level topic shift\n * (e.g., conversation moved from project A to project B). The session\n * summary worker handles intra-project topic shifts (e.g., from \"dark mode\"\n * to \"keyboard IPC\" within the same project).\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nimport { detectTopicShift } from \"../topics/detector.js\";\nimport { registryBackend, storageBackend } from \"./daemon/state.js\";\nimport {\n findNotesDir,\n getCurrentNotePath,\n appendCheckpoint,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicDetectPayload {\n /** Working directory of the session. */\n cwd: string;\n /** Recent conversation context (extracted user messages). */\n context?: string;\n /** The project slug the session is currently routed to. */\n currentProject?: string;\n /** Path to the JSONL transcript (optional — used to extract context if not provided). */\n transcriptPath?: string;\n /** Session ID (for logging). */\n sessionId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// JSONL context extraction (lightweight — just last few user messages)\n// ---------------------------------------------------------------------------\n\nconst MAX_CONTEXT_MESSAGES = 5;\nconst MAX_CONTEXT_CHARS = 2000;\n\n/**\n * Extract recent user messages from a JSONL transcript for topic detection.\n * Takes only the last few messages to represent the current topic.\n */\nfunction extractRecentContext(jsonlPath: string): string {\n try {\n const raw = readFileSync(jsonlPath, \"utf-8\");\n // Read from the end — last 50KB should be more than enough\n const tail = raw.length > 50_000 ? raw.slice(-50_000) : raw;\n const lines = tail.trim().split(\"\\n\");\n\n const messages: string[] = [];\n\n for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {\n const line = lines[i].trim();\n if (!line) continue;\n\n try {\n const entry = JSON.parse(line) as Record<string, unknown>;\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n if (msg?.content) {\n const text = contentToText(msg.content);\n if (text && text.length > 3) {\n messages.unshift(text.slice(0, 500));\n }\n }\n }\n } catch { /* skip invalid JSON */ }\n }\n\n return messages.join(\"\\n\\n\").slice(0, MAX_CONTEXT_CHARS);\n } catch {\n return \"\";\n }\n}\n\n/** Convert Claude content (string or content block array) to plain text. */\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Topic boundary file — signals to session-summary-worker\n// ---------------------------------------------------------------------------\n\nconst TOPIC_BOUNDARY_FILE = \"topic-boundary.json\";\n\ninterface TopicBoundary {\n timestamp: string;\n previousProject: string | null;\n suggestedProject: string | null;\n confidence: number;\n context: string;\n}\n\n/**\n * Write a topic boundary marker into the Notes directory.\n * The session-summary-worker checks for this file and uses it as an\n * additional signal that a new note should be created.\n */\nfunction writeTopicBoundary(\n cwd: string,\n boundary: TopicBoundary\n): void {\n try {\n const notesInfo = findNotesDir(cwd);\n const boundaryPath = join(notesInfo.path, TOPIC_BOUNDARY_FILE);\n writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), \"utf-8\");\n process.stderr.write(\n `[topic-detect] Wrote topic boundary marker: ${boundaryPath}\\n`\n );\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `topic-detect` work item.\n *\n * Called by work-queue-worker.ts. Throws on fatal errors so the work queue\n * retry logic handles them.\n */\nexport async function handleTopicDetect(payload: TopicDetectPayload): Promise<void> {\n const { cwd, currentProject, transcriptPath, sessionId } = payload;\n\n if (!cwd) {\n throw new Error(\"topic-detect payload missing cwd\");\n }\n\n process.stderr.write(\n `[topic-detect] Starting for ${cwd}` +\n `${currentProject ? ` (project=${currentProject})` : \"\"}` +\n `${sessionId ? ` (session=${sessionId})` : \"\"}\\n`\n );\n\n // Check that daemon state is available\n if (!registryBackend || !storageBackend) {\n process.stderr.write(\n \"[topic-detect] Registry backend or storage backend not available — skipping.\\n\"\n );\n return;\n }\n\n // Extract context from payload or transcript\n let context = payload.context || \"\";\n\n if (!context && transcriptPath && existsSync(transcriptPath)) {\n context = extractRecentContext(transcriptPath);\n }\n\n if (!context || context.trim().length < 10) {\n process.stderr.write(\n \"[topic-detect] Insufficient context for topic detection — skipping.\\n\"\n );\n return;\n }\n\n process.stderr.write(\n `[topic-detect] Context: ${context.length} chars, checking against memory...\\n`\n );\n\n // Run the BM25-based topic shift detector\n const result = await detectTopicShift(registryBackend, storageBackend, {\n context,\n currentProject,\n threshold: 0.6,\n candidates: 20,\n });\n\n process.stderr.write(\n `[topic-detect] Result: shifted=${result.shifted}, ` +\n `suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, ` +\n `chunks=${result.chunkCount}\\n`\n );\n\n if (result.topProjects.length > 0) {\n process.stderr.write(\n `[topic-detect] Top projects: ${result.topProjects.map(\n (p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`\n ).join(\", \")}\\n`\n );\n }\n\n if (result.shifted) {\n // Record the topic boundary\n writeTopicBoundary(cwd, {\n timestamp: new Date().toISOString(),\n previousProject: result.currentProject,\n suggestedProject: result.suggestedProject,\n confidence: result.confidence,\n context: context.slice(0, 200),\n });\n\n // Also append a checkpoint to the current session note\n try {\n const notesInfo = findNotesDir(cwd);\n const notePath = getCurrentNotePath(notesInfo.path);\n if (notePath) {\n appendCheckpoint(\n notePath,\n `Topic shift detected: conversation moved from **${result.currentProject}** ` +\n `to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). ` +\n `A new session note will be created for the new topic.`\n );\n }\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\\n`);\n }\n }\n\n process.stderr.write(\"[topic-detect] Done.\\n\");\n}\n","/**\n * work-queue-worker.ts — Daemon worker loop for the persistent work queue\n *\n * Runs every 5 seconds to drain the queue.\n * Handles 'session-end' work items by reading the transcript, extracting\n * work summaries, updating the session note, and updating TODO.md.\n * Handles 'session-summary' items by spawning Haiku for AI-powered note generation.\n * Handles 'context-handover' items by spawning Sonnet to write the\n * threshold-triggered pre-compaction reasoning handover (see\n * context-handover-worker.ts and hooks/ts/lib/context-fill.ts).\n * Handles 'registry-scan' items by running performScan() against the registry DB.\n *\n * Handles 'topic-detect' items by running BM25-based topic shift detection.\n * Other item types (note-update, todo-update) are stubs — they log and\n * complete immediately, ready for future expansion.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\n\nimport {\n dequeue,\n enqueue,\n markCompleted,\n markFailed,\n cleanup,\n getStats,\n hasPendingOrProcessingOfType,\n type WorkItem,\n} from \"./work-queue.js\";\n\nimport {\n handleSessionSummary,\n type SessionSummaryPayload,\n} from \"./session-summary-worker.js\";\n\nimport {\n handleContextHandover,\n type ContextHandoverPayload,\n} from \"./context-handover-worker.js\";\n\nimport {\n handleTopicDetect,\n type TopicDetectPayload,\n} from \"./topic-detect-worker.js\";\n\n// Registry scan — called from handleRegistryScan()\nimport { performScan } from \"../cli/commands/registry/scan.js\";\nimport { registryBackend } from \"./daemon/state.js\";\n\n// Hooks lib imports — resolving through the compiled JS path.\n// These are the same utilities used by stop-hook.ts.\nimport {\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n finalizeSessionNote,\n updateTodoContinue, sessionIdFromTranscript,\n archiveSessionFilesToSessionsDir,\n type WorkItem as NoteWorkItem,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst WORKER_INTERVAL_MS = 5_000;\nconst HOUSEKEEPING_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes\n\n// ---------------------------------------------------------------------------\n// Timers (stored so shutdown can clear them)\n// ---------------------------------------------------------------------------\n\nlet workerTimer: ReturnType<typeof setInterval> | null = null;\nlet housekeepingTimer: ReturnType<typeof setInterval> | null = null;\nlet _immediateSignal = false;\n\n// ---------------------------------------------------------------------------\n// Worker loop\n// ---------------------------------------------------------------------------\n\n/** Start the background worker and housekeeping timers. */\nexport function startWorker(): void {\n process.stderr.write(\"[work-queue-worker] Starting worker loop.\\n\");\n\n workerTimer = setInterval(async () => {\n try {\n await processNextItem();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Uncaught error in worker loop: ${e}\\n`);\n }\n }, WORKER_INTERVAL_MS);\n\n housekeepingTimer = setInterval(() => {\n try {\n cleanup();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Housekeeping error: ${e}\\n`);\n }\n }, HOUSEKEEPING_INTERVAL_MS);\n\n process.stderr.write(\"[work-queue-worker] Worker started (interval=5s, housekeeping=10min).\\n\");\n}\n\n/** Stop the worker timers gracefully. */\nexport function stopWorker(): void {\n if (workerTimer !== null) {\n clearInterval(workerTimer);\n workerTimer = null;\n }\n if (housekeepingTimer !== null) {\n clearInterval(housekeepingTimer);\n housekeepingTimer = null;\n }\n process.stderr.write(\"[work-queue-worker] Worker stopped.\\n\");\n}\n\n/**\n * Signal that new work has been enqueued.\n * The worker will run on its next tick — we don't need to reset the timer\n * since 5 s is fast enough. The flag allows future optimisations.\n */\nexport function notifyNewWork(): void {\n _immediateSignal = true;\n}\n\n// ---------------------------------------------------------------------------\n// Item processor (sequential — one item per tick)\n// ---------------------------------------------------------------------------\n\nasync function processNextItem(): Promise<void> {\n const item = dequeue();\n if (!item) return;\n\n process.stderr.write(\n `[work-queue-worker] Processing ${item.type} (id=${item.id}, attempt=${item.attempts}).\\n`\n );\n\n try {\n switch (item.type) {\n case \"session-end\":\n await handleSessionEnd(item);\n break;\n\n case \"session-summary\":\n await handleSessionSummary(item.payload as unknown as SessionSummaryPayload);\n break;\n\n case \"context-handover\":\n await handleContextHandover(item.payload as unknown as ContextHandoverPayload);\n break;\n\n case \"topic-detect\":\n await handleTopicDetect(item.payload as unknown as TopicDetectPayload);\n break;\n\n case \"registry-scan\":\n await handleRegistryScan();\n break;\n\n case \"note-update\":\n case \"todo-update\":\n // Stubs — log and complete\n process.stderr.write(\n `[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\\n`\n );\n break;\n\n default:\n throw new Error(`Unknown work item type: ${(item as WorkItem).type}`);\n }\n\n markCompleted(item.id);\n process.stderr.write(`[work-queue-worker] Completed ${item.type} (id=${item.id}).\\n`);\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n markFailed(item.id, msg);\n }\n}\n\n// ---------------------------------------------------------------------------\n// session-end handler\n// ---------------------------------------------------------------------------\n\n/**\n * Process a 'session-end' work item.\n *\n * Expected payload:\n * transcriptPath: string — absolute path to the .jsonl transcript\n * cwd: string — working directory of the session\n * message?: string — COMPLETED: line extracted by the hook (optional)\n */\nasync function handleSessionEnd(item: WorkItem): Promise<void> {\n const { transcriptPath, cwd, message: hookMessage } = item.payload as {\n transcriptPath: string;\n cwd: string;\n message?: string;\n };\n\n if (!transcriptPath) throw new Error(\"session-end payload missing transcriptPath\");\n if (!cwd) throw new Error(\"session-end payload missing cwd\");\n\n // Read transcript\n let transcript: string;\n try {\n transcript = readFileSync(transcriptPath, \"utf-8\");\n } catch (e) {\n throw new Error(`Could not read transcript at ${transcriptPath}: ${e}`);\n }\n\n const lines = transcript.trim().split(\"\\n\");\n\n // Extract work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n\n // Determine completion message\n let message = hookMessage ?? \"\";\n if (!message) {\n const lastEntry = tryParseJson(lines[lines.length - 1]) as\n | { type?: string; message?: { content?: unknown } }\n | null;\n if (lastEntry?.type === \"assistant\" && lastEntry.message?.content) {\n const content = contentToText(lastEntry.message.content);\n const m = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (m) {\n message = m[1].trim().replace(/\\*+/g, \"\").replace(/\\[.*?\\]/g, \"\").trim();\n }\n }\n }\n\n // Find notes directory and current note\n const notesInfo = findNotesDir(cwd);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // Add work items to session note\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n process.stderr.write(\n `[work-queue-worker] Added ${workItems.length} work item(s) to note.\\n`\n );\n } else if (message) {\n addWorkToSessionNote(currentNotePath, [{ title: message, completed: true }]);\n process.stderr.write(\"[work-queue-worker] Added completion message to note.\\n\");\n }\n\n // Finalize the note\n const summary = message || \"Session completed.\";\n finalizeSessionNote(currentNotePath, summary);\n process.stderr.write(\n `[work-queue-worker] Finalized session note: ${basename(currentNotePath)}.\\n`\n );\n\n // Update TODO.md ## Continue section.\n //\n // A session that produced neither work items nor a completion message has\n // no handover to write. Claiming the slot anyway hands the next session a\n // block naming a session that did nothing — see the 2026-08-04 incident\n // recorded in AIBroker's Notes. applyContinue now refuses such a write on\n // its own; this skips building it in the first place, so the \"last session\"\n // line keeps pointing at the last session that actually did something.\n if (workItems.length === 0 && !message) {\n process.stderr.write(\n \"[work-queue-worker] Nothing to hand over — leaving TODO.md ## Continue intact.\\n\"\n );\n } else {\n try {\n const stateLines: string[] = [];\n stateLines.push(`Working directory: ${cwd}`);\n if (workItems.length > 0) {\n stateLines.push(\"\", \"Work completed:\");\n for (const wi of workItems.slice(0, 5)) {\n stateLines.push(`- ${wi.title}`);\n }\n }\n if (message) {\n stateLines.push(\"\", `Last completed: ${message}`);\n }\n updateTodoContinue(\n cwd,\n basename(currentNotePath),\n stateLines.join(\"\\n\"),\n \"session-end\",\n // Same reasoning as the stop hook: the transcript filename is the\n // session's real identity, and the note title is not.\n sessionIdFromTranscript(transcriptPath)\n );\n } catch (todoError) {\n // Non-fatal — log and continue\n process.stderr.write(\n `[work-queue-worker] Could not update TODO.md: ${todoError}\\n`\n );\n }\n }\n } else {\n process.stderr.write(\n \"[work-queue-worker] No current session note found — skipping note update.\\n\"\n );\n }\n\n // Move session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const archivedCount = archiveSessionFilesToSessionsDir(transcriptDir);\n if (archivedCount > 0) {\n process.stderr.write(\n `[work-queue-worker] Archived ${archivedCount} session file(s) to sessions/.\\n`\n );\n }\n } catch (moveError) {\n // Non-fatal\n process.stderr.write(`[work-queue-worker] Could not move session files: ${moveError}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// registry-scan handler\n// ---------------------------------------------------------------------------\n\n/**\n * Run performScan() against the registry DB.\n *\n * Debounce: if another registry-scan job is already pending in the queue,\n * we still run this one (dequeue already picked it), but enqueueRegistryScan()\n * checks before enqueuing so duplicates rarely reach here.\n */\nasync function handleRegistryScan(): Promise<void> {\n if (!registryBackend) {\n throw new Error(\"registry-scan: registryBackend not initialized yet\");\n }\n\n const t0 = Date.now();\n process.stderr.write(\"[work-queue-worker] Running registry scan...\\n\");\n\n const result = await performScan();\n const elapsed = Date.now() - t0;\n\n process.stderr.write(\n `[work-queue-worker] Registry scan complete: ` +\n `${result.projectsScanned} projects (${result.projectsNew} new, ${result.projectsUpdated} updated), ` +\n `${result.sessionsScanned} sessions (${result.sessionsNew} new) in ${elapsed}ms.\\n`\n );\n\n if (result.skipped.length > 0) {\n process.stderr.write(\n `[work-queue-worker] Registry scan: ${result.skipped.length} project(s) skipped (path not found).\\n`\n );\n }\n}\n\n/**\n * Enqueue a registry-scan work item — debounced.\n * If a registry-scan item is already pending or processing, skip enqueue.\n */\nexport function enqueueRegistryScan(): void {\n // Debounce: skip if already queued or processing\n if (hasPendingOrProcessingOfType(\"registry-scan\")) {\n process.stderr.write(\"[work-queue-worker] Registry scan already pending/processing — skipping duplicate enqueue.\\n\");\n return;\n }\n\n enqueue({\n type: \"registry-scan\",\n priority: 5, // lowest priority — runs after session-end, session-summary\n payload: {},\n });\n}\n\n// ---------------------------------------------------------------------------\n// Transcript parsing helpers (mirrors stop-hook.ts logic)\n// ---------------------------------------------------------------------------\n\nfunction tryParseJson(line: string): Record<string, unknown> | null {\n try {\n return JSON.parse(line) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\nfunction extractWorkFromTranscript(lines: string[]): NoteWorkItem[] {\n const workItems: NoteWorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n for (const line of lines) {\n const entry = tryParseJson(line);\n if (!entry || entry.type !== \"assistant\") continue;\n\n const msg = entry.message as Record<string, unknown> | undefined;\n if (!msg?.content) continue;\n\n const content = contentToText(msg.content);\n\n // SUMMARY: line (preferred)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n const details: string[] = [];\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n const actionLines = actionsMatch[1]\n .split(\"\\n\")\n .map((l) =>\n l.replace(/^[-*•]\\s*/, \"\").replace(/^\\d+\\.\\s*/, \"\").trim()\n )\n .filter((l) => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3));\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true,\n });\n }\n }\n\n // COMPLETED: line (fallback)\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1]\n .trim()\n .replace(/\\*+/g, \"\")\n .replace(/\\[.*?\\]/g, \"\")\n .trim();\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({ title: completed, completed: true });\n }\n }\n }\n\n return workItems;\n}\n"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import "./utils-C9HsYDpO.mjs";
2
+ import "./migrate-BD7D8EEh.mjs";
3
+ import "./factory-EUUhSSoq.mjs";
4
+ import "./context-handover-cache-BzM7Qudn.mjs";
5
+ import "./config-CQCmH_gV.mjs";
6
+ import "./run-env-CEHTeIUM.mjs";
7
+ import "./server-CekGOJcp.mjs";
8
+ import "./state-n13uPK5B.mjs";
9
+ import { i as stopWorker, n as notifyNewWork, r as startWorker, t as enqueueRegistryScan } from "./work-queue-worker-BL4dq5P7.mjs";
10
+
11
+ export { enqueueRegistryScan };
package/docker/init.sql CHANGED
@@ -81,7 +81,7 @@ CREATE INDEX IF NOT EXISTS idx_pc_has_embedding ON pai_chunks (id)
81
81
  CREATE OR REPLACE FUNCTION pai_chunks_fts_update()
82
82
  RETURNS TRIGGER AS $$
83
83
  BEGIN
84
- NEW.fts_vector := to_tsvector('english', COALESCE(NEW.text, ''));
84
+ NEW.fts_vector := to_tsvector('simple', COALESCE(NEW.text, ''));
85
85
  RETURN NEW;
86
86
  END;
87
87
  $$ LANGUAGE plpgsql;
@@ -69,14 +69,14 @@ Generated man pages for every `pai` command area. Read any page in the terminal
69
69
  | `pai config unset <path>` | Remove one config value (dotted path), reverting it to the built-in default |
70
70
  | `pai config yaml` | Convert config.json → config.yaml (and voices.json → voices.yaml), with a short |
71
71
  | `pai daemon` | PAI daemon management: serve, status, restart, install, uninstall, logs |
72
- | `pai daemon install` | Install daemon as a launchd service and update ~/.claude.json to use the shim |
72
+ | `pai daemon install` | Install daemon as a service (launchd/systemd) and update ~/.claude.json to use the shim |
73
73
  | `pai daemon keepalive` | Show interactive-session cache keepalive config, per-session beat counters, and recent ledger lines |
74
74
  | `pai daemon logs` | Tail the daemon log (/tmp/pai-daemon.log) |
75
75
  | `pai daemon migrate` | Migrate federation data from SQLite to PostgreSQL |
76
- | `pai daemon restart` | Send SIGTERM to the running daemon (launchd will restart it) |
76
+ | `pai daemon restart` | Restart the running daemon (launchd on macOS, systemd on Linux) |
77
77
  | `pai daemon serve` | Start the PAI daemon in the foreground |
78
78
  | `pai daemon status` | Query the running daemon status |
79
- | `pai daemon uninstall` | Remove the launchd service and revert to direct MCP |
79
+ | `pai daemon uninstall` | Remove the daemon service (launchd/systemd) and revert to direct MCP |
80
80
  | `pai db` | Database inspection: query, tables, schema (sqlite or postgres) |
81
81
  | `pai db migrate-to-postgres` | One-shot migration of kg_entities, registry tables, and memory/vault rows from SQLite to Postgres |
82
82
  | `pai db query <sql>` | Run a SQL query against the selected database |
@@ -258,7 +258,7 @@ Generated man pages for every `pai` command area. Read any page in the terminal
258
258
  | `pai worker providers remove <name>` | Remove a provider and any classes pointing at it |
259
259
  | `pai worker providers test [name]` | One-word pong probe through a provider (default: the active one) |
260
260
  | `pai worker providers update <name>` | Change cost tier and tags of a provider (routing constraints use these) |
261
- | `pai worker providers use <name>` | Make this provider the active one for runs without --provider/--class |
261
+ | `pai worker providers use <name>` | Make this provider the active one, re-pointing every class pinned to a provider |
262
262
  | `pai worker proxy [stop]` | The local Anthropic↔OpenAI proxy (loopback only); started on demand by `run`, |
263
263
  | `pai worker ps` | List workers of this session (RUNNING + FINISHED); --all for every worker |
264
264
  | `pai worker replay <id>` | Print the transcript of one finished or running worker |
@@ -17,9 +17,9 @@ pai daemon <subcommand> [options]
17
17
  | [`pai daemon serve`](#pai-daemon-serve) | Start the PAI daemon in the foreground |
18
18
  | [`pai daemon migrate`](#pai-daemon-migrate) | Migrate federation data from SQLite to PostgreSQL |
19
19
  | [`pai daemon status`](#pai-daemon-status) | Query the running daemon status |
20
- | [`pai daemon restart`](#pai-daemon-restart) | Send SIGTERM to the running daemon (launchd will restart it) |
21
- | [`pai daemon install`](#pai-daemon-install) | Install daemon as a launchd service and update ~/.claude.json to use the shim |
22
- | [`pai daemon uninstall`](#pai-daemon-uninstall) | Remove the launchd service and revert to direct MCP |
20
+ | [`pai daemon restart`](#pai-daemon-restart) | Restart the running daemon (launchd on macOS, systemd on Linux) |
21
+ | [`pai daemon install`](#pai-daemon-install) | Install daemon as a service (launchd/systemd) and update ~/.claude.json to use the shim |
22
+ | [`pai daemon uninstall`](#pai-daemon-uninstall) | Remove the daemon service (launchd/systemd) and revert to direct MCP |
23
23
  | [`pai daemon keepalive`](#pai-daemon-keepalive) | Show interactive-session cache keepalive config, per-session beat counters, and recent ledger lines |
24
24
  | [`pai daemon logs`](#pai-daemon-logs) | Tail the daemon log (/tmp/pai-daemon.log) |
25
25
 
@@ -46,17 +46,17 @@ Query the running daemon status
46
46
 
47
47
  ### pai daemon restart
48
48
 
49
- Send SIGTERM to the running daemon (launchd will restart it)
49
+ Restart the running daemon (launchd on macOS, systemd on Linux)
50
50
 
51
51
 
52
52
  ### pai daemon install
53
53
 
54
- Install daemon as a launchd service and update ~/.claude.json to use the shim
54
+ Install daemon as a service (launchd/systemd) and update ~/.claude.json to use the shim
55
55
 
56
56
 
57
57
  ### pai daemon uninstall
58
58
 
59
- Remove the launchd service and revert to direct MCP
59
+ Remove the daemon service (launchd/systemd) and revert to direct MCP
60
60
 
61
61
 
62
62
  ### pai daemon keepalive
@@ -234,7 +234,7 @@ Go to a session: resume if a resumable snapshot exists, start fresh otherwise.
234
234
 
235
235
  Recommended short form: pai resume <name>
236
236
  Resolves by clc/registry name (case-insensitive) or UUID prefix.
237
- Sends '/Name <name>\ngo' as the initial prompt so the PAI ## Continue hook fires.
237
+ Sends '/Name <name>' as the initial prompt to label the session.
238
238
 
239
239
  **Arguments**
240
240
 
@@ -247,7 +247,7 @@ Sends '/Name <name>\ngo' as the initial prompt so the PAI ## Continue hook fires
247
247
  | Option | Description | Default |
248
248
  |--------|-------------|---------|
249
249
  | `--skip-name` | Do not prepend /Name to set the session name in chrome | |
250
- | `--skip-go` | Do not append \ngo to trigger PAI auto-resume (## Continue) | |
250
+ | `--skip-go` | Deprecated no-op: 'go' is no longer appended — type it by hand when wanted | |
251
251
  | `--dry-run` | Print the exact argv and cwd, then exit without launching | |
252
252
 
253
253
 
@@ -274,7 +274,8 @@ Send SIGTERM to a running worker process
274
274
 
275
275
  Hand the desktop controls (clickr) to a worker or take them back.
276
276
 
277
- <who> is `you` (the worker may actuate) or `me` (the operator keeps them);
277
+ <who> is `you` (<id> becomes the exclusive controls holder; needs clickr with
278
+ --agent support) or `me` (the operator takes them back);
278
279
  inside a worker's pane, typing "your controls" does the same.
279
280
 
280
281
  **Arguments**
@@ -302,6 +303,8 @@ Continue a finished worker on the same provider: claude --resume <session>
302
303
  |--------|-------------|---------|
303
304
  | `--print-id` | Print the new worker id on its own line (pane follow handoff) | |
304
305
  | `--no-pane` | Do not open a follow pane for the resumed worker | |
306
+ | `--output-format <fmt>` | text (default), json or stream-json — same as `run` | |
307
+ | `--label <text>` | Short task label (default: "↩ <old label>") | |
305
308
 
306
309
 
307
310
  ### pai worker proxy [stop]
@@ -458,7 +461,7 @@ Remove a provider and any classes pointing at it
458
461
 
459
462
  ### pai worker providers use <name>
460
463
 
461
- Make this provider the active one for runs without --provider/--class
464
+ Make this provider the active one, re-pointing every class pinned to a provider
462
465
 
463
466
  **Arguments**
464
467
 
@@ -333,7 +333,11 @@ pai worker classes set research kimi/fast
333
333
  ```
334
334
 
335
335
  `pai worker providers use kimi` makes it the default target for
336
- `pai worker run` with no `--provider`/`--class`.
336
+ `pai worker run` with no `--provider`, and also re-points every class whose
337
+ target already pins a provider (e.g. `research kimi/fast` above stays as-is
338
+ since it already names kimi; a class pinned to some other provider is
339
+ switched to kimi, keeping its `/alias` or other fields). A class with no
340
+ pinned provider (auto-routing only) is left untouched.
337
341
 
338
342
  ## Starting the harness on another provider
339
343
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.59.0",
3
+ "version": "0.60.1",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -1,34 +1,41 @@
1
1
  {
2
- "module": "context-preservation",
3
2
  "description": "Context preservation across compaction cycles, restarts, and interrupted sessions",
4
- "hooks": [
5
- {
6
- "event": "UserPromptSubmit",
7
- "command": "${PAI_DIR}/Hooks/pai-session-autosave.sh",
8
- "description": "Refresh the rolling ## Continue checkpoint from the transcript and working tree (rate-limited). Never overwrites a model-authored checkpoint for the same session."
9
- },
10
- {
11
- "event": "PostToolUse",
12
- "matcher": "*",
13
- "command": "${PAI_DIR}/Hooks/pai-session-autosave.sh",
14
- "description": "Same rolling checkpoint, so a long working turn interrupted by Ctrl+C still leaves recoverable state"
15
- },
16
- {
17
- "event": "PreCompact",
18
- "command": "${PAI_DIR}/Hooks/context-compression-hook.mjs",
19
- "description": "Extract session state, save checkpoint, write temp file for relay to post-compact"
20
- },
21
- {
22
- "event": "PreCompact",
23
- "matcher": "",
24
- "command": "${PAI_DIR}/Hooks/pai-pre-compact.sh",
25
- "description": "Shell-level pre-compact processing"
26
- },
27
- {
28
- "event": "SessionStart",
29
- "matcher": "compact",
30
- "command": "${PAI_DIR}/Hooks/post-compact-inject.mjs",
31
- "description": "Read saved state from temp file and inject into post-compaction context"
32
- }
33
- ]
3
+ "hooks": {
4
+ "UserPromptSubmit": [
5
+ {
6
+ "hooks": [
7
+ { "type": "command", "command": "${PAI_DIR}/Hooks/pai-session-autosave.sh" }
8
+ ]
9
+ }
10
+ ],
11
+ "PostToolUse": [
12
+ {
13
+ "matcher": "*",
14
+ "hooks": [
15
+ { "type": "command", "command": "${PAI_DIR}/Hooks/pai-session-autosave.sh" }
16
+ ]
17
+ }
18
+ ],
19
+ "PreCompact": [
20
+ {
21
+ "hooks": [
22
+ { "type": "command", "command": "${PAI_DIR}/Hooks/context-compression-hook.mjs" }
23
+ ]
24
+ },
25
+ {
26
+ "matcher": "",
27
+ "hooks": [
28
+ { "type": "command", "command": "${PAI_DIR}/Hooks/pai-pre-compact.sh" }
29
+ ]
30
+ }
31
+ ],
32
+ "SessionStart": [
33
+ {
34
+ "matcher": "compact",
35
+ "hooks": [
36
+ { "type": "command", "command": "${PAI_DIR}/Hooks/post-compact-inject.mjs" }
37
+ ]
38
+ }
39
+ ]
40
+ }
34
41
  }
@@ -3,8 +3,5 @@
3
3
  "displayName": "PAI Context Preservation",
4
4
  "description": "Context compression and relay across compaction cycles",
5
5
  "version": "0.7.0",
6
- "tier": "free",
7
- "required": false,
8
- "depends": ["core"],
9
- "hooks": "hooks/hooks.json"
6
+ "hooks": "./hooks/hooks.json"
10
7
  }