@tekmidian/pai 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/ARCHITECTURE.md +148 -6
  2. package/FEATURE.md +8 -1
  3. package/README.md +79 -0
  4. package/dist/{auto-route-D7W6RE06.mjs → auto-route-JjW3f7pV.mjs} +4 -4
  5. package/dist/{auto-route-D7W6RE06.mjs.map → auto-route-JjW3f7pV.mjs.map} +1 -1
  6. package/dist/chunker-CbnBe0s0.mjs +191 -0
  7. package/dist/chunker-CbnBe0s0.mjs.map +1 -0
  8. package/dist/cli/index.mjs +835 -40
  9. package/dist/cli/index.mjs.map +1 -1
  10. package/dist/{config-DBh1bYM2.mjs → config-DELNqq3Z.mjs} +4 -2
  11. package/dist/{config-DBh1bYM2.mjs.map → config-DELNqq3Z.mjs.map} +1 -1
  12. package/dist/daemon/index.mjs +9 -9
  13. package/dist/{daemon-v5O897D4.mjs → daemon-CeTX4NpF.mjs} +94 -13
  14. package/dist/daemon-CeTX4NpF.mjs.map +1 -0
  15. package/dist/daemon-mcp/index.mjs +3 -3
  16. package/dist/db-Dp8VXIMR.mjs +212 -0
  17. package/dist/db-Dp8VXIMR.mjs.map +1 -0
  18. package/dist/{detect-BHqYcjJ1.mjs → detect-D7gPV3fQ.mjs} +1 -1
  19. package/dist/{detect-BHqYcjJ1.mjs.map → detect-D7gPV3fQ.mjs.map} +1 -1
  20. package/dist/{detector-DKA83aTZ.mjs → detector-cYYhK2Mi.mjs} +2 -2
  21. package/dist/{detector-DKA83aTZ.mjs.map → detector-cYYhK2Mi.mjs.map} +1 -1
  22. package/dist/{embeddings-mfqv-jFu.mjs → embeddings-DGRAPAYb.mjs} +2 -2
  23. package/dist/{embeddings-mfqv-jFu.mjs.map → embeddings-DGRAPAYb.mjs.map} +1 -1
  24. package/dist/{factory-BDAiKtYR.mjs → factory-DZLvRf4m.mjs} +4 -4
  25. package/dist/{factory-BDAiKtYR.mjs.map → factory-DZLvRf4m.mjs.map} +1 -1
  26. package/dist/index.d.mts +1 -1
  27. package/dist/index.d.mts.map +1 -1
  28. package/dist/index.mjs +9 -7
  29. package/dist/{indexer-B20bPHL-.mjs → indexer-CKQcgKsz.mjs} +4 -190
  30. package/dist/indexer-CKQcgKsz.mjs.map +1 -0
  31. package/dist/{indexer-backend-BXaocO5r.mjs → indexer-backend-BHztlJJg.mjs} +4 -3
  32. package/dist/{indexer-backend-BXaocO5r.mjs.map → indexer-backend-BHztlJJg.mjs.map} +1 -1
  33. package/dist/{ipc-client-DPy7s3iu.mjs → ipc-client-CLt2fNlC.mjs} +1 -1
  34. package/dist/ipc-client-CLt2fNlC.mjs.map +1 -0
  35. package/dist/mcp/index.mjs +118 -5
  36. package/dist/mcp/index.mjs.map +1 -1
  37. package/dist/{migrate-Bwj7qPaE.mjs → migrate-jokLenje.mjs} +8 -1
  38. package/dist/migrate-jokLenje.mjs.map +1 -0
  39. package/dist/{pai-marker-DX_mFLum.mjs → pai-marker-CXQPX2P6.mjs} +1 -1
  40. package/dist/{pai-marker-DX_mFLum.mjs.map → pai-marker-CXQPX2P6.mjs.map} +1 -1
  41. package/dist/{postgres-Ccvpc6fC.mjs → postgres-CRBe30Ag.mjs} +1 -1
  42. package/dist/{postgres-Ccvpc6fC.mjs.map → postgres-CRBe30Ag.mjs.map} +1 -1
  43. package/dist/{schemas-DjdwzIQ8.mjs → schemas-BY3Pjvje.mjs} +1 -1
  44. package/dist/{schemas-DjdwzIQ8.mjs.map → schemas-BY3Pjvje.mjs.map} +1 -1
  45. package/dist/{search-PjftDxxs.mjs → search-GK0ibTJy.mjs} +2 -2
  46. package/dist/{search-PjftDxxs.mjs.map → search-GK0ibTJy.mjs.map} +1 -1
  47. package/dist/{sqlite-CHUrNtbI.mjs → sqlite-RyR8Up1v.mjs} +3 -3
  48. package/dist/{sqlite-CHUrNtbI.mjs.map → sqlite-RyR8Up1v.mjs.map} +1 -1
  49. package/dist/{tools-CLK4080-.mjs → tools-CUg0Lyg-.mjs} +175 -11
  50. package/dist/{tools-CLK4080-.mjs.map → tools-CUg0Lyg-.mjs.map} +1 -1
  51. package/dist/{utils-DEWdIFQ0.mjs → utils-QSfKagcj.mjs} +62 -2
  52. package/dist/utils-QSfKagcj.mjs.map +1 -0
  53. package/dist/vault-indexer-Bo2aPSzP.mjs +499 -0
  54. package/dist/vault-indexer-Bo2aPSzP.mjs.map +1 -0
  55. package/dist/zettelkasten-Co-w0XSZ.mjs +901 -0
  56. package/dist/zettelkasten-Co-w0XSZ.mjs.map +1 -0
  57. package/package.json +2 -1
  58. package/src/hooks/README.md +99 -0
  59. package/src/hooks/hooks.md +13 -0
  60. package/src/hooks/pre-compact.sh +95 -0
  61. package/src/hooks/session-stop.sh +93 -0
  62. package/statusline-command.sh +9 -4
  63. package/templates/README.md +7 -0
  64. package/templates/agent-prefs.example.md +7 -0
  65. package/templates/claude-md.template.md +7 -0
  66. package/templates/pai-project.template.md +4 -6
  67. package/templates/pai-skill.template.md +295 -0
  68. package/templates/templates.md +20 -0
  69. package/dist/daemon-v5O897D4.mjs.map +0 -1
  70. package/dist/db-BcDxXVBu.mjs +0 -110
  71. package/dist/db-BcDxXVBu.mjs.map +0 -1
  72. package/dist/indexer-B20bPHL-.mjs.map +0 -1
  73. package/dist/ipc-client-DPy7s3iu.mjs.map +0 -1
  74. package/dist/migrate-Bwj7qPaE.mjs.map +0 -1
  75. package/dist/utils-DEWdIFQ0.mjs.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"indexer-B20bPHL-.mjs","names":[],"sources":["../src/memory/chunker.ts","../src/memory/indexer.ts"],"sourcesContent":["/**\n * Markdown text chunker for the PAI memory engine.\n *\n * Splits markdown files into overlapping text segments suitable for BM25\n * full-text indexing. Respects heading boundaries where possible, falling\n * back to paragraph and sentence splitting when sections are large.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface Chunk {\n text: string;\n startLine: number; // 1-indexed\n endLine: number; // 1-indexed, inclusive\n hash: string; // SHA-256 of text\n}\n\nexport interface ChunkOptions {\n /** Approximate maximum tokens per chunk. Default 400. */\n maxTokens?: number;\n /** Overlap in tokens from the previous chunk. Default 80. */\n overlap?: number;\n}\n\nconst DEFAULT_MAX_TOKENS = 400;\nconst DEFAULT_OVERLAP = 80;\n\n/**\n * Approximate token count using a words * 1.3 heuristic.\n * Matches the OpenClaw estimate approach.\n */\nexport function estimateTokens(text: string): number {\n const wordCount = text.split(/\\s+/).filter(Boolean).length;\n return Math.ceil(wordCount * 1.3);\n}\n\n/**\n * Compute SHA-256 hash of a string, returning a hex string.\n */\nfunction sha256(text: string): string {\n return createHash(\"sha256\").update(text).digest(\"hex\");\n}\n\n// ---------------------------------------------------------------------------\n// Internal section / paragraph / sentence splitters\n// ---------------------------------------------------------------------------\n\n/**\n * A contiguous block of lines associated with an approximate token count.\n */\ninterface LineBlock {\n lines: Array<{ text: string; lineNo: number }>;\n tokens: number;\n}\n\n/**\n * Split content into sections delimited by ## or ### headings.\n * Each section starts at its heading line (or at line 1 for a preamble).\n */\nfunction splitBySections(\n lines: Array<{ text: string; lineNo: number }>,\n): LineBlock[] {\n const sections: LineBlock[] = [];\n let current: Array<{ text: string; lineNo: number }> = [];\n\n for (const line of lines) {\n const isHeading = /^#{1,3}\\s/.test(line.text);\n if (isHeading && current.length > 0) {\n const text = current.map((l) => l.text).join(\"\\n\");\n sections.push({ lines: current, tokens: estimateTokens(text) });\n current = [];\n }\n current.push(line);\n }\n\n if (current.length > 0) {\n const text = current.map((l) => l.text).join(\"\\n\");\n sections.push({ lines: current, tokens: estimateTokens(text) });\n }\n\n return sections;\n}\n\n/**\n * Split a LineBlock by double-newline paragraph boundaries.\n */\nfunction splitByParagraphs(block: LineBlock): LineBlock[] {\n const paragraphs: LineBlock[] = [];\n let current: Array<{ text: string; lineNo: number }> = [];\n\n for (const line of block.lines) {\n if (line.text.trim() === \"\" && current.length > 0) {\n // Empty line — potential paragraph boundary\n const text = current.map((l) => l.text).join(\"\\n\");\n paragraphs.push({ lines: [...current], tokens: estimateTokens(text) });\n current = [];\n } else {\n current.push(line);\n }\n }\n\n if (current.length > 0) {\n const text = current.map((l) => l.text).join(\"\\n\");\n paragraphs.push({ lines: current, tokens: estimateTokens(text) });\n }\n\n return paragraphs.length > 0 ? paragraphs : [block];\n}\n\n/**\n * Split a LineBlock by sentence boundaries (. ! ?) when even paragraphs are\n * too large. Works character-by-character within joined lines.\n */\nfunction splitBySentences(block: LineBlock, maxTokens: number): LineBlock[] {\n const fullText = block.lines.map((l) => l.text).join(\" \");\n // Very rough sentence split — split on '. ', '! ', '? ' followed by uppercase\n const sentenceRe = /(?<=[.!?])\\s+(?=[A-Z\"'])/g;\n const sentences = fullText.split(sentenceRe);\n\n const result: LineBlock[] = [];\n let accText = \"\";\n // We can't recover exact line numbers inside a single oversized paragraph,\n // so we approximate using the block's start/end lines distributed evenly.\n const startLine = block.lines[0]?.lineNo ?? 1;\n const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;\n const totalLines = endLine - startLine + 1;\n const linesPerSentence = Math.max(1, Math.floor(totalLines / Math.max(1, sentences.length)));\n\n let sentenceIdx = 0;\n let approxLine = startLine;\n\n const flush = () => {\n if (!accText.trim()) return;\n const endApprox = Math.min(approxLine + linesPerSentence - 1, endLine);\n result.push({\n lines: [{ text: accText.trim(), lineNo: approxLine }],\n tokens: estimateTokens(accText),\n });\n approxLine = endApprox + 1;\n accText = \"\";\n };\n\n for (const sentence of sentences) {\n sentenceIdx++;\n const candidateText = accText ? accText + \" \" + sentence : sentence;\n if (estimateTokens(candidateText) > maxTokens && accText) {\n flush();\n accText = sentence;\n } else {\n accText = candidateText;\n }\n }\n void sentenceIdx; // used only for iteration count\n flush();\n\n return result.length > 0 ? result : [block];\n}\n\n// ---------------------------------------------------------------------------\n// Overlap helper\n// ---------------------------------------------------------------------------\n\n/**\n * Extract the last `overlapTokens` worth of text from a list of previously\n * emitted chunks to prepend to the next chunk.\n */\nfunction buildOverlapPrefix(\n chunks: Chunk[],\n overlapTokens: number,\n): Array<{ text: string; lineNo: number }> {\n if (overlapTokens <= 0 || chunks.length === 0) return [];\n\n const lastChunk = chunks[chunks.length - 1];\n if (!lastChunk) return [];\n\n const lines = lastChunk.text.split(\"\\n\");\n const kept: string[] = [];\n let acc = 0;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const lineTokens = estimateTokens(lines[i] ?? \"\");\n acc += lineTokens;\n kept.unshift(lines[i] ?? \"\");\n if (acc >= overlapTokens) break;\n }\n\n // Distribute overlap lines across the lastChunk's line range\n const startLine = lastChunk.endLine - kept.length + 1;\n return kept.map((text, idx) => ({ text, lineNo: Math.max(lastChunk.startLine, startLine + idx) }));\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Chunk a markdown file into overlapping segments for BM25 indexing.\n *\n * Strategy:\n * 1. Split by headings (##, ###) as natural boundaries.\n * 2. If a section exceeds maxTokens, split by paragraphs.\n * 3. If a paragraph still exceeds maxTokens, split by sentences.\n * 4. Apply overlap: each chunk includes the last `overlap` tokens from the\n * previous chunk.\n */\nexport function chunkMarkdown(content: string, opts?: ChunkOptions): Chunk[] {\n const maxTokens = opts?.maxTokens ?? DEFAULT_MAX_TOKENS;\n const overlapTokens = opts?.overlap ?? DEFAULT_OVERLAP;\n\n if (!content.trim()) return [];\n\n const rawLines = content.split(\"\\n\");\n const lines: Array<{ text: string; lineNo: number }> = rawLines.map((text, idx) => ({\n text,\n lineNo: idx + 1, // 1-indexed\n }));\n\n // Step 1: section split\n const sections = splitBySections(lines);\n\n // Step 2 & 3: further split oversized sections\n const finalBlocks: LineBlock[] = [];\n for (const section of sections) {\n if (section.tokens <= maxTokens) {\n finalBlocks.push(section);\n continue;\n }\n // Too big — split by paragraphs\n const paras = splitByParagraphs(section);\n for (const para of paras) {\n if (para.tokens <= maxTokens) {\n finalBlocks.push(para);\n continue;\n }\n // Still too big — split by sentences\n const sentences = splitBySentences(para, maxTokens);\n finalBlocks.push(...sentences);\n }\n }\n\n // Step 4: build final chunks with overlap\n const chunks: Chunk[] = [];\n\n for (const block of finalBlocks) {\n if (block.lines.length === 0) continue;\n\n // Build overlap prefix from previous chunks\n const overlapLines = buildOverlapPrefix(chunks, overlapTokens);\n\n // Combine overlap + block lines\n const allLines = [...overlapLines, ...block.lines];\n const text = allLines.map((l) => l.text).join(\"\\n\").trim();\n\n if (!text) continue;\n\n const startLine = block.lines[0]?.lineNo ?? 1;\n const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;\n\n chunks.push({\n text,\n startLine,\n endLine,\n hash: sha256(text),\n });\n }\n\n return chunks;\n}\n","/**\n * File indexer for the PAI federation memory engine.\n *\n * Scans project memory/ and Notes/ directories, chunks markdown files, and\n * inserts the resulting chunks into federation.db for BM25 search.\n *\n * Change detection: files whose SHA-256 hash has not changed since the last\n * index run are skipped, keeping incremental re-indexing fast.\n *\n * Phase 2.5: adds embedChunks() for generating vector embeddings on indexed\n * chunks that do not yet have an embedding stored.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { readFileSync, statSync, readdirSync, existsSync } from \"node:fs\";\nimport { join, relative, basename, normalize } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport type { Database } from \"better-sqlite3\";\nimport { chunkMarkdown } from \"./chunker.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface IndexResult {\n filesProcessed: number;\n chunksCreated: number;\n filesSkipped: number;\n}\n\n// ---------------------------------------------------------------------------\n// Tier detection\n// ---------------------------------------------------------------------------\n\n/**\n * Classify a relative file path into one of the four memory tiers.\n *\n * Rules (in priority order):\n * - MEMORY.md anywhere in memory/ → 'evergreen'\n * - YYYY-MM-DD.md in memory/ → 'daily'\n * - anything else in memory/ → 'topic'\n * - anything in Notes/ → 'session'\n */\nexport function detectTier(\n relativePath: string,\n): \"evergreen\" | \"daily\" | \"topic\" | \"session\" {\n // Normalise to forward slashes and strip leading ./\n const p = relativePath.replace(/\\\\/g, \"/\").replace(/^\\.\\//, \"\");\n\n // Notes directory → session tier\n if (p.startsWith(\"Notes/\") || p === \"Notes\") {\n return \"session\";\n }\n\n const fileName = basename(p);\n\n // MEMORY.md (case-sensitive match) → evergreen\n if (fileName === \"MEMORY.md\") {\n return \"evergreen\";\n }\n\n // YYYY-MM-DD.md → daily\n if (/^\\d{4}-\\d{2}-\\d{2}\\.md$/.test(fileName)) {\n return \"daily\";\n }\n\n // Default for memory/ files\n return \"topic\";\n}\n\n// ---------------------------------------------------------------------------\n// Chunk ID generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic chunk ID from its coordinates.\n * Format: sha256(\"projectId:path:chunkIndex:startLine:endLine\")\n *\n * The chunkIndex (0-based position within the file) is included so that\n * chunks with approximated line numbers (e.g. from splitBySentences) never\n * produce colliding IDs even when multiple chunks share the same startLine/endLine.\n */\nfunction chunkId(\n projectId: number,\n path: string,\n chunkIndex: number,\n startLine: number,\n endLine: number,\n): string {\n return createHash(\"sha256\")\n .update(`${projectId}:${path}:${chunkIndex}:${startLine}:${endLine}`)\n .digest(\"hex\");\n}\n\n// ---------------------------------------------------------------------------\n// File hash\n// ---------------------------------------------------------------------------\n\nfunction sha256File(content: string): string {\n return createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\n// ---------------------------------------------------------------------------\n// Core indexing operations\n// ---------------------------------------------------------------------------\n\n/**\n * Index a single file into the federation database.\n *\n * @returns true if the file was re-indexed (changed or new), false if skipped.\n */\nexport function indexFile(\n db: Database,\n projectId: number,\n rootPath: string,\n relativePath: string,\n source: string,\n tier: string,\n): boolean {\n const absPath = join(rootPath, relativePath);\n\n // Read file content\n let content: string;\n let stat: ReturnType<typeof statSync>;\n try {\n content = readFileSync(absPath, \"utf8\");\n stat = statSync(absPath);\n } catch {\n // File unreadable or missing — skip silently\n return false;\n }\n\n const hash = sha256File(content);\n const mtime = Math.floor(stat.mtimeMs);\n const size = stat.size;\n\n // Check if the file has changed since last index\n const existing = db\n .prepare(\n \"SELECT hash FROM memory_files WHERE project_id = ? AND path = ?\",\n )\n .get(projectId, relativePath) as { hash: string } | undefined;\n\n if (existing?.hash === hash) {\n // Unchanged — skip\n return false;\n }\n\n // Delete old chunks for this file from both tables\n const oldChunkIds = db\n .prepare(\n \"SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?\",\n )\n .all(projectId, relativePath) as Array<{ id: string }>;\n\n const deleteFts = db.prepare(\"DELETE FROM memory_fts WHERE id = ?\");\n const deleteChunk = db.prepare(\n \"DELETE FROM memory_chunks WHERE project_id = ? AND path = ?\",\n );\n\n db.transaction(() => {\n for (const row of oldChunkIds) {\n deleteFts.run(row.id);\n }\n deleteChunk.run(projectId, relativePath);\n })();\n\n // Chunk the new content\n const chunks = chunkMarkdown(content);\n\n // Insert new chunks into memory_chunks and memory_fts\n const insertChunk = db.prepare(`\n INSERT INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `);\n\n const insertFts = db.prepare(`\n INSERT INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n `);\n\n const upsertFile = db.prepare(`\n INSERT INTO memory_files (project_id, path, source, tier, hash, mtime, size)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(project_id, path) DO UPDATE SET\n source = excluded.source,\n tier = excluded.tier,\n hash = excluded.hash,\n mtime = excluded.mtime,\n size = excluded.size\n `);\n\n const updatedAt = Date.now();\n\n db.transaction(() => {\n for (let i = 0; i < chunks.length; i++) {\n const chunk = chunks[i]!;\n const id = chunkId(projectId, relativePath, i, chunk.startLine, chunk.endLine);\n insertChunk.run(\n id,\n projectId,\n source,\n tier,\n relativePath,\n chunk.startLine,\n chunk.endLine,\n chunk.hash,\n chunk.text,\n updatedAt,\n );\n insertFts.run(\n chunk.text,\n id,\n projectId,\n relativePath,\n source,\n tier,\n chunk.startLine,\n chunk.endLine,\n );\n }\n upsertFile.run(projectId, relativePath, source, tier, hash, mtime, size);\n })();\n\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Directory walker\n// ---------------------------------------------------------------------------\n\n/**\n * Safety cap: maximum number of .md files collected per project scan.\n * Prevents runaway scans on huge root paths (e.g. home directory).\n * Projects with more files than this are scanned up to the cap only.\n */\nconst MAX_FILES_PER_PROJECT = 5_000;\n\n/**\n * Maximum recursion depth for directory walks.\n * Prevents deep traversal of large directory trees (e.g. development repos).\n * Depth 0 = the given directory itself (no recursion).\n * Value 6 allows: root → subdirs → sub-subdirs → ... up to 6 levels.\n * Sufficient for memory/, Notes/, and typical docs structures.\n */\nconst MAX_WALK_DEPTH = 6;\n\n/**\n * Recursively collect all .md files under a directory.\n * Returns absolute paths. Stops early if the accumulated count hits the cap\n * or if the recursion depth exceeds MAX_WALK_DEPTH.\n *\n * @param dir Directory to scan.\n * @param acc Shared accumulator array (mutated in place for early exit).\n * @param cap Maximum number of files to collect (across all recursive calls).\n * @param depth Current recursion depth (0 = the initial call).\n */\nfunction walkMdFiles(\n dir: string,\n acc?: string[],\n cap = MAX_FILES_PER_PROJECT,\n depth = 0,\n): string[] {\n const results = acc ?? [];\n if (!existsSync(dir)) return results;\n if (results.length >= cap) return results;\n if (depth > MAX_WALK_DEPTH) return results;\n\n try {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (results.length >= cap) break;\n if (entry.isSymbolicLink()) continue;\n // Skip known junk directories at every recursion depth\n if (ALWAYS_SKIP_DIRS.has(entry.name)) continue;\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n walkMdFiles(full, results, cap, depth + 1);\n } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n results.push(full);\n }\n }\n } catch {\n // Unreadable directory — skip\n }\n return results;\n}\n\n/**\n * Directories to ALWAYS skip, at any depth, during any directory walk.\n * These are build artifacts, dependency trees, and VCS internals that\n * should never be indexed regardless of where they appear in the tree.\n */\nconst ALWAYS_SKIP_DIRS = new Set([\n // Version control\n \".git\",\n // Dependency directories (any language)\n \"node_modules\",\n \"vendor\",\n \"Pods\", // CocoaPods (iOS/macOS)\n // Build / compile output\n \"dist\",\n \"build\",\n \"out\",\n \"DerivedData\", // Xcode\n \".next\", // Next.js\n // Python virtual environments and caches\n \".venv\",\n \"venv\",\n \"__pycache__\",\n // General caches\n \".cache\",\n \".bun\",\n]);\n\n/**\n * Directories to skip when doing a root-level content scan.\n * These are either already handled by dedicated scans or should never be indexed.\n */\nconst ROOT_SCAN_SKIP_DIRS = new Set([\n \"memory\",\n \"Notes\",\n \".claude\",\n \".DS_Store\",\n // Everything in ALWAYS_SKIP_DIRS is also excluded at root level\n ...ALWAYS_SKIP_DIRS,\n]);\n\n/**\n * Additional directories to skip at the content-scan level (first level below root).\n * These are common macOS/Linux home-directory or repo noise directories that are\n * never meaningful as project content.\n */\nconst CONTENT_SCAN_SKIP_DIRS = new Set([\n // macOS home directory standard folders\n \"Library\",\n \"Applications\",\n \"Music\",\n \"Movies\",\n \"Pictures\",\n \"Desktop\",\n \"Downloads\",\n \"Public\",\n // Common dev noise\n \"coverage\",\n // Everything in ALWAYS_SKIP_DIRS is also excluded at this level\n ...ALWAYS_SKIP_DIRS,\n]);\n\n/**\n * Recursively collect all .md files under rootPath, excluding directories\n * that are already covered by dedicated scans (memory/, Notes/) and\n * common noise directories (.git, node_modules, etc.).\n *\n * Returns absolute paths for files NOT already handled by the specific scanners.\n * Stops collecting once MAX_FILES_PER_PROJECT is reached.\n */\nfunction walkContentFiles(rootPath: string): string[] {\n if (!existsSync(rootPath)) return [];\n\n const results: string[] = [];\n try {\n for (const entry of readdirSync(rootPath, { withFileTypes: true })) {\n if (results.length >= MAX_FILES_PER_PROJECT) break;\n if (entry.isSymbolicLink()) continue;\n if (ROOT_SCAN_SKIP_DIRS.has(entry.name)) continue;\n if (CONTENT_SCAN_SKIP_DIRS.has(entry.name)) continue;\n\n const full = join(rootPath, entry.name);\n if (entry.isDirectory()) {\n walkMdFiles(full, results, MAX_FILES_PER_PROJECT);\n } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n // Skip root-level MEMORY.md — handled by the dedicated evergreen scan\n if (entry.name !== \"MEMORY.md\") {\n results.push(full);\n }\n }\n }\n } catch {\n // Unreadable directory — skip\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Project-level indexing\n// ---------------------------------------------------------------------------\n\n/**\n * Index all memory, Notes, and content files for a single registered project.\n *\n * Scans:\n * - {rootPath}/MEMORY.md → source='memory', tier='evergreen'\n * - {rootPath}/memory/ → source='memory', tier from detectTier()\n * - {rootPath}/Notes/ → source='notes', tier='session'\n * - {rootPath}/**\\/\\*.md → source='content', tier='topic' (all other .md files, recursive)\n * - {claudeNotesDir}/ → source='notes', tier='session' (if set and different)\n *\n * The content scan covers projects like job-discussions where markdown files\n * live in date/topic subdirectories rather than a memory/ folder. The\n * memory/, Notes/, .git/, and node_modules/ directories are excluded from\n * the content scan to avoid double-indexing.\n *\n * The claudeNotesDir parameter points to ~/.claude/projects/{encoded}/Notes/\n * where Claude Code writes session notes for a given working directory.\n * It is stored on the project row as claude_notes_dir after a registry scan.\n */\n/**\n * Number of files to process before yielding to the event loop inside\n * indexProject. Keeps IPC responsive even while indexing large projects.\n * Lower = more responsive but more overhead. 10 is a good balance.\n */\nconst INDEX_YIELD_EVERY = 10;\n\n/**\n * Returns true if rootPath should skip the recursive content scan.\n *\n * Skips content scanning for:\n * - The home directory itself or any ancestor (too broad — millions of files)\n * - Git repositories (code repos — index memory/ and Notes/ only, not all .md files)\n *\n * The content scan is still useful for Obsidian vaults, Notes folders, and\n * other doc-centric project trees where ALL markdown files are meaningful.\n *\n * The memory/, Notes/, and claude_notes_dir scans always run regardless.\n */\nfunction isPathTooBroadForContentScan(rootPath: string): boolean {\n const normalized = normalize(rootPath);\n const home = homedir();\n\n // Skip the home directory itself or any ancestor of home\n if (home.startsWith(normalized) || normalized === \"/\") {\n return true;\n }\n\n // Skip home directory itself (depth 0)\n if (normalized.startsWith(home)) {\n const rel = normalized.slice(home.length).replace(/^\\//, \"\");\n const depth = rel ? rel.split(\"/\").length : 0;\n if (depth === 0) return true;\n }\n\n // Skip git repositories — content scan is only for doc-centric projects\n // (Obsidian vaults, knowledge bases). Code repos use memory/ and Notes/ only.\n if (existsSync(join(normalized, \".git\"))) {\n return true;\n }\n\n return false;\n}\n\nexport async function indexProject(\n db: Database,\n projectId: number,\n rootPath: string,\n claudeNotesDir?: string | null,\n): Promise<IndexResult> {\n const result: IndexResult = {\n filesProcessed: 0,\n chunksCreated: 0,\n filesSkipped: 0,\n };\n\n const filesToIndex: Array<{ absPath: string; rootBase: string; source: string; tier: string }> = [];\n\n // Root-level MEMORY.md\n const rootMemoryMd = join(rootPath, \"MEMORY.md\");\n if (existsSync(rootMemoryMd)) {\n filesToIndex.push({ absPath: rootMemoryMd, rootBase: rootPath, source: \"memory\", tier: \"evergreen\" });\n }\n\n // memory/ directory\n const memoryDir = join(rootPath, \"memory\");\n for (const absPath of walkMdFiles(memoryDir)) {\n const relPath = relative(rootPath, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"memory\", tier });\n }\n\n // {rootPath}/Notes/ directory\n const notesDir = join(rootPath, \"Notes\");\n for (const absPath of walkMdFiles(notesDir)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic session-title chunks for Notes files with the standard filename format:\n // \"NNNN - YYYY-MM-DD - Descriptive Title.md\"\n // These are small, high-signal chunks that make session titles searchable via BM25 and embeddings.\n {\n const SESSION_TITLE_RE = /^(\\d{4})\\s*-\\s*(\\d{4}-\\d{2}-\\d{2})\\s*-\\s*(.+)\\.md$/;\n const titleInsertChunk = db.prepare(`\n INSERT OR IGNORE INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `);\n const titleInsertFts = db.prepare(`\n INSERT OR IGNORE INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n `);\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(notesDir)) {\n const fileName = basename(absPath);\n const m = SESSION_TITLE_RE.exec(fileName);\n if (!m) continue;\n const [, num, date, title] = m;\n const text = `Session #${num} ${date}: ${title}`;\n const relPath = relative(rootPath, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n db.transaction(() => {\n titleInsertChunk.run(id, projectId, \"notes\", \"session\", syntheticPath, 0, 0, hash, text, updatedAt);\n titleInsertFts.run(text, id, projectId, syntheticPath, \"notes\", \"session\", 0, 0);\n })();\n }\n }\n\n // {rootPath}/**/*.md — all other markdown content (e.g. year/month/topic dirs)\n // Uses walkContentFiles which skips memory/, Notes/, .git/, node_modules/ etc.\n // Skip the content scan for paths that are too broad (home dir, filesystem root, etc.)\n // to avoid runaway directory traversal. Memory and Notes scans above are always safe\n // because they target specific named subdirectories.\n if (!isPathTooBroadForContentScan(rootPath)) {\n for (const absPath of walkContentFiles(rootPath)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"content\", tier: \"topic\" });\n }\n }\n\n // Claude Code session notes directory (~/.claude/projects/{encoded}/Notes/)\n // Only scan if it is set, exists, and is not the same path as rootPath/Notes/\n if (claudeNotesDir && claudeNotesDir !== notesDir) {\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n filesToIndex.push({ absPath, rootBase: claudeNotesDir, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic title chunks for claude notes dir\n {\n const SESSION_TITLE_RE_CLAUDE = /^(\\d{4})\\s*-\\s*(\\d{4}-\\d{2}-\\d{2})\\s*-\\s*(.+)\\.md$/;\n const updatedAt = Date.now();\n const titleInsertChunk2 = db.prepare(`\n INSERT OR IGNORE INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `);\n const titleInsertFts2 = db.prepare(`\n INSERT OR IGNORE INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n `);\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n const fileName = basename(absPath);\n const m = SESSION_TITLE_RE_CLAUDE.exec(fileName);\n if (!m) continue;\n const [, num, date, title] = m;\n const text = `Session #${num} ${date}: ${title}`;\n const relPath = relative(claudeNotesDir, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n db.transaction(() => {\n titleInsertChunk2.run(id, projectId, \"notes\", \"session\", syntheticPath, 0, 0, hash, text, updatedAt);\n titleInsertFts2.run(text, id, projectId, syntheticPath, \"notes\", \"session\", 0, 0);\n })();\n }\n }\n\n // Derive the sibling memory/ directory: .../Notes/ → .../memory/\n if (claudeNotesDir.endsWith(\"/Notes\")) {\n const claudeProjectDir = claudeNotesDir.slice(0, -\"/Notes\".length);\n const claudeMemoryDir = join(claudeProjectDir, \"memory\");\n\n // MEMORY.md at the Claude Code project dir level (sibling of Notes/)\n const claudeMemoryMd = join(claudeProjectDir, \"MEMORY.md\");\n if (existsSync(claudeMemoryMd)) {\n filesToIndex.push({\n absPath: claudeMemoryMd,\n rootBase: claudeProjectDir,\n source: \"memory\",\n tier: \"evergreen\",\n });\n }\n\n // memory/ directory sibling of Notes/\n for (const absPath of walkMdFiles(claudeMemoryDir)) {\n const relPath = relative(claudeProjectDir, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: claudeProjectDir, source: \"memory\", tier });\n }\n }\n }\n\n // Yield after collection phase (which is synchronous) before we start processing\n await yieldToEventLoop();\n\n let filesSinceYield = 0;\n\n for (const { absPath, rootBase, source, tier } of filesToIndex) {\n // Yield to the event loop periodically so the IPC server stays responsive\n if (filesSinceYield >= INDEX_YIELD_EVERY) {\n await yieldToEventLoop();\n filesSinceYield = 0;\n }\n filesSinceYield++;\n\n const relPath = relative(rootBase, absPath);\n const changed = indexFile(db, projectId, rootBase, relPath, source, tier);\n\n if (changed) {\n // Count chunks created for this file\n const count = db\n .prepare(\n \"SELECT COUNT(*) as n FROM memory_chunks WHERE project_id = ? AND path = ?\",\n )\n .get(projectId, relPath) as { n: number };\n\n result.filesProcessed++;\n result.chunksCreated += count.n;\n } else {\n result.filesSkipped++;\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Global indexing (all registered projects)\n// ---------------------------------------------------------------------------\n\n/**\n * Yield to the Node.js event loop between projects so the IPC server\n * remains responsive during long index runs.\n */\nfunction yieldToEventLoop(): Promise<void> {\n return new Promise((resolve) => setImmediate(resolve));\n}\n\n/**\n * Index all active projects registered in the registry DB.\n *\n * Async: yields to the event loop between each project so that the daemon's\n * Unix socket server can process IPC requests (e.g. status) while indexing.\n */\nexport async function indexAll(\n db: Database,\n registryDb: Database,\n): Promise<{ projects: number; result: IndexResult }> {\n const projects = registryDb\n .prepare(\"SELECT id, root_path, claude_notes_dir FROM projects WHERE status = 'active'\")\n .all() as Array<{ id: number; root_path: string; claude_notes_dir: string | null }>;\n\n const totals: IndexResult = {\n filesProcessed: 0,\n chunksCreated: 0,\n filesSkipped: 0,\n };\n\n for (const project of projects) {\n // Yield before each project so the event loop can drain IPC requests\n await yieldToEventLoop();\n\n const r = await indexProject(db, project.id, project.root_path, project.claude_notes_dir);\n totals.filesProcessed += r.filesProcessed;\n totals.chunksCreated += r.chunksCreated;\n totals.filesSkipped += r.filesSkipped;\n }\n\n return { projects: projects.length, result: totals };\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation\n// ---------------------------------------------------------------------------\n\nexport interface EmbedResult {\n chunksEmbedded: number;\n chunksSkipped: number;\n}\n\n/**\n * Generate and store embeddings for chunks that do not yet have one.\n *\n * Because better-sqlite3 is synchronous but the embedding pipeline is async,\n * we fetch all unembedded chunk texts first, generate embeddings in batches,\n * and then write them back in a transaction.\n *\n * @param db Open federation database.\n * @param projectId Optional — restrict to a specific project.\n * @param batchSize Number of chunks to embed per round. Default 50.\n * @param onProgress Optional callback called after each batch with running totals.\n */\nexport async function embedChunks(\n db: Database,\n projectId?: number,\n batchSize = 50,\n onProgress?: (embedded: number, total: number) => void,\n): Promise<EmbedResult> {\n // Dynamic import — keeps the heavy ML runtime out of the module load path\n const { generateEmbedding, serializeEmbedding } = await import(\"./embeddings.js\");\n\n const conditions = [\"embedding IS NULL\"];\n const params: (string | number)[] = [];\n\n if (projectId !== undefined) {\n conditions.push(\"project_id = ?\");\n params.push(projectId);\n }\n\n const where = \"WHERE \" + conditions.join(\" AND \");\n\n const rows = db\n .prepare(`SELECT id, text FROM memory_chunks ${where} ORDER BY id`)\n .all(...params) as Array<{ id: string; text: string }>;\n\n if (rows.length === 0) {\n return { chunksEmbedded: 0, chunksSkipped: 0 };\n }\n\n const updateStmt = db.prepare(\n \"UPDATE memory_chunks SET embedding = ? WHERE id = ?\",\n );\n\n let embedded = 0;\n const total = rows.length;\n\n // Process in batches so progress callbacks are meaningful\n for (let i = 0; i < rows.length; i += batchSize) {\n const batch = rows.slice(i, i + batchSize);\n\n // Generate embeddings for the batch (async — must happen OUTSIDE transaction)\n const embeddings: Array<{ id: string; blob: Buffer }> = [];\n for (const row of batch) {\n const vec = await generateEmbedding(row.text);\n const blob = serializeEmbedding(vec);\n embeddings.push({ id: row.id, blob });\n }\n\n // Write the batch in a single transaction\n db.transaction(() => {\n for (const { id, blob } of embeddings) {\n updateStmt.run(blob, id);\n }\n })();\n\n embedded += embeddings.length;\n onProgress?.(embedded, total);\n }\n\n return { chunksEmbedded: embedded, chunksSkipped: 0 };\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ipc-client-DPy7s3iu.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = \"/tmp/pai.sock\";\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n */\n async call(method: string, params: Record<string, unknown>): Promise<unknown> {\n return this.send(method, params);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(\"IPC call timed out after 60s\"));\n }, IPC_TIMEOUT_MS);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2BA,MAAa,kBAAkB;;AAG/B,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;CAOlC,MAAM,KAAK,QAAgB,QAAmD;AAC5E,SAAO,KAAK,KAAK,QAAQ,OAAO;;;;;CAMlC,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAAkC;;;;;;CAYlF,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACkB;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,+BAA+B,CAAC;MAChD,eAAe;IAClB"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"migrate-Bwj7qPaE.mjs","names":[],"sources":["../src/registry/migrate.ts"],"sourcesContent":["/**\n * Migration helper: imports the existing JSON session-registry into the\n * new SQLite registry.db.\n *\n * Source file: ~/.claude/session-registry.json\n * Target: openRegistry() → projects + sessions tables\n *\n * The JSON registry uses encoded directory names as keys (Claude Code's\n * encoding: leading `/` is replaced by `-`, then each remaining `/` is also\n * replaced by `-`). This module reverses that encoding to recover the real\n * filesystem path.\n *\n * Session note filenames are expected in one of two formats:\n * Modern: \"NNNN - YYYY-MM-DD - Description.md\" (space-dash-space)\n * Legacy: \"NNNN_YYYY-MM-DD_description.md\" (underscores)\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Shape of a single entry in session-registry.json */\ninterface RegistryEntry {\n /** Absolute path to the Notes/ directory for this project */\n notesDir?: string;\n /** Display name stored in the registry (optional) */\n displayName?: string;\n /** Any other keys the file might carry */\n [key: string]: unknown;\n}\n\n/** Top-level shape of session-registry.json */\ntype SessionRegistry = Record<string, RegistryEntry>;\n\n// ---------------------------------------------------------------------------\n// Encoding / decoding\n// ---------------------------------------------------------------------------\n\n/**\n * Build a lookup table from session-registry.json mapping encoded_dir →\n * original_path. This is the authoritative source for decoding because the\n * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal\n * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.\n *\n * Example:\n * `-Users-alice--ssh` encodes `/Users/alice/.ssh`\n * `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`\n * encodes `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`\n *\n * @param jsonPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n * @returns Map from encoded_dir → original_path, or empty map if the file is\n * missing / unparseable.\n */\nexport function buildEncodedDirMap(\n jsonPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): Map<string, string> {\n const map = new Map<string, string>();\n if (!existsSync(jsonPath)) return map;\n\n try {\n const raw = readFileSync(jsonPath, \"utf8\");\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Support both formats:\n // list-based: { \"projects\": [ { \"encoded_dir\", \"original_path\" }, ... ] }\n // object-keyed: { \"<encoded_dir>\": { ... } } (original Claude format)\n if (Array.isArray(parsed.projects)) {\n for (const entry of parsed.projects as Array<Record<string, unknown>>) {\n const key = entry.encoded_dir as string | undefined;\n const val = entry.original_path as string | undefined;\n if (key && val) map.set(key, val);\n }\n } else {\n // Object-keyed format — keys are encoded dirs\n for (const [key, value] of Object.entries(parsed)) {\n if (key === \"version\") continue;\n const val = (value as Record<string, unknown>)?.original_path as\n | string\n | undefined;\n if (val) map.set(key, val);\n }\n }\n } catch {\n // Unparseable — return empty map; callers fall back to heuristic decode\n }\n\n return map;\n}\n\n/**\n * Reverse Claude Code's directory encoding.\n *\n * Claude Code's actual encoding rules:\n * - `/` (path separator) → `-`\n * - ` ` (space) → `--` (escaped)\n * - `.` (dot) → `--` (escaped)\n * - `-` (literal hyphen) → `--` (escaped)\n *\n * Because space, dot, and hyphen all encode to `--`, the encoding is\n * **lossy** — you cannot unambiguously reverse it. This function therefore\n * provides a *best-effort* heuristic decode (treating `--` as a literal `-`\n * which gives wrong results for paths with spaces or dots).\n *\n * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping\n * from session-registry.json instead of calling this function directly.\n *\n * Examples (best-effort, may be wrong for paths with spaces/dots):\n * `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`\n * `-Users-alice--ssh` → `/Users/alice/-ssh` ← WRONG (actually .ssh)\n *\n * @param encoded The Claude-encoded directory name.\n * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.\n * If provided and the key is found, that value is returned\n * instead of the heuristic result.\n */\nexport function decodeEncodedDir(\n encoded: string,\n lookupMap?: Map<string, string>\n): string {\n // Authoritative lookup wins\n if (lookupMap?.has(encoded)) {\n return lookupMap.get(encoded)!;\n }\n\n // Best-effort heuristic: every `-` maps to `/`.\n // This is correct for simple paths (no spaces, dots, or literal hyphens\n // in component names) but will produce wrong results for e.g. `.ssh`\n // (decoded as `/ssh` instead of `/.ssh`). That's acceptable here because\n // callers should be using the lookupMap for paths that exist in the registry.\n if (encoded.startsWith(\"-\")) {\n return encoded.replace(/-/g, \"/\");\n }\n\n // Not a Claude-encoded path — return as-is\n return encoded;\n}\n\n// ---------------------------------------------------------------------------\n// Slug generation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a URL-safe kebab-case slug from an arbitrary string.\n *\n * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.\n */\nexport function slugify(value: string): string {\n // Take last path segment if it looks like a path\n const segment = value.includes(\"/\")\n ? value.replace(/\\/$/, \"\").split(\"/\").pop() ?? value\n : value;\n\n return segment\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\") // non-alphanumeric runs → single dash\n .replace(/^-+|-+$/g, \"\"); // trim leading/trailing dashes\n}\n\n// ---------------------------------------------------------------------------\n// Session note parsing\n// ---------------------------------------------------------------------------\n\ninterface ParsedSession {\n number: number;\n date: string;\n slug: string;\n title: string;\n filename: string;\n}\n\n/** Match `0027 - 2026-01-04 - Some Description.md` */\nconst MODERN_RE = /^(\\d{4})\\s+-\\s+(\\d{4}-\\d{2}-\\d{2})\\s+-\\s+(.+)\\.md$/i;\n\n/** Match `0027_2026-01-04_some_description.md` */\nconst LEGACY_RE = /^(\\d{4})_(\\d{4}-\\d{2}-\\d{2})_(.+)\\.md$/i;\n\n/**\n * Attempt to parse a session note filename into its structured parts.\n *\n * Returns `null` if the filename does not match either known format.\n */\nexport function parseSessionFilename(\n filename: string\n): ParsedSession | null {\n let m = MODERN_RE.exec(filename);\n if (m) {\n const [, num, date, description] = m;\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n m = LEGACY_RE.exec(filename);\n if (m) {\n const [, num, date, rawDesc] = m;\n const description = rawDesc.replace(/_/g, \" \");\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Migration\n// ---------------------------------------------------------------------------\n\nexport interface MigrationResult {\n projectsInserted: number;\n projectsSkipped: number;\n sessionsInserted: number;\n errors: string[];\n}\n\n/**\n * Migrate the existing JSON session-registry into the SQLite registry.\n *\n * @param db Open better-sqlite3 Database (target).\n * @param registryPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n *\n * The migration is idempotent: projects and sessions that already exist\n * (matched by slug / project_id+number) are silently skipped.\n */\nexport function migrateFromJson(\n db: Database,\n registryPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): MigrationResult {\n const result: MigrationResult = {\n projectsInserted: 0,\n projectsSkipped: 0,\n sessionsInserted: 0,\n errors: [],\n };\n\n // ── Load source file ──────────────────────────────────────────────────────\n if (!existsSync(registryPath)) {\n result.errors.push(`Registry file not found: ${registryPath}`);\n return result;\n }\n\n let registry: SessionRegistry;\n try {\n const raw = readFileSync(registryPath, \"utf8\");\n registry = JSON.parse(raw) as SessionRegistry;\n } catch (err) {\n result.errors.push(`Failed to parse registry JSON: ${String(err)}`);\n return result;\n }\n\n // ── Prepared statements ───────────────────────────────────────────────────\n const insertProject = db.prepare(`\n INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status,\n created_at, updated_at)\n VALUES\n (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',\n @created_at, @updated_at)\n `);\n\n const getProject = db.prepare(\n \"SELECT id FROM projects WHERE slug = ?\"\n );\n\n const insertSession = db.prepare(`\n INSERT OR IGNORE INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES\n (@project_id, @number, @date, @slug, @title, @filename, 'completed',\n @created_at)\n `);\n\n const now = Date.now();\n\n // ── Build authoritative encoded-dir → path lookup ─────────────────────────\n const lookupMap = buildEncodedDirMap(registryPath);\n\n // ── Process each encoded directory entry ──────────────────────────────────\n for (const [encodedDir, entry] of Object.entries(registry)) {\n const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n const baseSlug = slugify(rootPath);\n\n // --- Upsert project ---\n let slug = baseSlug;\n let attempt = 0;\n while (true) {\n const info = insertProject.run({\n slug,\n display_name:\n (entry.displayName as string | undefined) ??\n (rootPath.split(\"/\").pop() ?? rootPath),\n root_path: rootPath,\n encoded_dir: encodedDir,\n created_at: now,\n updated_at: now,\n });\n\n if (info.changes > 0) {\n result.projectsInserted++;\n break;\n }\n\n // Row existed — check if it's ours (matching root_path) or a collision\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath);\n if (existing) {\n result.projectsSkipped++;\n break;\n }\n\n // Genuine slug collision — append numeric suffix and retry\n attempt++;\n slug = `${baseSlug}-${attempt}`;\n }\n\n const projectRow = getProject.get(slug) as { id: number } | undefined;\n // Also check by root_path in case slug was different\n const projectById = projectRow ??\n (db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined);\n\n if (!projectById) {\n result.errors.push(\n `Could not resolve project id for encoded dir: ${encodedDir}`\n );\n continue;\n }\n\n const projectId = projectById.id;\n\n // --- Scan Notes/ directory for session notes ---\n const notesDir =\n typeof entry.notesDir === \"string\"\n ? entry.notesDir\n : join(rootPath, \"Notes\");\n\n if (!existsSync(notesDir)) {\n // No notes directory — that is fine, project still gets created\n continue;\n }\n\n let files: string[];\n try {\n files = readdirSync(notesDir);\n } catch (err) {\n result.errors.push(\n `Cannot read notes dir ${notesDir}: ${String(err)}`\n );\n continue;\n }\n\n for (const filename of files) {\n if (!filename.endsWith(\".md\")) continue;\n\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n try {\n const info = insertSession.run({\n project_id: projectId,\n number: parsed.number,\n date: parsed.date,\n slug: parsed.slug,\n title: parsed.title,\n filename: parsed.filename,\n created_at: now,\n });\n if (info.changes > 0) result.sessionsInserted++;\n } catch (err) {\n result.errors.push(\n `Failed to insert session ${filename}: ${String(err)}`\n );\n }\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils-DEWdIFQ0.mjs","names":[],"sources":["../src/cli/utils.ts"],"sourcesContent":["/**\n * Shared utilities for CLI commands: formatting helpers, path encoding,\n * slug generation, and chalk colour wrappers.\n */\n\nimport chalk from \"chalk\";\nimport { resolve, basename, join } from \"node:path\";\nimport { mkdirSync, existsSync, writeFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Chalk colour helpers (thin wrappers so callers don't import chalk directly)\n// ---------------------------------------------------------------------------\n\nexport const ok = (msg: string) => chalk.green(msg);\nexport const warn = (msg: string) => chalk.yellow(msg);\nexport const err = (msg: string) => chalk.red(msg);\nexport const dim = (msg: string) => chalk.dim(msg);\nexport const bold = (msg: string) => chalk.bold(msg);\nexport const header = (msg: string) => chalk.bold.underline(msg);\n\n// ---------------------------------------------------------------------------\n// Path / slug helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert any path string into a kebab-case slug.\n * \"/Users/foo/my-project\" → \"my-project\"\n * \"Some Cool Project\" → \"some-cool-project\"\n */\nexport function slugify(input: string): string {\n return input\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\") // non-alphanum runs → single hyphen\n .replace(/^-+|-+$/g, \"\"); // strip leading/trailing hyphens\n}\n\n/**\n * Derive a default project slug from the last component of a path.\n * \"/home/user/my-project\" → \"my-project\"\n */\nexport function slugFromPath(projectPath: string): string {\n return slugify(basename(projectPath));\n}\n\n/**\n * Encode an absolute path into Claude Code's encoded-dir format.\n *\n * Claude Code's actual encoding rules (reverse-engineered from real data):\n * - Every `/`, ` ` (space), `.` (dot), and `-` (literal hyphen) → single `-`\n * - The result therefore starts with `-` (from the leading `/`)\n *\n * This is a lossy encoding — space, dot, hyphen, and path-separator all\n * collapse to the same token. The decode is therefore ambiguous; prefer\n * {@link buildEncodedDirMap} from migrate.ts to get authoritative mappings.\n *\n * Examples:\n * \"/Users/foo/my-project\" → \"-Users-foo-my-project\"\n * \"/Users/foo/04 - Ablage\" → \"-Users-foo-04---Ablage\"\n * \"/Users/foo/.ssh\" → \"-Users-foo--ssh\"\n * \"/Users/foo/MDF-System.de\" → \"-Users-foo-MDF-System-de\"\n *\n * NOTE: For `project add`, prefer {@link findExistingEncodedDir} to look up\n * whether Claude Code has already created a directory for this path — that\n * avoids any mismatch between our encoding and Claude's.\n */\nexport function encodeDir(absolutePath: string): string {\n // Every `/`, space, dot, and hyphen → single `-`\n // The leading `/` produces the leading `-` that all encoded dirs start with.\n return absolutePath.replace(/[\\/\\s.\\-]/g, \"-\");\n}\n\n/**\n * Look up an absolute path in ~/.claude/projects/ to find the encoded-dir\n * name that Claude Code actually uses for it.\n *\n * This is more reliable than {@link encodeDir} because it reads the real\n * filesystem rather than re-implementing Claude's encoding algorithm.\n *\n * Returns the encoded-dir string (e.g. \"-Users-foo-my-project\") if a match\n * is found in ~/.claude/projects/, or `null` if not present.\n */\nexport function findExistingEncodedDir(absolutePath: string): string | null {\n const claudeProjectsDir = join(homedir(), \".claude\", \"projects\");\n if (!existsSync(claudeProjectsDir)) return null;\n\n // Build the expected encoded form to compare against directory names\n const expected = encodeDir(absolutePath);\n\n try {\n const entries = readdirSync(claudeProjectsDir);\n // Exact match (our encoding matches Claude's)\n if (entries.includes(expected)) return expected;\n\n // Fallback: scan all entries and compare the decoded path.\n // Import decodeEncodedDir lazily to avoid circular dependency.\n for (const entry of entries) {\n const full = join(claudeProjectsDir, entry);\n try {\n if (!statSync(full).isDirectory()) continue;\n } catch {\n continue;\n }\n // Simple heuristic decode: `--` → `-`, single `-` → `/`\n // (good enough for finding exact matches via the registry JSON)\n if (entry === expected) return entry;\n }\n } catch {\n // Unreadable directory — ignore\n }\n\n return null;\n}\n\n/**\n * Decode a Claude encoded-dir back to an approximate absolute path.\n *\n * NOTE: This decode is best-effort only — the encoding is lossy (space, dot,\n * literal hyphen, and path-separator all collapse to `-`). Prefer reading\n * original_path from session-registry.json via buildEncodedDirMap() in\n * src/registry/migrate.ts for authoritative decoding.\n *\n * \"-Users-foo-my-project\" → \"/Users/foo/my-project\"\n */\nexport function decodeDir(encodedDir: string): string {\n if (!encodedDir) return \"/\";\n // Encoded dirs start with `-` (from the leading `/`).\n // Best-effort: treat every `-` as `/`.\n return encodedDir.replace(/-/g, \"/\");\n}\n\n/**\n * Resolve a raw CLI path argument to an absolute path.\n */\nexport function resolvePath(rawPath: string): string {\n return resolve(rawPath);\n}\n\n// ---------------------------------------------------------------------------\n// Filesystem scaffolding\n// ---------------------------------------------------------------------------\n\nconst MEMORY_MD_SCAFFOLD = `# Memory\n\nProject-specific memory for PAI sessions.\nAdd persistent notes, reminders, and context here.\n`;\n\n/**\n * Ensure Notes/ and memory/ sub-directories exist under `projectRoot`.\n * Also creates a memory/MEMORY.md scaffold if it does not yet exist.\n */\nexport function scaffoldProjectDirs(projectRoot: string): void {\n const notesDir = `${projectRoot}/Notes`;\n const memoryDir = `${projectRoot}/memory`;\n const memoryFile = `${memoryDir}/MEMORY.md`;\n\n mkdirSync(notesDir, { recursive: true });\n mkdirSync(memoryDir, { recursive: true });\n\n if (!existsSync(memoryFile)) {\n writeFileSync(memoryFile, MEMORY_MD_SCAFFOLD, \"utf8\");\n }\n}\n\n// ---------------------------------------------------------------------------\n// Table rendering\n// ---------------------------------------------------------------------------\n\n/**\n * Pad a string to a minimum width (left-aligned).\n */\nexport function pad(str: string, width: number): string {\n const plain = stripAnsi(str);\n const extra = width - plain.length;\n return str + (extra > 0 ? \" \".repeat(extra) : \"\");\n}\n\n/**\n * Strip ANSI escape sequences to measure visible string length.\n */\nfunction stripAnsi(str: string): string {\n // eslint-disable-next-line no-control-regex\n return str.replace(/\\x1B\\[[0-9;]*m/g, \"\");\n}\n\n/**\n * Render a simple columnar table.\n *\n * @param headers Column header strings\n * @param rows Array of row arrays (each cell is a string, may include chalk sequences)\n */\nexport function renderTable(headers: string[], rows: string[][]): string {\n const allRows = [headers, ...rows];\n\n // Compute column widths\n const widths = headers.map((_, colIdx) =>\n Math.max(...allRows.map((row) => stripAnsi(row[colIdx] ?? \"\").length))\n );\n\n const divider = dim(\" \" + widths.map((w) => \"-\".repeat(w)).join(\" \"));\n const renderRow = (row: string[], isHeader = false) => {\n const cells = widths.map((w, i) => {\n const cell = row[i] ?? \"\";\n return isHeader ? pad(bold(cell), w + (cell.length - stripAnsi(cell).length)) : pad(cell, w + (cell.length - stripAnsi(cell).length));\n });\n return \" \" + cells.join(\" \");\n };\n\n const lines: string[] = [];\n lines.push(renderRow(headers, true));\n lines.push(divider);\n for (const row of rows) {\n lines.push(renderRow(row));\n }\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Date helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Shorten an absolute path for display: replace home dir with ~,\n * truncate from left if still longer than maxLen.\n */\nexport function shortenPath(absolutePath: string, maxLen = 40): string {\n const home = homedir();\n let p = absolutePath;\n if (p.startsWith(home)) {\n p = \"~\" + p.slice(home.length);\n }\n if (p.length <= maxLen) return p;\n return \"...\" + p.slice(p.length - maxLen + 3);\n}\n\n/**\n * Format an epoch milliseconds timestamp as YYYY-MM-DD.\n */\nexport function fmtDate(epochMs: number | null | undefined): string {\n if (epochMs == null) return dim(\"—\");\n return new Date(epochMs).toISOString().slice(0, 10);\n}\n\n/**\n * Return the current epoch milliseconds.\n */\nexport function now(): number {\n return Date.now();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAcA,MAAa,MAAM,QAAgB,MAAM,MAAM,IAAI;AACnD,MAAa,QAAQ,QAAgB,MAAM,OAAO,IAAI;AACtD,MAAa,OAAO,QAAgB,MAAM,IAAI,IAAI;AAClD,MAAa,OAAO,QAAgB,MAAM,IAAI,IAAI;AAClD,MAAa,QAAQ,QAAgB,MAAM,KAAK,IAAI;AACpD,MAAa,UAAU,QAAgB,MAAM,KAAK,UAAU,IAAI;;;;;;AAWhE,SAAgB,QAAQ,OAAuB;AAC7C,QAAO,MACJ,aAAa,CACb,QAAQ,eAAe,IAAI,CAC3B,QAAQ,YAAY,GAAG;;;;;;AAO5B,SAAgB,aAAa,aAA6B;AACxD,QAAO,QAAQ,SAAS,YAAY,CAAC;;;;;;;;;;;;;;;;;;;;;;;AAwBvC,SAAgB,UAAU,cAA8B;AAGtD,QAAO,aAAa,QAAQ,cAAc,IAAI;;;;;AAiEhD,SAAgB,YAAY,SAAyB;AACnD,QAAO,QAAQ,QAAQ;;AAOzB,MAAM,qBAAqB;;;;;;;;;AAU3B,SAAgB,oBAAoB,aAA2B;CAC7D,MAAM,WAAW,GAAG,YAAY;CAChC,MAAM,YAAY,GAAG,YAAY;CACjC,MAAM,aAAa,GAAG,UAAU;AAEhC,WAAU,UAAU,EAAE,WAAW,MAAM,CAAC;AACxC,WAAU,WAAW,EAAE,WAAW,MAAM,CAAC;AAEzC,KAAI,CAAC,WAAW,WAAW,CACzB,eAAc,YAAY,oBAAoB,OAAO;;;;;AAWzD,SAAgB,IAAI,KAAa,OAAuB;CAEtD,MAAM,QAAQ,QADA,UAAU,IAAI,CACA;AAC5B,QAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,MAAM,GAAG;;;;;AAMhD,SAAS,UAAU,KAAqB;AAEtC,QAAO,IAAI,QAAQ,mBAAmB,GAAG;;;;;;;;AAS3C,SAAgB,YAAY,SAAmB,MAA0B;CACvE,MAAM,UAAU,CAAC,SAAS,GAAG,KAAK;CAGlC,MAAM,SAAS,QAAQ,KAAK,GAAG,WAC7B,KAAK,IAAI,GAAG,QAAQ,KAAK,QAAQ,UAAU,IAAI,WAAW,GAAG,CAAC,OAAO,CAAC,CACvE;CAED,MAAM,UAAU,IAAI,OAAO,OAAO,KAAK,MAAM,IAAI,OAAO,EAAE,CAAC,CAAC,KAAK,KAAK,CAAC;CACvE,MAAM,aAAa,KAAe,WAAW,UAAU;AAKrD,SAAO,OAJO,OAAO,KAAK,GAAG,MAAM;GACjC,MAAM,OAAO,IAAI,MAAM;AACvB,UAAO,WAAW,IAAI,KAAK,KAAK,EAAE,KAAK,KAAK,SAAS,UAAU,KAAK,CAAC,QAAQ,GAAG,IAAI,MAAM,KAAK,KAAK,SAAS,UAAU,KAAK,CAAC,QAAQ;IACrI,CACkB,KAAK,KAAK;;CAGhC,MAAM,QAAkB,EAAE;AAC1B,OAAM,KAAK,UAAU,SAAS,KAAK,CAAC;AACpC,OAAM,KAAK,QAAQ;AACnB,MAAK,MAAM,OAAO,KAChB,OAAM,KAAK,UAAU,IAAI,CAAC;AAE5B,QAAO,MAAM,KAAK,KAAK;;;;;;AAWzB,SAAgB,YAAY,cAAsB,SAAS,IAAY;CACrE,MAAM,OAAO,SAAS;CACtB,IAAI,IAAI;AACR,KAAI,EAAE,WAAW,KAAK,CACpB,KAAI,MAAM,EAAE,MAAM,KAAK,OAAO;AAEhC,KAAI,EAAE,UAAU,OAAQ,QAAO;AAC/B,QAAO,QAAQ,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE;;;;;AAM/C,SAAgB,QAAQ,SAA4C;AAClE,KAAI,WAAW,KAAM,QAAO,IAAI,IAAI;AACpC,QAAO,IAAI,KAAK,QAAQ,CAAC,aAAa,CAAC,MAAM,GAAG,GAAG;;;;;AAMrD,SAAgB,MAAc;AAC5B,QAAO,KAAK,KAAK"}