@tekmidian/pai 0.5.6 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/dist/cli/index.mjs +479 -5
- package/dist/cli/index.mjs.map +1 -1
- package/dist/daemon/index.mjs +2 -2
- package/dist/{daemon-D9evGlgR.mjs → daemon-2ND5WO2j.mjs} +3 -3
- package/dist/{daemon-D9evGlgR.mjs.map → daemon-2ND5WO2j.mjs.map} +1 -1
- package/dist/{db-4lSqLFb8.mjs → db-BtuN768f.mjs} +9 -2
- package/dist/db-BtuN768f.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs +19 -4
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +14 -9
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs +14 -8
- package/dist/hooks/initialize-session.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +18 -2
- package/dist/hooks/load-core-context.mjs.map +4 -4
- package/dist/hooks/load-project-context.mjs +14 -8
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +105 -8
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/mcp/index.mjs +1 -1
- package/dist/{vault-indexer-DXWs9pDn.mjs → vault-indexer-k-kUlaZ-.mjs} +41 -7
- package/dist/vault-indexer-k-kUlaZ-.mjs.map +1 -0
- package/package.json +1 -1
- package/src/hooks/ts/capture-all-events.ts +6 -0
- package/src/hooks/ts/lib/project-utils.ts +24 -5
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +6 -0
- package/src/hooks/ts/session-start/initialize-session.ts +7 -1
- package/src/hooks/ts/session-start/load-core-context.ts +7 -0
- package/src/hooks/ts/session-start/load-project-context.ts +8 -1
- package/src/hooks/ts/stop/stop-hook.ts +28 -0
- package/templates/claude-md.template.md +7 -74
- package/templates/skills/CORE/Aesthetic.md +333 -0
- package/templates/skills/CORE/CONSTITUTION.md +1502 -0
- package/templates/skills/CORE/HistorySystem.md +427 -0
- package/templates/skills/CORE/HookSystem.md +1082 -0
- package/templates/skills/CORE/Prompting.md +509 -0
- package/templates/skills/CORE/ProsodyAgentTemplate.md +53 -0
- package/templates/skills/CORE/ProsodyGuide.md +416 -0
- package/templates/skills/CORE/SKILL.md +741 -0
- package/templates/skills/CORE/SkillSystem.md +213 -0
- package/templates/skills/CORE/TerminalTabs.md +119 -0
- package/templates/skills/CORE/VOICE.md +106 -0
- package/templates/skills/user/.gitkeep +0 -0
- package/dist/db-4lSqLFb8.mjs.map +0 -1
- package/dist/vault-indexer-DXWs9pDn.mjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/hooks/ts/post-tool-use/sync-todo-to-md.ts", "../../src/hooks/ts/lib/project-utils.ts", "../../src/hooks/ts/lib/pai-paths.ts"],
|
|
4
|
-
"sourcesContent": ["#!/usr/bin/env node\n/**\n * sync-todo-to-md.ts\n *\n * PostToolUse hook for TodoWrite that:\n * 1. Syncs Claude's todos to TODO.md \"Current Session\" section\n * 2. PRESERVES all user-managed sections (Plans, Completed, Backlog, etc.)\n * 3. Adds completed items to the session note\n *\n * IMPORTANT: This hook PRESERVES user content. It only updates \"Current Session\".\n */\n\nimport { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport {\n findTodoPath,\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n type WorkItem\n} from '../lib/project-utils';\n\ninterface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n activeForm: string;\n}\n\ninterface HookInput {\n session_id: string;\n cwd: string;\n tool_name: string;\n tool_input: {\n todos: TodoItem[];\n };\n}\n\n/**\n * Format current session todos as markdown\n */\nfunction formatSessionTodos(todos: TodoItem[]): string {\n const inProgress = todos.filter(t => t.status === 'in_progress');\n const pending = todos.filter(t => t.status === 'pending');\n const completed = todos.filter(t => t.status === 'completed');\n\n let content = '';\n\n if (inProgress.length > 0) {\n content += `### In Progress\\n\\n`;\n for (const todo of inProgress) {\n content += `- [ ] **${todo.content}** _(${todo.activeForm})_\\n`;\n }\n content += '\\n';\n }\n\n if (pending.length > 0) {\n content += `### Pending\\n\\n`;\n for (const todo of pending) {\n content += `- [ ] ${todo.content}\\n`;\n }\n content += '\\n';\n }\n\n if (completed.length > 0) {\n content += `### Completed\\n\\n`;\n for (const todo of completed) {\n content += `- [x] ${todo.content}\\n`;\n }\n content += '\\n';\n }\n\n if (todos.length === 0) {\n content += `_(No active session tasks)_\\n\\n`;\n }\n\n return content;\n}\n\n/**\n * Extract all sections from TODO.md EXCEPT \"Current Session\"\n * These are user-managed sections that should be preserved.\n */\nfunction extractPreservedSections(content: string): string {\n let preserved = '';\n\n // Match all ## sections that are NOT \"Current Session\"\n const sectionRegex = /\\n(## (?!Current Session)[^\\n]+[\\s\\S]*?)(?=\\n## |\\n---\\n+\\*Last updated|$)/g;\n const matches = content.matchAll(sectionRegex);\n\n for (const match of matches) {\n preserved += match[1];\n }\n\n return preserved;\n}\n\n/**\n * Fix malformed headings: Remove --- prefix from headings (---# \u2192 #)\n * Claude sometimes incorrectly merges horizontal rules with headings.\n */\nfunction fixMalformedHeadings(content: string): string {\n return content.replace(/^---#/gm, '#');\n}\n\n/**\n * Build new TODO.md preserving user sections\n */\nfunction buildTodoContent(todos: TodoItem[], existingContent: string): string {\n const now = new Date().toISOString();\n\n // Get all preserved sections (everything except Current Session)\n const preserved = extractPreservedSections(existingContent);\n\n // Build new content\n let content = `# TODO\n\n## Current Session\n\n${formatSessionTodos(todos)}`;\n\n // Add preserved sections\n if (preserved.trim()) {\n content += preserved;\n }\n\n // Ensure we end with exactly one timestamp\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)*$/, '');\n content += `\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n return content;\n}\n\nasync function main() {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const stdinData = Buffer.concat(chunks).toString('utf-8');\n\n if (!stdinData.trim()) {\n console.error('No input received');\n process.exit(0);\n }\n\n const hookInput: HookInput = JSON.parse(stdinData);\n\n if (hookInput.tool_name !== 'TodoWrite') {\n process.exit(0);\n }\n\n const todos = hookInput.tool_input?.todos;\n\n if (!todos || !Array.isArray(todos)) {\n console.error('No todos in tool input');\n process.exit(0);\n }\n\n const cwd = hookInput.cwd || process.cwd();\n\n // Find TODO.md path\n const todoPath = findTodoPath(cwd);\n\n // Create TODO.md if it doesn't exist\n if (!existsSync(todoPath)) {\n const parentDir = todoPath.replace(/\\/[^/]+$/, '');\n mkdirSync(parentDir, { recursive: true });\n console.error(`Creating TODO.md at ${todoPath}`);\n }\n\n // Read existing content to preserve user sections\n let existingContent = '';\n try {\n existingContent = readFileSync(todoPath, 'utf-8');\n } catch (e) {\n // New file, no content to preserve\n }\n\n // Build and write new content (with heading fix)\n let newContent = buildTodoContent(todos, existingContent);\n newContent = fixMalformedHeadings(newContent);\n writeFileSync(todoPath, newContent);\n\n const stats = {\n inProgress: todos.filter(t => t.status === 'in_progress').length,\n pending: todos.filter(t => t.status === 'pending').length,\n completed: todos.filter(t => t.status === 'completed').length\n };\n console.error(`TODO.md synced: ${stats.inProgress} in progress, ${stats.pending} pending, ${stats.completed} completed`);\n\n // Add completed items to session note (if local Notes/ exists)\n const completedTodos = todos.filter(t => t.status === 'completed');\n\n if (completedTodos.length > 0) {\n const notesInfo = findNotesDir(cwd);\n\n if (notesInfo.isLocal) {\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n let noteContent = '';\n try {\n noteContent = readFileSync(currentNotePath, 'utf-8');\n } catch (e) {\n console.error('Could not read session note:', e);\n }\n\n const newlyCompleted = completedTodos.filter(t => !noteContent.includes(t.content));\n\n if (newlyCompleted.length > 0) {\n const workItems: WorkItem[] = newlyCompleted.map(t => ({\n title: t.content,\n completed: true\n }));\n\n addWorkToSessionNote(currentNotePath, workItems);\n console.error(`Added ${newlyCompleted.length} completed item(s) to session note`);\n }\n }\n }\n }\n\n } catch (error) {\n console.error('sync-todo-to-md error:', error);\n }\n\n process.exit(0);\n}\n\nmain();\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * No PAI dependency \u2014 works for any Claude Code user with whazaa installed.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const { homedir } = require('os');\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('whazaa');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n\n/**\n * Update the ## Continue section at the top of TODO.md.\n * This mirrors \"pause session\" behavior \u2014 gives the next session a starting point.\n * Replaces any existing ## Continue section.\n */\nexport function updateTodoContinue(\n cwd: string,\n noteFilename: string,\n state: string | null,\n tokenDisplay: string\n): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove existing ## Continue section (from ## Continue to the first standalone --- line)\n content = content.replace(/## Continue\\n[\\s\\S]*?\\n---\\n+/, '');\n\n const now = new Date().toISOString();\n const stateLines = state\n ? state.split('\\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\\n')\n : `> Check the latest session note for details.`;\n\n const continueSection = `## Continue\n\n> **Last session:** ${noteFilename.replace('.md', '')}\n> **Paused at:** ${now}\n>\n${stateLines}\n\n---\n\n`;\n\n // Remove leading whitespace from content\n content = content.replace(/^\\s+/, '');\n\n // If content starts with # title, insert after it\n const titleMatch = content.match(/^(# [^\\n]+\\n+)/);\n if (titleMatch) {\n content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);\n } else {\n content = continueSection + content;\n }\n\n // Clean up trailing timestamps and add fresh one\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error('TODO.md ## Continue section updated');\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
|
|
5
|
-
"mappings": ";;;AAYA,SAAS,iBAAAA,gBAAe,cAAAC,aAAY,gBAAAC,eAAc,aAAAC,kBAAiB;;;ACFnE,SAAS,cAAAC,aAAY,WAAW,aAAa,gBAAAC,eAAc,eAAe,kBAAkB;AAC5F,SAAS,QAAAC,OAAM,gBAAgB;;;ACE/B,SAAS,eAAe;AACxB,SAAS,SAAS,YAAY;AAC9B,SAAS,YAAY,oBAAoB;AAMzC,SAAS,cAAoB;AAE3B,QAAM,gBAAgB;AAAA,IACpB,QAAQ,QAAQ,IAAI,WAAW,IAAI,MAAM;AAAA,IACzC,QAAQ,QAAQ,GAAG,WAAW,MAAM;AAAA,EACtC;AAEA,aAAW,WAAW,eAAe;AACnC,QAAI,WAAW,OAAO,GAAG;AACvB,UAAI;AACF,cAAM,UAAU,aAAa,SAAS,OAAO;AAC7C,mBAAW,QAAQ,QAAQ,MAAM,IAAI,GAAG;AACtC,gBAAM,UAAU,KAAK,KAAK;AAE1B,cAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,EAAG;AAEzC,gBAAM,UAAU,QAAQ,QAAQ,GAAG;AACnC,cAAI,UAAU,GAAG;AACf,kBAAM,MAAM,QAAQ,UAAU,GAAG,OAAO,EAAE,KAAK;AAC/C,gBAAI,QAAQ,QAAQ,UAAU,UAAU,CAAC,EAAE,KAAK;AAGhD,gBAAK,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,KAC3C,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,GAAI;AAClD,sBAAQ,MAAM,MAAM,GAAG,EAAE;AAAA,YAC3B;AAGA,oBAAQ,MAAM,QAAQ,WAAW,QAAQ,CAAC;AAC1C,oBAAQ,MAAM,QAAQ,cAAc,QAAQ,CAAC;AAG7C,gBAAI,QAAQ,IAAI,GAAG,MAAM,QAAW;AAClC,sBAAQ,IAAI,GAAG,IAAI;AAAA,YACrB;AAAA,UACF;AAAA,QACF;AAEA;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AACF;AAGA,YAAY;AAQL,IAAM,UAAU,QAAQ,IAAI,UAC/B,QAAQ,QAAQ,IAAI,OAAO,IAC3B,QAAQ,QAAQ,GAAG,SAAS;AAKzB,IAAM,YAAY,KAAK,SAAS,OAAO;AACvC,IAAM,aAAa,KAAK,SAAS,QAAQ;AACzC,IAAM,aAAa,KAAK,SAAS,QAAQ;AACzC,IAAM,cAAc,KAAK,SAAS,SAAS;AAC3C,IAAM,eAAe,KAAK,SAAS,UAAU;AAMpD,SAAS,uBAA6B;AACpC,MAAI,CAAC,WAAW,OAAO,GAAG;AACxB,YAAQ,MAAM,2BAA2B,OAAO,EAAE;AAClD,YAAQ,MAAM,2DAA2D;AACzE,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI,CAAC,WAAW,SAAS,GAAG;AAC1B,YAAQ,MAAM,kCAAkC,SAAS,EAAE;AAC3D,YAAQ,MAAM,sCAAsC;AACpD,YAAQ,MAAM,uBAAuB,OAAO,EAAE;AAC9C,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;AAIA,qBAAqB;;;
|
|
4
|
+
"sourcesContent": ["#!/usr/bin/env node\n/**\n * sync-todo-to-md.ts\n *\n * PostToolUse hook for TodoWrite that:\n * 1. Syncs Claude's todos to TODO.md \"Current Session\" section\n * 2. PRESERVES all user-managed sections (Plans, Completed, Backlog, etc.)\n * 3. Adds completed items to the session note\n *\n * IMPORTANT: This hook PRESERVES user content. It only updates \"Current Session\".\n */\n\nimport { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport {\n findTodoPath,\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n type WorkItem\n} from '../lib/project-utils';\n\ninterface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n activeForm: string;\n}\n\ninterface HookInput {\n session_id: string;\n cwd: string;\n tool_name: string;\n tool_input: {\n todos: TodoItem[];\n };\n}\n\n/**\n * Format current session todos as markdown\n */\nfunction formatSessionTodos(todos: TodoItem[]): string {\n const inProgress = todos.filter(t => t.status === 'in_progress');\n const pending = todos.filter(t => t.status === 'pending');\n const completed = todos.filter(t => t.status === 'completed');\n\n let content = '';\n\n if (inProgress.length > 0) {\n content += `### In Progress\\n\\n`;\n for (const todo of inProgress) {\n content += `- [ ] **${todo.content}** _(${todo.activeForm})_\\n`;\n }\n content += '\\n';\n }\n\n if (pending.length > 0) {\n content += `### Pending\\n\\n`;\n for (const todo of pending) {\n content += `- [ ] ${todo.content}\\n`;\n }\n content += '\\n';\n }\n\n if (completed.length > 0) {\n content += `### Completed\\n\\n`;\n for (const todo of completed) {\n content += `- [x] ${todo.content}\\n`;\n }\n content += '\\n';\n }\n\n if (todos.length === 0) {\n content += `_(No active session tasks)_\\n\\n`;\n }\n\n return content;\n}\n\n/**\n * Extract all sections from TODO.md EXCEPT \"Current Session\"\n * These are user-managed sections that should be preserved.\n */\nfunction extractPreservedSections(content: string): string {\n let preserved = '';\n\n // Match all ## sections that are NOT \"Current Session\"\n const sectionRegex = /\\n(## (?!Current Session)[^\\n]+[\\s\\S]*?)(?=\\n## |\\n---\\n+\\*Last updated|$)/g;\n const matches = content.matchAll(sectionRegex);\n\n for (const match of matches) {\n preserved += match[1];\n }\n\n return preserved;\n}\n\n/**\n * Fix malformed headings: Remove --- prefix from headings (---# \u2192 #)\n * Claude sometimes incorrectly merges horizontal rules with headings.\n */\nfunction fixMalformedHeadings(content: string): string {\n return content.replace(/^---#/gm, '#');\n}\n\n/**\n * Build new TODO.md preserving user sections\n */\nfunction buildTodoContent(todos: TodoItem[], existingContent: string): string {\n const now = new Date().toISOString();\n\n // Get all preserved sections (everything except Current Session)\n const preserved = extractPreservedSections(existingContent);\n\n // Build new content\n let content = `# TODO\n\n## Current Session\n\n${formatSessionTodos(todos)}`;\n\n // Add preserved sections\n if (preserved.trim()) {\n content += preserved;\n }\n\n // Ensure we end with exactly one timestamp\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)*$/, '');\n content += `\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n return content;\n}\n\nasync function main() {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const stdinData = Buffer.concat(chunks).toString('utf-8');\n\n if (!stdinData.trim()) {\n console.error('No input received');\n process.exit(0);\n }\n\n const hookInput: HookInput = JSON.parse(stdinData);\n\n if (hookInput.tool_name !== 'TodoWrite') {\n process.exit(0);\n }\n\n const todos = hookInput.tool_input?.todos;\n\n if (!todos || !Array.isArray(todos)) {\n console.error('No todos in tool input');\n process.exit(0);\n }\n\n const cwd = hookInput.cwd || process.cwd();\n\n // Find TODO.md path\n const todoPath = findTodoPath(cwd);\n\n // Create TODO.md if it doesn't exist\n if (!existsSync(todoPath)) {\n const parentDir = todoPath.replace(/\\/[^/]+$/, '');\n mkdirSync(parentDir, { recursive: true });\n console.error(`Creating TODO.md at ${todoPath}`);\n }\n\n // Read existing content to preserve user sections\n let existingContent = '';\n try {\n existingContent = readFileSync(todoPath, 'utf-8');\n } catch (e) {\n // New file, no content to preserve\n }\n\n // Build and write new content (with heading fix)\n let newContent = buildTodoContent(todos, existingContent);\n newContent = fixMalformedHeadings(newContent);\n writeFileSync(todoPath, newContent);\n\n const stats = {\n inProgress: todos.filter(t => t.status === 'in_progress').length,\n pending: todos.filter(t => t.status === 'pending').length,\n completed: todos.filter(t => t.status === 'completed').length\n };\n console.error(`TODO.md synced: ${stats.inProgress} in progress, ${stats.pending} pending, ${stats.completed} completed`);\n\n // Add completed items to session note (if local Notes/ exists)\n const completedTodos = todos.filter(t => t.status === 'completed');\n\n if (completedTodos.length > 0) {\n const notesInfo = findNotesDir(cwd);\n\n if (notesInfo.isLocal) {\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n let noteContent = '';\n try {\n noteContent = readFileSync(currentNotePath, 'utf-8');\n } catch (e) {\n console.error('Could not read session note:', e);\n }\n\n const newlyCompleted = completedTodos.filter(t => !noteContent.includes(t.content));\n\n if (newlyCompleted.length > 0) {\n const workItems: WorkItem[] = newlyCompleted.map(t => ({\n title: t.content,\n completed: true\n }));\n\n addWorkToSessionNote(currentNotePath, workItems);\n console.error(`Added ${newlyCompleted.length} completed item(s) to session note`);\n }\n }\n }\n }\n\n } catch (error) {\n console.error('sync-todo-to-md error:', error);\n }\n\n process.exit(0);\n}\n\nmain();\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\nimport { homedir } from 'os';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n/**\n * Directories known to be automated health-check / probe sessions.\n * Hooks should exit early for these to avoid registry clutter and wasted work.\n */\nconst PROBE_CWD_PATTERNS = [\n '/CodexBar/ClaudeProbe',\n '/ClaudeProbe',\n];\n\n/**\n * Check if the current working directory belongs to a probe/health-check session.\n * Returns true if hooks should skip this session entirely.\n */\nexport function isProbeSession(cwd?: string): boolean {\n const dir = cwd || process.cwd();\n return PROBE_CWD_PATTERNS.some(pattern => dir.includes(pattern));\n}\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if a messaging MCP server (AIBroker, Whazaa, or Telex) is configured.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * When any messaging server is active, the AI handles notifications via MCP\n * and ntfy is skipped to avoid duplicates.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('aibroker') || enabled.includes('whazaa') || enabled.includes('telex');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n\n/**\n * Update the ## Continue section at the top of TODO.md.\n * This mirrors \"pause session\" behavior \u2014 gives the next session a starting point.\n * Replaces any existing ## Continue section.\n */\nexport function updateTodoContinue(\n cwd: string,\n noteFilename: string,\n state: string | null,\n tokenDisplay: string\n): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove existing ## Continue section (from ## Continue to the first standalone --- line)\n content = content.replace(/## Continue\\n[\\s\\S]*?\\n---\\n+/, '');\n\n const now = new Date().toISOString();\n const stateLines = state\n ? state.split('\\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\\n')\n : `> Working directory: ${cwd}. Check the latest session note for details.`;\n\n const continueSection = `## Continue\n\n> **Last session:** ${noteFilename.replace('.md', '')}\n> **Paused at:** ${now}\n>\n${stateLines}\n\n---\n\n`;\n\n // Remove leading whitespace from content\n content = content.replace(/^\\s+/, '');\n\n // If content starts with # title, insert after it\n const titleMatch = content.match(/^(# [^\\n]+\\n+)/);\n if (titleMatch) {\n content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);\n } else {\n content = continueSection + content;\n }\n\n // Clean up trailing timestamps and add fresh one\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error('TODO.md ## Continue section updated');\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
|
|
5
|
+
"mappings": ";;;AAYA,SAAS,iBAAAA,gBAAe,cAAAC,aAAY,gBAAAC,eAAc,aAAAC,kBAAiB;;;ACFnE,SAAS,cAAAC,aAAY,WAAW,aAAa,gBAAAC,eAAc,eAAe,kBAAkB;AAC5F,SAAS,QAAAC,OAAM,gBAAgB;;;ACE/B,SAAS,eAAe;AACxB,SAAS,SAAS,YAAY;AAC9B,SAAS,YAAY,oBAAoB;AAMzC,SAAS,cAAoB;AAE3B,QAAM,gBAAgB;AAAA,IACpB,QAAQ,QAAQ,IAAI,WAAW,IAAI,MAAM;AAAA,IACzC,QAAQ,QAAQ,GAAG,WAAW,MAAM;AAAA,EACtC;AAEA,aAAW,WAAW,eAAe;AACnC,QAAI,WAAW,OAAO,GAAG;AACvB,UAAI;AACF,cAAM,UAAU,aAAa,SAAS,OAAO;AAC7C,mBAAW,QAAQ,QAAQ,MAAM,IAAI,GAAG;AACtC,gBAAM,UAAU,KAAK,KAAK;AAE1B,cAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,EAAG;AAEzC,gBAAM,UAAU,QAAQ,QAAQ,GAAG;AACnC,cAAI,UAAU,GAAG;AACf,kBAAM,MAAM,QAAQ,UAAU,GAAG,OAAO,EAAE,KAAK;AAC/C,gBAAI,QAAQ,QAAQ,UAAU,UAAU,CAAC,EAAE,KAAK;AAGhD,gBAAK,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,KAC3C,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,GAAI;AAClD,sBAAQ,MAAM,MAAM,GAAG,EAAE;AAAA,YAC3B;AAGA,oBAAQ,MAAM,QAAQ,WAAW,QAAQ,CAAC;AAC1C,oBAAQ,MAAM,QAAQ,cAAc,QAAQ,CAAC;AAG7C,gBAAI,QAAQ,IAAI,GAAG,MAAM,QAAW;AAClC,sBAAQ,IAAI,GAAG,IAAI;AAAA,YACrB;AAAA,UACF;AAAA,QACF;AAEA;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AACF;AAGA,YAAY;AAQL,IAAM,UAAU,QAAQ,IAAI,UAC/B,QAAQ,QAAQ,IAAI,OAAO,IAC3B,QAAQ,QAAQ,GAAG,SAAS;AAKzB,IAAM,YAAY,KAAK,SAAS,OAAO;AACvC,IAAM,aAAa,KAAK,SAAS,QAAQ;AACzC,IAAM,aAAa,KAAK,SAAS,QAAQ;AACzC,IAAM,cAAc,KAAK,SAAS,SAAS;AAC3C,IAAM,eAAe,KAAK,SAAS,UAAU;AAMpD,SAAS,uBAA6B;AACpC,MAAI,CAAC,WAAW,OAAO,GAAG;AACxB,YAAQ,MAAM,2BAA2B,OAAO,EAAE;AAClD,YAAQ,MAAM,2DAA2D;AACzE,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI,CAAC,WAAW,SAAS,GAAG;AAC1B,YAAQ,MAAM,kCAAkC,SAAS,EAAE;AAC3D,YAAQ,MAAM,sCAAsC;AACpD,YAAQ,MAAM,uBAAuB,OAAO,EAAE;AAC9C,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;AAIA,qBAAqB;;;ADzEd,IAAM,eAAeC,MAAK,SAAS,UAAU;AAU7C,SAAS,WAAW,MAAsB;AAC/C,SAAO,KACJ,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,MAAM,GAAG;AACtB;AAKO,SAAS,cAAc,KAAqB;AACjD,QAAM,UAAU,WAAW,GAAG;AAC9B,SAAOA,MAAK,cAAc,OAAO;AACnC;AAKO,SAAS,YAAY,KAAqB;AAC/C,SAAOA,MAAK,cAAc,GAAG,GAAG,OAAO;AACzC;AAWO,SAAS,aAAa,KAAiD;AAE5E,QAAM,cAAc,SAAS,GAAG,EAAE,YAAY;AAC9C,MAAI,gBAAgB,WAAWC,YAAW,GAAG,GAAG;AAC9C,WAAO,EAAE,MAAM,KAAK,SAAS,KAAK;AAAA,EACpC;AAGA,QAAM,aAAa;AAAA,IACjBD,MAAK,KAAK,OAAO;AAAA,IACjBA,MAAK,KAAK,OAAO;AAAA,IACjBA,MAAK,KAAK,WAAW,OAAO;AAAA,EAC9B;AAEA,aAAW,QAAQ,YAAY;AAC7B,QAAIC,YAAW,IAAI,GAAG;AACpB,aAAO,EAAE,MAAM,SAAS,KAAK;AAAA,IAC/B;AAAA,EACF;AAGA,SAAO,EAAE,MAAM,YAAY,GAAG,GAAG,SAAS,MAAM;AAClD;AAsQO,SAAS,mBAAmB,UAAiC;AAClE,MAAI,CAACC,YAAW,QAAQ,GAAG;AACzB,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,CAAC,QAA+B;AACnD,QAAI,CAACA,YAAW,GAAG,EAAG,QAAO;AAC7B,UAAM,QAAQ,YAAY,GAAG,EAC1B,OAAO,OAAK,EAAE,MAAM,uBAAuB,CAAC,EAC5C,KAAK,CAAC,GAAG,MAAM;AACd,YAAM,OAAO,SAAS,EAAE,MAAM,QAAQ,IAAI,CAAC,KAAK,KAAK,EAAE;AACvD,YAAM,OAAO,SAAS,EAAE,MAAM,QAAQ,IAAI,CAAC,KAAK,KAAK,EAAE;AACvD,aAAO,OAAO;AAAA,IAChB,CAAC;AACH,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,WAAOC,MAAK,KAAK,MAAM,MAAM,SAAS,CAAC,CAAC;AAAA,EAC1C;AAGA,QAAM,MAAM,oBAAI,KAAK;AACrB,QAAM,OAAO,OAAO,IAAI,YAAY,CAAC;AACrC,QAAM,QAAQ,OAAO,IAAI,SAAS,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AACxD,QAAM,kBAAkBA,MAAK,UAAU,MAAM,KAAK;AAClD,QAAM,QAAQ,aAAa,eAAe;AAC1C,MAAI,MAAO,QAAO;AAGlB,QAAM,WAAW,IAAI,KAAK,IAAI,YAAY,GAAG,IAAI,SAAS,IAAI,GAAG,CAAC;AAClE,QAAM,WAAW,OAAO,SAAS,YAAY,CAAC;AAC9C,QAAM,YAAY,OAAO,SAAS,SAAS,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AACjE,QAAM,eAAeA,MAAK,UAAU,UAAU,SAAS;AACvD,QAAM,YAAY,aAAa,YAAY;AAC3C,MAAI,UAAW,QAAO;AAGtB,SAAO,aAAa,QAAQ;AAC9B;AA8GO,SAAS,qBAAqB,UAAkB,WAAuB,cAA6B;AACzG,MAAI,CAACC,YAAW,QAAQ,GAAG;AACzB,YAAQ,MAAM,wBAAwB,QAAQ,EAAE;AAChD;AAAA,EACF;AAEA,MAAI,UAAUC,cAAa,UAAU,OAAO;AAG5C,MAAI,WAAW;AACf,MAAI,cAAc;AAChB,gBAAY;AAAA,MAAS,YAAY;AAAA;AAAA;AAAA,EACnC;AAEA,aAAW,QAAQ,WAAW;AAC5B,UAAM,WAAW,KAAK,cAAc,QAAQ,QAAQ;AACpD,gBAAY,KAAK,QAAQ,MAAM,KAAK,KAAK;AAAA;AACzC,QAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,GAAG;AAC3C,iBAAW,UAAU,KAAK,SAAS;AACjC,oBAAY,OAAO,MAAM;AAAA;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AAGA,QAAM,gBAAgB,QAAQ,MAAM,iCAAiC;AACrE,MAAI,eAAe;AACjB,UAAM,cAAc,QAAQ,QAAQ,cAAc,CAAC,CAAC,IAAI,cAAc,CAAC,EAAE;AACzE,cAAU,QAAQ,UAAU,GAAG,WAAW,IAAI,WAAW,QAAQ,UAAU,WAAW;AAAA,EACxF,OAAO;AAEL,UAAM,iBAAiB,QAAQ,QAAQ,eAAe;AACtD,QAAI,mBAAmB,IAAI;AACzB,gBAAU,QAAQ,UAAU,GAAG,cAAc,IAAI,WAAW,OAAO,QAAQ,UAAU,cAAc;AAAA,IACrG;AAAA,EACF;AAEA,gBAAc,UAAU,OAAO;AAC/B,UAAQ,MAAM,SAAS,UAAU,MAAM,qBAAqB,SAAS,QAAQ,CAAC,EAAE;AAClF;AA8OO,SAAS,aAAa,KAAqB;AAEhD,QAAM,aAAa;AAAA,IACjBC,MAAK,KAAK,SAAS;AAAA,IACnBA,MAAK,KAAK,SAAS,SAAS;AAAA,IAC5BA,MAAK,KAAK,SAAS,SAAS;AAAA,IAC5BA,MAAK,KAAK,WAAW,SAAS;AAAA,EAChC;AAEA,aAAW,QAAQ,YAAY;AAC7B,QAAIC,YAAW,IAAI,GAAG;AACpB,aAAO;AAAA,IACT;AAAA,EACF;AAGA,SAAOD,MAAK,YAAY,GAAG,GAAG,SAAS;AACzC;;;AD3vBA,SAAS,mBAAmB,OAA2B;AACrD,QAAM,aAAa,MAAM,OAAO,OAAK,EAAE,WAAW,aAAa;AAC/D,QAAM,UAAU,MAAM,OAAO,OAAK,EAAE,WAAW,SAAS;AACxD,QAAM,YAAY,MAAM,OAAO,OAAK,EAAE,WAAW,WAAW;AAE5D,MAAI,UAAU;AAEd,MAAI,WAAW,SAAS,GAAG;AACzB,eAAW;AAAA;AAAA;AACX,eAAW,QAAQ,YAAY;AAC7B,iBAAW,WAAW,KAAK,OAAO,QAAQ,KAAK,UAAU;AAAA;AAAA,IAC3D;AACA,eAAW;AAAA,EACb;AAEA,MAAI,QAAQ,SAAS,GAAG;AACtB,eAAW;AAAA;AAAA;AACX,eAAW,QAAQ,SAAS;AAC1B,iBAAW,SAAS,KAAK,OAAO;AAAA;AAAA,IAClC;AACA,eAAW;AAAA,EACb;AAEA,MAAI,UAAU,SAAS,GAAG;AACxB,eAAW;AAAA;AAAA;AACX,eAAW,QAAQ,WAAW;AAC5B,iBAAW,SAAS,KAAK,OAAO;AAAA;AAAA,IAClC;AACA,eAAW;AAAA,EACb;AAEA,MAAI,MAAM,WAAW,GAAG;AACtB,eAAW;AAAA;AAAA;AAAA,EACb;AAEA,SAAO;AACT;AAMA,SAAS,yBAAyB,SAAyB;AACzD,MAAI,YAAY;AAGhB,QAAM,eAAe;AACrB,QAAM,UAAU,QAAQ,SAAS,YAAY;AAE7C,aAAW,SAAS,SAAS;AAC3B,iBAAa,MAAM,CAAC;AAAA,EACtB;AAEA,SAAO;AACT;AAMA,SAAS,qBAAqB,SAAyB;AACrD,SAAO,QAAQ,QAAQ,WAAW,GAAG;AACvC;AAKA,SAAS,iBAAiB,OAAmB,iBAAiC;AAC5E,QAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AAGnC,QAAM,YAAY,yBAAyB,eAAe;AAG1D,MAAI,UAAU;AAAA;AAAA;AAAA;AAAA,EAId,mBAAmB,KAAK,CAAC;AAGzB,MAAI,UAAU,KAAK,GAAG;AACpB,eAAW;AAAA,EACb;AAGA,YAAU,QAAQ,QAAQ,2CAA2C,EAAE;AACvE,aAAW;AAAA;AAAA;AAAA,iBAA2B,GAAG;AAAA;AAEzC,SAAO;AACT;AAEA,eAAe,OAAO;AACpB,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,qBAAiB,SAAS,QAAQ,OAAO;AACvC,aAAO,KAAK,KAAK;AAAA,IACnB;AACA,UAAM,YAAY,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AAExD,QAAI,CAAC,UAAU,KAAK,GAAG;AACrB,cAAQ,MAAM,mBAAmB;AACjC,cAAQ,KAAK,CAAC;AAAA,IAChB;AAEA,UAAM,YAAuB,KAAK,MAAM,SAAS;AAEjD,QAAI,UAAU,cAAc,aAAa;AACvC,cAAQ,KAAK,CAAC;AAAA,IAChB;AAEA,UAAM,QAAQ,UAAU,YAAY;AAEpC,QAAI,CAAC,SAAS,CAAC,MAAM,QAAQ,KAAK,GAAG;AACnC,cAAQ,MAAM,wBAAwB;AACtC,cAAQ,KAAK,CAAC;AAAA,IAChB;AAEA,UAAM,MAAM,UAAU,OAAO,QAAQ,IAAI;AAGzC,UAAM,WAAW,aAAa,GAAG;AAGjC,QAAI,CAACE,YAAW,QAAQ,GAAG;AACzB,YAAM,YAAY,SAAS,QAAQ,YAAY,EAAE;AACjD,MAAAC,WAAU,WAAW,EAAE,WAAW,KAAK,CAAC;AACxC,cAAQ,MAAM,uBAAuB,QAAQ,EAAE;AAAA,IACjD;AAGA,QAAI,kBAAkB;AACtB,QAAI;AACF,wBAAkBC,cAAa,UAAU,OAAO;AAAA,IAClD,SAAS,GAAG;AAAA,IAEZ;AAGA,QAAI,aAAa,iBAAiB,OAAO,eAAe;AACxD,iBAAa,qBAAqB,UAAU;AAC5C,IAAAC,eAAc,UAAU,UAAU;AAElC,UAAM,QAAQ;AAAA,MACZ,YAAY,MAAM,OAAO,OAAK,EAAE,WAAW,aAAa,EAAE;AAAA,MAC1D,SAAS,MAAM,OAAO,OAAK,EAAE,WAAW,SAAS,EAAE;AAAA,MACnD,WAAW,MAAM,OAAO,OAAK,EAAE,WAAW,WAAW,EAAE;AAAA,IACzD;AACA,YAAQ,MAAM,mBAAmB,MAAM,UAAU,iBAAiB,MAAM,OAAO,aAAa,MAAM,SAAS,YAAY;AAGvH,UAAM,iBAAiB,MAAM,OAAO,OAAK,EAAE,WAAW,WAAW;AAEjE,QAAI,eAAe,SAAS,GAAG;AAC7B,YAAM,YAAY,aAAa,GAAG;AAElC,UAAI,UAAU,SAAS;AACrB,cAAM,kBAAkB,mBAAmB,UAAU,IAAI;AAEzD,YAAI,iBAAiB;AACnB,cAAI,cAAc;AAClB,cAAI;AACF,0BAAcD,cAAa,iBAAiB,OAAO;AAAA,UACrD,SAAS,GAAG;AACV,oBAAQ,MAAM,gCAAgC,CAAC;AAAA,UACjD;AAEA,gBAAM,iBAAiB,eAAe,OAAO,OAAK,CAAC,YAAY,SAAS,EAAE,OAAO,CAAC;AAElF,cAAI,eAAe,SAAS,GAAG;AAC7B,kBAAM,YAAwB,eAAe,IAAI,QAAM;AAAA,cACrD,OAAO,EAAE;AAAA,cACT,WAAW;AAAA,YACb,EAAE;AAEF,iCAAqB,iBAAiB,SAAS;AAC/C,oBAAQ,MAAM,SAAS,eAAe,MAAM,oCAAoC;AAAA,UAClF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EAEF,SAAS,OAAO;AACd,YAAQ,MAAM,0BAA0B,KAAK;AAAA,EAC/C;AAEA,UAAQ,KAAK,CAAC;AAChB;AAEA,KAAK;",
|
|
6
6
|
"names": ["writeFileSync", "existsSync", "readFileSync", "mkdirSync", "existsSync", "readFileSync", "join", "join", "existsSync", "existsSync", "join", "existsSync", "readFileSync", "join", "existsSync", "existsSync", "mkdirSync", "readFileSync", "writeFileSync"]
|
|
7
7
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Database, Database as Database$1, Database as Database$2 } from "better-sqlite3";
|
|
2
2
|
|
|
3
3
|
//#region src/registry/schema.d.ts
|
|
4
|
-
declare const SCHEMA_VERSION =
|
|
5
|
-
declare const CREATE_TABLES_SQL = "\nPRAGMA journal_mode = WAL;\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS projects (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT NOT NULL UNIQUE,\n display_name TEXT NOT NULL,\n root_path TEXT NOT NULL UNIQUE,\n encoded_dir TEXT NOT NULL UNIQUE,\n type TEXT NOT NULL DEFAULT 'local'\n CHECK(type IN ('local','central','obsidian-linked','external')),\n status TEXT NOT NULL DEFAULT 'active'\n CHECK(status IN ('active','archived','migrating')),\n parent_id INTEGER,\n obsidian_link TEXT,\n claude_notes_dir TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n archived_at INTEGER,\n FOREIGN KEY (parent_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS sessions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n number INTEGER NOT NULL,\n date TEXT NOT NULL,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n filename TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'open'\n CHECK(status IN ('open','completed','compacted')),\n claude_session_id TEXT,\n token_count INTEGER,\n created_at INTEGER NOT NULL,\n closed_at INTEGER,\n UNIQUE (project_id, number),\n FOREIGN KEY (project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS tags (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL UNIQUE\n);\n\nCREATE TABLE IF NOT EXISTS project_tags (\n project_id INTEGER NOT NULL,\n tag_id INTEGER NOT NULL,\n PRIMARY KEY (project_id, tag_id),\n FOREIGN KEY (project_id) REFERENCES projects(id),\n FOREIGN KEY (tag_id) REFERENCES tags(id)\n);\n\nCREATE TABLE IF NOT EXISTS session_tags (\n session_id INTEGER NOT NULL,\n tag_id INTEGER NOT NULL,\n PRIMARY KEY (session_id, tag_id),\n FOREIGN KEY (session_id) REFERENCES sessions(id),\n FOREIGN KEY (tag_id) REFERENCES tags(id)\n);\n\nCREATE TABLE IF NOT EXISTS aliases (\n alias TEXT PRIMARY KEY,\n project_id INTEGER NOT NULL,\n FOREIGN KEY (project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS compaction_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n session_id INTEGER,\n trigger TEXT NOT NULL\n CHECK(trigger IN ('precompact','manual','end-session')),\n files_written TEXT NOT NULL,\n token_count INTEGER,\n created_at INTEGER NOT NULL,\n FOREIGN KEY (project_id) REFERENCES projects(id),\n FOREIGN KEY (session_id) REFERENCES sessions(id)\n);\n\nCREATE TABLE IF NOT EXISTS links (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id INTEGER NOT NULL,\n target_project_id INTEGER NOT NULL,\n link_type TEXT NOT NULL DEFAULT 'related'\n CHECK(link_type IN ('related','follow-up','reference')),\n created_at INTEGER NOT NULL,\n UNIQUE (session_id, target_project_id),\n FOREIGN KEY (session_id) REFERENCES sessions(id),\n FOREIGN KEY (target_project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n\n-- Indexes\nCREATE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);\nCREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);\nCREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type);\nCREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_date ON sessions(date);\nCREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);\nCREATE INDEX IF NOT EXISTS idx_sessions_claude ON sessions(claude_session_id);\nCREATE INDEX IF NOT EXISTS idx_pc_project ON project_tags(project_id);\n";
|
|
4
|
+
declare const SCHEMA_VERSION = 4;
|
|
5
|
+
declare const CREATE_TABLES_SQL = "\nPRAGMA journal_mode = WAL;\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS projects (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT NOT NULL UNIQUE,\n display_name TEXT NOT NULL,\n root_path TEXT NOT NULL UNIQUE,\n encoded_dir TEXT NOT NULL UNIQUE,\n type TEXT NOT NULL DEFAULT 'local'\n CHECK(type IN ('local','central','obsidian-linked','external')),\n status TEXT NOT NULL DEFAULT 'active'\n CHECK(status IN ('active','archived','migrating')),\n parent_id INTEGER,\n obsidian_link TEXT,\n claude_notes_dir TEXT,\n session_config TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n archived_at INTEGER,\n FOREIGN KEY (parent_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS sessions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n number INTEGER NOT NULL,\n date TEXT NOT NULL,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n filename TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'open'\n CHECK(status IN ('open','completed','compacted')),\n claude_session_id TEXT,\n token_count INTEGER,\n created_at INTEGER NOT NULL,\n closed_at INTEGER,\n UNIQUE (project_id, number),\n FOREIGN KEY (project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS tags (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL UNIQUE\n);\n\nCREATE TABLE IF NOT EXISTS project_tags (\n project_id INTEGER NOT NULL,\n tag_id INTEGER NOT NULL,\n PRIMARY KEY (project_id, tag_id),\n FOREIGN KEY (project_id) REFERENCES projects(id),\n FOREIGN KEY (tag_id) REFERENCES tags(id)\n);\n\nCREATE TABLE IF NOT EXISTS session_tags (\n session_id INTEGER NOT NULL,\n tag_id INTEGER NOT NULL,\n PRIMARY KEY (session_id, tag_id),\n FOREIGN KEY (session_id) REFERENCES sessions(id),\n FOREIGN KEY (tag_id) REFERENCES tags(id)\n);\n\nCREATE TABLE IF NOT EXISTS aliases (\n alias TEXT PRIMARY KEY,\n project_id INTEGER NOT NULL,\n FOREIGN KEY (project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS compaction_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n session_id INTEGER,\n trigger TEXT NOT NULL\n CHECK(trigger IN ('precompact','manual','end-session')),\n files_written TEXT NOT NULL,\n token_count INTEGER,\n created_at INTEGER NOT NULL,\n FOREIGN KEY (project_id) REFERENCES projects(id),\n FOREIGN KEY (session_id) REFERENCES sessions(id)\n);\n\nCREATE TABLE IF NOT EXISTS links (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id INTEGER NOT NULL,\n target_project_id INTEGER NOT NULL,\n link_type TEXT NOT NULL DEFAULT 'related'\n CHECK(link_type IN ('related','follow-up','reference')),\n created_at INTEGER NOT NULL,\n UNIQUE (session_id, target_project_id),\n FOREIGN KEY (session_id) REFERENCES sessions(id),\n FOREIGN KEY (target_project_id) REFERENCES projects(id)\n);\n\nCREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n\n-- Indexes\nCREATE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);\nCREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);\nCREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type);\nCREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_date ON sessions(date);\nCREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);\nCREATE INDEX IF NOT EXISTS idx_sessions_claude ON sessions(claude_session_id);\nCREATE INDEX IF NOT EXISTS idx_pc_project ON project_tags(project_id);\n";
|
|
6
6
|
/**
|
|
7
7
|
* Run the full DDL against an open database connection.
|
|
8
8
|
*
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/registry/schema.ts","../src/registry/db.ts","../src/registry/migrate.ts","../src/registry/pai-marker.ts","../src/memory/schema.ts","../src/memory/db.ts","../src/memory/chunker.ts","../src/memory/indexer.ts","../src/memory/search.ts","../src/memory/reranker.ts"],"mappings":";;;cAgBa,cAAA;AAAA,cAEA,iBAAA;;;;;;ACYb;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/registry/schema.ts","../src/registry/db.ts","../src/registry/migrate.ts","../src/registry/pai-marker.ts","../src/memory/schema.ts","../src/memory/db.ts","../src/memory/chunker.ts","../src/memory/indexer.ts","../src/memory/search.ts","../src/memory/reranker.ts"],"mappings":";;;cAgBa,cAAA;AAAA,cAEA,iBAAA;;;;;;ACYb;;;iBD0GgB,gBAAA,CAAiB,EAAA,EAAI,QAAA;;;AAtHrC;;;;;AAsHA;;;;;;AAtHA,iBCYgB,YAAA,CAAa,IAAA,YAAuC,UAAA;;;;;;;ACkIpE;;;;;AAUC;;;;;;;;;;;AAyBD;;;;;AAmCA;iBA5GgB,gBAAA,CACd,OAAA,UACA,SAAA,GAAY,GAAA;;;;;;iBAoCE,OAAA,CAAQ,KAAA;AAAA,UAgBd,aAAA;EACR,MAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;EACA,QAAA;AAAA;;;;;;iBAcc,oBAAA,CACd,QAAA,WACC,aAAA;AAAA,UAiCc,eAAA;EACf,gBAAA;EACA,eAAA;EACA,gBAAA;EACA,MAAA;AAAA;;;;;;AC1DF;;;;;iBDuEgB,eAAA,CACd,EAAA,EAAI,QAAA,EACJ,YAAA,YACC,eAAA;;;;;;AF1OH;;;;;AAEA;UGMiB,SAAA;;EAEf,IAAA;EHR4B;EGU5B,IAAA;EH4G8B;EG1G9B,WAAA;AAAA;;;;;AFAF;;;;;;;iBEkJgB,eAAA,CACd,WAAA,UACA,IAAA,UACA,WAAA;ADzDF;;;;AAAA,iBC0HgB,aAAA,CACd,WAAA;EACG,IAAA;EAAc,UAAA;EAAoB,MAAA;AAAA;ADtFvC;;;;;AAUC;;;;;AAVD,iBCmHgB,kBAAA,CAAmB,UAAA,aAAuB,SAAA;;;cCtP7C,qBAAA;;;AF6Fb;;;;;;;iBEegB,0BAAA,CAA2B,EAAA,EAAI,QAAA;;;AJvH/C;;;;;AAsHA;;;;;;AAtHA,iBKYgB,cAAA,CAAe,IAAA,YAAyC,UAAA;;;;;;ALdxE;;;;UMNiB,KAAA;EACf,IAAA;EACA,SAAA;EACA,OAAA;EACA,IAAA;AAAA;AAAA,UAGe,YAAA;ENuHe;EMrH9B,SAAA;ENqHmC;EMnHnC,OAAA;AAAA;;;ALSF;;iBKCgB,cAAA,CAAe,IAAA;;;;;;AJ2F/B;;;;;iBImFgB,aAAA,CAAc,OAAA,UAAiB,IAAA,GAAO,YAAA,GAAe,KAAA;;;UCrLpD,WAAA;EACf,cAAA;EACA,aAAA;EACA,YAAA;AAAA;;;;ANGF;;;;;;iBMagB,UAAA,CACd,YAAA;;AL8EF;;;;iBKXgB,SAAA,CACd,EAAA,EAAI,QAAA,EACJ,SAAA,UACA,QAAA,UACA,YAAA,UACA,MAAA,UACA,IAAA;AAAA,iBA6UoB,YAAA,CACpB,EAAA,EAAI,QAAA,EACJ,SAAA,UACA,QAAA,UACA,cAAA,mBACC,OAAA,CAAQ,WAAA;;;;ALvSX;;;iBKihBsB,QAAA,CACpB,EAAA,EAAI,QAAA,EACJ,UAAA,EAAY,QAAA,GACX,OAAA;EAAU,QAAA;EAAkB,MAAA,EAAQ,WAAA;AAAA;;;UCjqBtB,YAAA;EACf,SAAA;EACA,WAAA;EACA,IAAA;EACA,SAAA;EACA,OAAA;EACA,OAAA;EACA,KAAA;EACA,IAAA;EACA,MAAA;EACA,SAAA;AAAA;AAAA,UAGe,aAAA;EPF2D;EOI1E,UAAA;;EAEA,OAAA;ENsFc;EMpFd,KAAA;;EAEA,UAAA;ENmFA;EMjFA,QAAA;AAAA;;;ANsHF;;;;;AAUC;;;;;;;;;;;iBM1Fe,aAAA,CAAc,KAAA;;;;;ANsJ9B;;;;;;;;iBMpHgB,YAAA,CACd,EAAA,EAAI,QAAA,EACJ,KAAA,UACA,IAAA,GAAO,aAAA,GACN,YAAA;;;;;iBAwRa,aAAA,CACd,OAAA,EAAS,YAAA,IACT,UAAA,EAAY,QAAA,GACX,YAAA;;;;;;;iBC3Wa,sBAAA,CAAuB,KAAA;AAAA,UAoCtB,aAAA;;EAEf,IAAA;ER9C0E;;;;AC4F5E;EOxCE,aAAA;AAAA;;;;;;;AP8EF;;;iBOlEsB,aAAA,CACpB,KAAA,UACA,OAAA,EAAS,YAAA,IACT,IAAA,GAAO,aAAA,GACN,OAAA,CAAQ,YAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as initializeSchema, i as SCHEMA_VERSION, n as openRegistry, r as CREATE_TABLES_SQL } from "./db-
|
|
1
|
+
import { a as initializeSchema, i as SCHEMA_VERSION, n as openRegistry, r as CREATE_TABLES_SQL } from "./db-BtuN768f.mjs";
|
|
2
2
|
import "./utils-QSfKagcj.mjs";
|
|
3
3
|
import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, r as migrateFromJson } from "./migrate-jokLenje.mjs";
|
|
4
4
|
import { n as ensurePaiMarker, r as readPaiMarker, t as discoverPaiMarkers } from "./pai-marker-CXQPX2P6.mjs";
|
package/dist/mcp/index.mjs
CHANGED
|
@@ -169,7 +169,7 @@ function deduplicateByInode(files) {
|
|
|
169
169
|
return result;
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
|
-
* Parse all
|
|
172
|
+
* Parse all links from markdown content.
|
|
173
173
|
*
|
|
174
174
|
* Handles:
|
|
175
175
|
* - Standard wikilinks: [[Target Note]]
|
|
@@ -177,11 +177,16 @@ function deduplicateByInode(files) {
|
|
|
177
177
|
* - Heading anchors: [[Target Note#Heading]] (stripped for resolution)
|
|
178
178
|
* - Embeds: ![[Target Note]]
|
|
179
179
|
* - Frontmatter wikilinks (YAML between --- delimiters)
|
|
180
|
+
* - Markdown links: [text](path/to/note.md)
|
|
181
|
+
* - Markdown embeds: 
|
|
182
|
+
*
|
|
183
|
+
* External URLs (http://, https://, mailto:, etc.) are excluded — only
|
|
184
|
+
* relative paths are treated as vault links.
|
|
180
185
|
*
|
|
181
186
|
* @param content Raw markdown file content.
|
|
182
187
|
* @returns Array of parsed links in document order.
|
|
183
188
|
*/
|
|
184
|
-
function
|
|
189
|
+
function parseLinks(content) {
|
|
185
190
|
const links = [];
|
|
186
191
|
const lines = content.split("\n");
|
|
187
192
|
let frontmatterEnd = 0;
|
|
@@ -190,9 +195,11 @@ function parseWikilinks(content) {
|
|
|
190
195
|
if (closingIdx !== -1) frontmatterEnd = content.slice(0, closingIdx + 4).split("\n").length - 1;
|
|
191
196
|
}
|
|
192
197
|
const wikilinkRe = /(!?)\[\[([^\]]+?)\]\]/g;
|
|
198
|
+
const mdLinkRe = /(!)?\[([^\]]*)\]\(([^)]+)\)/g;
|
|
193
199
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
194
200
|
const line = lines[lineIdx];
|
|
195
201
|
const lineNumber = lineIdx + 1;
|
|
202
|
+
const isFrontmatter = lineIdx < frontmatterEnd;
|
|
196
203
|
wikilinkRe.lastIndex = 0;
|
|
197
204
|
let match;
|
|
198
205
|
while ((match = wikilinkRe.exec(line)) !== null) {
|
|
@@ -204,14 +211,38 @@ function parseWikilinks(content) {
|
|
|
204
211
|
const hashIdx = beforePipe.indexOf("#");
|
|
205
212
|
const raw = hashIdx === -1 ? beforePipe.trim() : beforePipe.slice(0, hashIdx).trim();
|
|
206
213
|
if (!raw) continue;
|
|
207
|
-
const isFrontmatter = lineIdx < frontmatterEnd;
|
|
208
214
|
links.push({
|
|
209
215
|
raw,
|
|
210
216
|
alias: alias?.trim() ?? null,
|
|
211
217
|
lineNumber,
|
|
212
|
-
isEmbed: isEmbed && !isFrontmatter
|
|
218
|
+
isEmbed: isEmbed && !isFrontmatter,
|
|
219
|
+
isMdLink: false
|
|
213
220
|
});
|
|
214
221
|
}
|
|
222
|
+
if (!isFrontmatter) {
|
|
223
|
+
mdLinkRe.lastIndex = 0;
|
|
224
|
+
while ((match = mdLinkRe.exec(line)) !== null) {
|
|
225
|
+
const isEmbed = match[1] === "!";
|
|
226
|
+
const displayText = match[2];
|
|
227
|
+
let target = match[3].trim();
|
|
228
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue;
|
|
229
|
+
if (target.startsWith("#")) continue;
|
|
230
|
+
const hashIdx = target.indexOf("#");
|
|
231
|
+
if (hashIdx !== -1) target = target.slice(0, hashIdx);
|
|
232
|
+
try {
|
|
233
|
+
target = decodeURIComponent(target);
|
|
234
|
+
} catch {}
|
|
235
|
+
const raw = target.replace(/\.md$/i, "").trim();
|
|
236
|
+
if (!raw) continue;
|
|
237
|
+
links.push({
|
|
238
|
+
raw,
|
|
239
|
+
alias: displayText || null,
|
|
240
|
+
lineNumber,
|
|
241
|
+
isEmbed,
|
|
242
|
+
isMdLink: true
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
215
246
|
}
|
|
216
247
|
return links;
|
|
217
248
|
}
|
|
@@ -439,14 +470,17 @@ async function indexVault(db, vaultProjectId, vaultRoot) {
|
|
|
439
470
|
} catch {
|
|
440
471
|
continue;
|
|
441
472
|
}
|
|
442
|
-
const parsedLinks =
|
|
473
|
+
const parsedLinks = parseLinks(content);
|
|
443
474
|
for (const link of parsedLinks) {
|
|
444
475
|
const target = resolveWikilink(link.raw, nameIndex, canonical.vaultRelPath);
|
|
476
|
+
let linkType;
|
|
477
|
+
if (link.isMdLink) linkType = link.isEmbed ? "md-embed" : "md-link";
|
|
478
|
+
else linkType = link.isEmbed ? "embed" : "wikilink";
|
|
445
479
|
linkRows.push({
|
|
446
480
|
source: canonical.vaultRelPath,
|
|
447
481
|
raw: link.raw,
|
|
448
482
|
target,
|
|
449
|
-
linkType
|
|
483
|
+
linkType,
|
|
450
484
|
lineNumber: link.lineNumber
|
|
451
485
|
});
|
|
452
486
|
}
|
|
@@ -496,4 +530,4 @@ async function indexVault(db, vaultProjectId, vaultRoot) {
|
|
|
496
530
|
|
|
497
531
|
//#endregion
|
|
498
532
|
export { indexVault };
|
|
499
|
-
//# sourceMappingURL=vault-indexer-
|
|
533
|
+
//# sourceMappingURL=vault-indexer-k-kUlaZ-.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault-indexer-k-kUlaZ-.mjs","names":[],"sources":["../src/memory/vault-indexer.ts"],"sourcesContent":["/**\n * Vault indexer for the PAI federation memory engine.\n *\n * Indexes an entire Obsidian vault (or any markdown knowledge base), following\n * symlinks, deduplicating files by inode, parsing wikilinks, and computing\n * per-file health metrics (orphan detection, dead links).\n *\n * Key differences from the project indexer (indexer.ts):\n * - Follows symbolic links (project indexer skips them)\n * - Deduplicates files with the same inode (same content reachable via multiple paths)\n * - Parses [[wikilinks]] and builds a directed link graph\n * - Resolves wikilinks using Obsidian's shortest-match algorithm\n * - Computes health metrics per file: inbound/outbound link counts, dead links, orphans\n */\n\nimport { createHash } from \"node:crypto\";\nimport { readFileSync, statSync, readdirSync, existsSync } from \"node:fs\";\nimport { join, relative, basename, dirname, normalize } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport { chunkMarkdown } from \"./chunker.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface VaultFile {\n absPath: string;\n vaultRelPath: string;\n inode: number;\n device: number;\n}\n\nexport interface InodeGroup {\n canonical: VaultFile;\n aliases: VaultFile[];\n}\n\nexport interface ParsedLink {\n raw: string;\n alias: string | null;\n lineNumber: number;\n isEmbed: boolean;\n /** True when parsed from markdown `[text](path)` syntax (vs `[[wikilink]]`). */\n isMdLink?: boolean;\n}\n\nexport interface VaultIndexResult {\n filesIndexed: number;\n chunksCreated: number;\n filesSkipped: number;\n aliasesRecorded: number;\n linksExtracted: number;\n deadLinksFound: number;\n orphansFound: number;\n elapsed: number;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Maximum number of .md files to collect from a vault. */\nconst VAULT_MAX_FILES = 10_000;\n\n/** Maximum recursion depth for vault directory walks. */\nconst VAULT_MAX_DEPTH = 10;\n\n/** Number of files to process before yielding to the event loop. */\nconst VAULT_YIELD_EVERY = 10;\n\n/**\n * Directories to always skip, at any depth, during vault walks.\n * Includes standard build/VCS noise plus Obsidian-specific directories.\n */\nconst VAULT_SKIP_DIRS = new Set([\n // Version control\n \".git\",\n // Dependency directories (any language)\n \"node_modules\",\n \"vendor\",\n \"Pods\",\n // Build / compile output\n \"dist\",\n \"build\",\n \"out\",\n \"DerivedData\",\n \".next\",\n // Python virtual environments and caches\n \".venv\",\n \"venv\",\n \"__pycache__\",\n // General caches\n \".cache\",\n \".bun\",\n // Obsidian internals\n \".obsidian\",\n \".trash\",\n]);\n\n// ---------------------------------------------------------------------------\n// Utility\n// ---------------------------------------------------------------------------\n\nfunction sha256File(content: string): string {\n return createHash(\"sha256\").update(content).digest(\"hex\");\n}\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\nfunction yieldToEventLoop(): Promise<void> {\n return new Promise((resolve) => setImmediate(resolve));\n}\n\n// ---------------------------------------------------------------------------\n// Vault directory walker (follows symlinks)\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively collect all .md files under a vault root, following symlinks.\n *\n * Symlink-following behaviour:\n * - Symbolic links to files: followed if the target is a .md file\n * - Symbolic links to directories: followed with cycle detection via inode\n *\n * Cycle detection is based on the real inode of each visited directory.\n * Using the real stat (not lstat) ensures that symlinked dirs resolve to\n * their actual inode, preventing infinite loops.\n *\n * @param dir Directory to scan.\n * @param vaultRoot Absolute root of the vault (for computing vaultRelPath).\n * @param acc Shared accumulator (mutated in place for early exit).\n * @param visited Set of \"device:inode\" strings for visited directories.\n * @param depth Current recursion depth.\n */\nexport function walkVaultMdFiles(\n vaultRoot: string,\n opts?: { maxFiles?: number; maxDepth?: number },\n): VaultFile[] {\n const maxFiles = opts?.maxFiles ?? VAULT_MAX_FILES;\n const maxDepth = opts?.maxDepth ?? VAULT_MAX_DEPTH;\n\n const results: VaultFile[] = [];\n const visitedDirs = new Set<string>();\n\n function walk(dir: string, depth: number): void {\n if (results.length >= maxFiles) return;\n if (depth > maxDepth) return;\n\n // Get the real inode of this directory (follows symlinks on the dir itself)\n let dirStat: ReturnType<typeof statSync>;\n try {\n dirStat = statSync(dir);\n } catch {\n return; // Unreadable or broken symlink — skip\n }\n\n const dirKey = `${dirStat.dev}:${dirStat.ino}`;\n if (visitedDirs.has(dirKey)) return; // Cycle detected\n visitedDirs.add(dirKey);\n\n let entries: import(\"node:fs\").Dirent<string>[];\n try {\n entries = readdirSync(dir, { withFileTypes: true, encoding: \"utf8\" });\n } catch {\n return; // Unreadable directory — skip\n }\n\n for (const entry of entries) {\n if (results.length >= maxFiles) break;\n if (VAULT_SKIP_DIRS.has(entry.name)) continue;\n\n const full = join(dir, entry.name);\n\n if (entry.isSymbolicLink()) {\n // Follow the symlink — resolve to real target\n let targetStat: ReturnType<typeof statSync>;\n try {\n targetStat = statSync(full); // statSync follows symlinks\n } catch {\n continue; // Broken symlink — skip\n }\n\n if (targetStat.isDirectory()) {\n if (!VAULT_SKIP_DIRS.has(entry.name)) {\n walk(full, depth + 1);\n }\n } else if (targetStat.isFile() && entry.name.endsWith(\".md\")) {\n results.push({\n absPath: full,\n vaultRelPath: relative(vaultRoot, full),\n inode: targetStat.ino,\n device: targetStat.dev,\n });\n }\n } else if (entry.isDirectory()) {\n walk(full, depth + 1);\n } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n let fileStat: ReturnType<typeof statSync>;\n try {\n fileStat = statSync(full);\n } catch {\n continue;\n }\n results.push({\n absPath: full,\n vaultRelPath: relative(vaultRoot, full),\n inode: fileStat.ino,\n device: fileStat.dev,\n });\n }\n }\n }\n\n if (existsSync(vaultRoot)) {\n walk(vaultRoot, 0);\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Inode deduplication\n// ---------------------------------------------------------------------------\n\n/**\n * Group vault files by inode identity (device + inode).\n *\n * Within each group, the canonical file is chosen as the one with the\n * fewest path separators (shallowest), breaking ties by shortest string.\n * All other group members become aliases.\n */\nexport function deduplicateByInode(files: VaultFile[]): InodeGroup[] {\n const groups = new Map<string, VaultFile[]>();\n\n for (const file of files) {\n const key = `${file.device}:${file.inode}`;\n const existing = groups.get(key);\n if (existing) {\n existing.push(file);\n } else {\n groups.set(key, [file]);\n }\n }\n\n const result: InodeGroup[] = [];\n\n for (const group of groups.values()) {\n if (group.length === 0) continue;\n\n // Sort: fewest path separators first, then shortest string\n const sorted = [...group].sort((a, b) => {\n const aDepth = (a.vaultRelPath.match(/\\//g) ?? []).length;\n const bDepth = (b.vaultRelPath.match(/\\//g) ?? []).length;\n if (aDepth !== bDepth) return aDepth - bDepth;\n return a.vaultRelPath.length - b.vaultRelPath.length;\n });\n\n const [canonical, ...aliases] = sorted as [VaultFile, ...VaultFile[]];\n result.push({ canonical, aliases });\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Link parser (wikilinks + markdown links)\n// ---------------------------------------------------------------------------\n\n/**\n * Parse all links from markdown content.\n *\n * Handles:\n * - Standard wikilinks: [[Target Note]]\n * - Aliased wikilinks: [[Target Note|Display Text]]\n * - Heading anchors: [[Target Note#Heading]] (stripped for resolution)\n * - Embeds: ![[Target Note]]\n * - Frontmatter wikilinks (YAML between --- delimiters)\n * - Markdown links: [text](path/to/note.md)\n * - Markdown embeds: \n *\n * External URLs (http://, https://, mailto:, etc.) are excluded — only\n * relative paths are treated as vault links.\n *\n * @param content Raw markdown file content.\n * @returns Array of parsed links in document order.\n */\nexport function parseLinks(content: string): ParsedLink[] {\n const links: ParsedLink[] = [];\n const lines = content.split(\"\\n\");\n\n // Determine frontmatter range (YAML between opening and closing ---)\n let frontmatterEnd = 0;\n if (content.startsWith(\"---\")) {\n const closingIdx = content.indexOf(\"\\n---\", 3);\n if (closingIdx !== -1) {\n frontmatterEnd = content.slice(0, closingIdx + 4).split(\"\\n\").length - 1;\n }\n }\n\n // Regex for [[wikilinks]] and ![[embeds]]\n const wikilinkRe = /(!?)\\[\\[([^\\]]+?)\\]\\]/g;\n\n // Regex for markdown links [text](target) and embeds \n // Negative lookbehind avoids matching wikilinks already captured above.\n // The target group excludes closing paren and whitespace-after-URL.\n const mdLinkRe = /(!)?\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n\n for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n const line = lines[lineIdx]!;\n const lineNumber = lineIdx + 1; // 1-indexed\n const isFrontmatter = lineIdx < frontmatterEnd;\n\n // --- Wikilinks ---\n wikilinkRe.lastIndex = 0;\n let match: RegExpExecArray | null;\n while ((match = wikilinkRe.exec(line)) !== null) {\n const isEmbed = match[1] === \"!\";\n const inner = match[2]!;\n\n // Split on first | for alias\n const pipeIdx = inner.indexOf(\"|\");\n const beforePipe = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);\n const alias = pipeIdx === -1 ? null : inner.slice(pipeIdx + 1);\n\n // Strip heading anchor (everything after #)\n const hashIdx = beforePipe.indexOf(\"#\");\n const raw = hashIdx === -1 ? beforePipe.trim() : beforePipe.slice(0, hashIdx).trim();\n\n if (!raw) continue; // Skip links with empty targets (e.g. [[#Heading]])\n\n links.push({\n raw,\n alias: alias?.trim() ?? null,\n lineNumber,\n isEmbed: isEmbed && !isFrontmatter,\n isMdLink: false,\n });\n }\n\n // --- Markdown links --- (skip inside frontmatter)\n if (!isFrontmatter) {\n mdLinkRe.lastIndex = 0;\n while ((match = mdLinkRe.exec(line)) !== null) {\n const isEmbed = match[1] === \"!\";\n const displayText = match[2]!;\n let target = match[3]!.trim();\n\n // Skip external URLs\n if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue;\n\n // Skip pure anchor links (#heading)\n if (target.startsWith(\"#\")) continue;\n\n // Strip heading anchor from target\n const hashIdx = target.indexOf(\"#\");\n if (hashIdx !== -1) target = target.slice(0, hashIdx);\n\n // URL-decode (Obsidian encodes spaces as %20 in md links)\n try {\n target = decodeURIComponent(target);\n } catch {\n // Malformed encoding — use as-is\n }\n\n // Strip .md extension for resolution (resolveWikilink adds it back)\n const raw = target.replace(/\\.md$/i, \"\").trim();\n if (!raw) continue;\n\n // Skip if this exact position was already captured as a wikilink\n // (e.g. [[link]] inside a markdown link won't happen, but be safe)\n links.push({\n raw,\n alias: displayText || null,\n lineNumber,\n isEmbed,\n isMdLink: true,\n });\n }\n }\n }\n\n return links;\n}\n\n/** @deprecated Use {@link parseLinks} instead. */\nexport const parseWikilinks = parseLinks;\n\n// ---------------------------------------------------------------------------\n// Name index builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build a name lookup index for Obsidian wikilink resolution.\n *\n * Maps lowercase filename (without .md extension) to all vault-relative paths\n * that share that name. Includes both canonical paths and alias paths so that\n * wikilinks resolve regardless of which path the file is accessed through.\n */\nexport function buildNameIndex(files: VaultFile[]): Map<string, string[]> {\n const index = new Map<string, string[]>();\n\n for (const file of files) {\n const name = basename(file.vaultRelPath, \".md\").toLowerCase();\n const existing = index.get(name);\n if (existing) {\n existing.push(file.vaultRelPath);\n } else {\n index.set(name, [file.vaultRelPath]);\n }\n }\n\n return index;\n}\n\n// ---------------------------------------------------------------------------\n// Wikilink resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a wikilink target to a vault-relative path using Obsidian's rules.\n *\n * Resolution algorithm:\n * 1. If raw contains \"/\", attempt exact path match (with and without .md).\n * 2. Normalize: lowercase the raw target, strip .md extension.\n * 3. Look up in the name index (all files with that basename).\n * 4. If exactly one match, return it.\n * 5. If multiple matches, pick the one closest to the source file\n * (longest common directory prefix, then shortest overall path).\n * 6. If no matches, return null (dead link).\n *\n * @param raw The raw link target (heading-stripped, pipe-stripped).\n * @param nameIndex Map from lowercase basename-without-ext to vault paths.\n * @param sourcePath Vault-relative path of the file containing the link.\n * @returns Vault-relative path of the resolved target, or null.\n */\nexport function resolveWikilink(\n raw: string,\n nameIndex: Map<string, string[]>,\n sourcePath: string,\n): string | null {\n if (!raw) return null;\n\n // Case 1: path contains \"/\" — try exact match with and without .md\n if (raw.includes(\"/\")) {\n const normalized = normalize(raw);\n const normalizedMd = normalized.endsWith(\".md\") ? normalized : normalized + \".md\";\n\n // Check if any indexed path matches (case-insensitive for macOS compatibility)\n for (const [, paths] of nameIndex) {\n for (const p of paths) {\n if (p === normalizedMd || p === normalized) return p;\n if (p.toLowerCase() === normalizedMd.toLowerCase()) return p;\n }\n }\n // Fall through to name lookup in case the path prefix was wrong\n }\n\n // Normalize the raw target for name lookup.\n // Use the basename only — Obsidian resolves by filename, not full path.\n // E.g. \"PAI/20-webseiten/_20-webseiten-master\" → \"_20-webseiten-master\"\n const rawBase = basename(raw)\n .replace(/\\.md$/i, \"\")\n .toLowerCase()\n .trim();\n\n if (!rawBase) return null;\n\n const candidates = nameIndex.get(rawBase);\n\n if (!candidates || candidates.length === 0) {\n return null; // Dead link\n }\n\n if (candidates.length === 1) {\n return candidates[0]!;\n }\n\n // Multiple matches — pick the one closest to the source file\n const sourceDir = dirname(sourcePath);\n\n let bestPath: string | null = null;\n let bestPrefixLen = -1;\n let bestPathLen = Infinity;\n\n for (const candidate of candidates) {\n const candidateDir = dirname(candidate);\n const prefixLen = commonPrefixLength(sourceDir, candidateDir);\n const pathLen = candidate.length;\n\n if (\n prefixLen > bestPrefixLen ||\n (prefixLen === bestPrefixLen && pathLen < bestPathLen)\n ) {\n bestPrefixLen = prefixLen;\n bestPathLen = pathLen;\n bestPath = candidate;\n }\n }\n\n return bestPath;\n}\n\n/**\n * Compute the length of the common prefix between two directory paths,\n * measured in path segments (not raw characters).\n *\n * Example: \"a/b/c\" and \"a/b/d\" → 2 (common: \"a\", \"b\")\n */\nfunction commonPrefixLength(a: string, b: string): number {\n if (a === \".\" && b === \".\") return 0;\n const aParts = a === \".\" ? [] : a.split(\"/\");\n const bParts = b === \".\" ? [] : b.split(\"/\");\n let count = 0;\n const len = Math.min(aParts.length, bParts.length);\n for (let i = 0; i < len; i++) {\n if (aParts[i] === bParts[i]) {\n count++;\n } else {\n break;\n }\n }\n return count;\n}\n\n// ---------------------------------------------------------------------------\n// Main vault indexing orchestrator\n// ---------------------------------------------------------------------------\n\n/**\n * Index an entire Obsidian vault (or markdown knowledge base) into the\n * federation database.\n *\n * Steps:\n * 1. Walk vault root, following symlinks.\n * 2. Deduplicate by inode — each unique file is indexed once.\n * 3. Build a name index for wikilink resolution.\n * 4. For each canonical file:\n * a. SHA-256 hash for change detection — skip unchanged files.\n * b. Read content, chunk with chunkMarkdown().\n * c. Insert chunks into memory_chunks and memory_fts.\n * d. Upsert vault_files row.\n * 5. Record aliases in vault_aliases.\n * 6. Rebuild vault_name_index table.\n * 7. Rebuild vault_links:\n * a. Parse [[wikilinks]] from each canonical file.\n * b. Resolve each link with resolveWikilink().\n * c. Insert into vault_links.\n * 8. Compute and upsert health metrics (vault_health).\n * 9. Return statistics.\n *\n * @param db Open federation database.\n * @param vaultProjectId Registry project ID for the vault \"project\".\n * @param vaultRoot Absolute path to the vault root directory.\n */\nexport async function indexVault(\n db: Database,\n vaultProjectId: number,\n vaultRoot: string,\n): Promise<VaultIndexResult> {\n const startTime = Date.now();\n\n const result: VaultIndexResult = {\n filesIndexed: 0,\n chunksCreated: 0,\n filesSkipped: 0,\n aliasesRecorded: 0,\n linksExtracted: 0,\n deadLinksFound: 0,\n orphansFound: 0,\n elapsed: 0,\n };\n\n // ---------------------------------------------------------------------------\n // Step 1: Walk vault, collecting all .md files (follows symlinks)\n // ---------------------------------------------------------------------------\n\n const allFiles = walkVaultMdFiles(vaultRoot);\n\n // ---------------------------------------------------------------------------\n // Step 2: Deduplicate by inode\n // ---------------------------------------------------------------------------\n\n const inodeGroups = deduplicateByInode(allFiles);\n\n // ---------------------------------------------------------------------------\n // Step 3: Build name index (from all files including aliases, for resolution)\n // ---------------------------------------------------------------------------\n\n const nameIndex = buildNameIndex(allFiles);\n\n // ---------------------------------------------------------------------------\n // Step 4: Prepare SQL statements\n // ---------------------------------------------------------------------------\n\n const selectFileHash = db.prepare(\n \"SELECT hash FROM vault_files WHERE vault_path = ?\",\n );\n\n const deleteOldChunkIds = db.prepare(\n \"SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?\",\n );\n\n const deleteFts = db.prepare(\"DELETE FROM memory_fts WHERE id = ?\");\n\n const deleteChunks = db.prepare(\n \"DELETE FROM memory_chunks WHERE project_id = ? AND path = ?\",\n );\n\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 upsertVaultFile = db.prepare(`\n INSERT INTO vault_files (vault_path, inode, device, hash, title, indexed_at)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(vault_path) DO UPDATE SET\n inode = excluded.inode,\n device = excluded.device,\n hash = excluded.hash,\n title = excluded.title,\n indexed_at = excluded.indexed_at\n `);\n\n // ---------------------------------------------------------------------------\n // Step 4 (cont.): Index each canonical file\n // ---------------------------------------------------------------------------\n\n await yieldToEventLoop();\n let filesSinceYield = 0;\n\n for (const group of inodeGroups) {\n // Yield periodically to keep the IPC server responsive\n if (filesSinceYield >= VAULT_YIELD_EVERY) {\n await yieldToEventLoop();\n filesSinceYield = 0;\n }\n filesSinceYield++;\n\n const { canonical } = group;\n\n // Read file content\n let content: string;\n try {\n content = readFileSync(canonical.absPath, \"utf8\");\n } catch {\n result.filesSkipped++;\n continue;\n }\n\n const hash = sha256File(content);\n\n // Change detection: skip if hash is unchanged\n const existing = selectFileHash.get(canonical.vaultRelPath) as\n | { hash: string }\n | undefined;\n\n if (existing?.hash === hash) {\n result.filesSkipped++;\n continue;\n }\n\n // Delete old chunks for this vault path\n const oldChunkIds = deleteOldChunkIds.all(\n vaultProjectId,\n canonical.vaultRelPath,\n ) as Array<{ id: string }>;\n\n db.transaction(() => {\n for (const row of oldChunkIds) {\n deleteFts.run(row.id);\n }\n deleteChunks.run(vaultProjectId, canonical.vaultRelPath);\n })();\n\n // Chunk the content\n const chunks = chunkMarkdown(content);\n const updatedAt = Date.now();\n\n // Extract title from first H1 heading or filename\n const titleMatch = /^#\\s+(.+)$/m.exec(content);\n const title = titleMatch\n ? titleMatch[1]!.trim()\n : basename(canonical.vaultRelPath, \".md\");\n\n db.transaction(() => {\n for (let i = 0; i < chunks.length; i++) {\n const chunk = chunks[i]!;\n const id = chunkId(\n vaultProjectId,\n canonical.vaultRelPath,\n i,\n chunk.startLine,\n chunk.endLine,\n );\n insertChunk.run(\n id,\n vaultProjectId,\n \"vault\",\n \"topic\",\n canonical.vaultRelPath,\n chunk.startLine,\n chunk.endLine,\n chunk.hash,\n chunk.text,\n updatedAt,\n );\n insertFts.run(\n chunk.text,\n id,\n vaultProjectId,\n canonical.vaultRelPath,\n \"vault\",\n \"topic\",\n chunk.startLine,\n chunk.endLine,\n );\n }\n upsertVaultFile.run(\n canonical.vaultRelPath,\n canonical.inode,\n canonical.device,\n hash,\n title,\n updatedAt,\n );\n })();\n\n result.filesIndexed++;\n result.chunksCreated += chunks.length;\n }\n\n // ---------------------------------------------------------------------------\n // Step 5: Record aliases in vault_aliases\n // ---------------------------------------------------------------------------\n\n await yieldToEventLoop();\n\n // Clear old aliases for this vault before rebuilding\n // (We identify vault aliases by checking which canonical paths belong to\n // the canonical files we just indexed — simpler to clear + rebuild all.)\n db.exec(\"DELETE FROM vault_aliases\");\n\n const insertAlias = db.prepare(`\n INSERT OR REPLACE INTO vault_aliases (vault_path, canonical_path, inode, device)\n VALUES (?, ?, ?, ?)\n `);\n\n const insertAliasesTx = db.transaction((groups: InodeGroup[]) => {\n for (const group of groups) {\n for (const alias of group.aliases) {\n insertAlias.run(\n alias.vaultRelPath,\n group.canonical.vaultRelPath,\n alias.inode,\n alias.device,\n );\n result.aliasesRecorded++;\n }\n }\n });\n insertAliasesTx(inodeGroups);\n\n // ---------------------------------------------------------------------------\n // Step 6: Rebuild vault_name_index\n // ---------------------------------------------------------------------------\n\n await yieldToEventLoop();\n\n db.exec(\"DELETE FROM vault_name_index\");\n\n const insertNameIndex = db.prepare(`\n INSERT OR REPLACE INTO vault_name_index (name, vault_path) VALUES (?, ?)\n `);\n\n const insertNameIndexTx = db.transaction(\n (entries: Array<[string, string]>) => {\n for (const [name, path] of entries) {\n insertNameIndex.run(name, path);\n }\n },\n );\n\n const nameEntries: Array<[string, string]> = [];\n for (const [name, paths] of nameIndex) {\n for (const path of paths) {\n nameEntries.push([name, path]);\n }\n }\n insertNameIndexTx(nameEntries);\n\n // ---------------------------------------------------------------------------\n // Step 7: Rebuild vault_links\n // ---------------------------------------------------------------------------\n\n await yieldToEventLoop();\n\n db.exec(\"DELETE FROM vault_links\");\n\n const insertLink = db.prepare(`\n INSERT OR IGNORE INTO vault_links\n (source_path, target_raw, target_path, link_type, line_number)\n VALUES (?, ?, ?, ?, ?)\n `);\n\n // Parse and resolve wikilinks in bulk transaction\n const linkRows: Array<{\n source: string;\n raw: string;\n target: string | null;\n linkType: string;\n lineNumber: number;\n }> = [];\n\n for (const group of inodeGroups) {\n const { canonical } = group;\n\n let content: string;\n try {\n content = readFileSync(canonical.absPath, \"utf8\");\n } catch {\n continue;\n }\n\n const parsedLinks = parseLinks(content);\n for (const link of parsedLinks) {\n const target = resolveWikilink(link.raw, nameIndex, canonical.vaultRelPath);\n let linkType: string;\n if (link.isMdLink) {\n linkType = link.isEmbed ? \"md-embed\" : \"md-link\";\n } else {\n linkType = link.isEmbed ? \"embed\" : \"wikilink\";\n }\n linkRows.push({\n source: canonical.vaultRelPath,\n raw: link.raw,\n target,\n linkType,\n lineNumber: link.lineNumber,\n });\n }\n }\n\n const insertLinksTx = db.transaction(\n (\n rows: Array<{\n source: string;\n raw: string;\n target: string | null;\n linkType: string;\n lineNumber: number;\n }>,\n ) => {\n for (const row of rows) {\n insertLink.run(row.source, row.raw, row.target, row.linkType, row.lineNumber);\n }\n },\n );\n insertLinksTx(linkRows);\n\n result.linksExtracted = linkRows.length;\n result.deadLinksFound = linkRows.filter((r) => r.target === null).length;\n\n // ---------------------------------------------------------------------------\n // Step 8: Compute and upsert vault_health metrics\n // ---------------------------------------------------------------------------\n\n await yieldToEventLoop();\n\n // Count outbound links per source\n const outboundCounts = db\n .prepare(\n `SELECT source_path, COUNT(*) AS cnt FROM vault_links GROUP BY source_path`,\n )\n .all() as Array<{ source_path: string; cnt: number }>;\n\n // Count dead links per source\n const deadLinkCounts = db\n .prepare(\n `SELECT source_path, COUNT(*) AS cnt FROM vault_links\n WHERE target_path IS NULL GROUP BY source_path`,\n )\n .all() as Array<{ source_path: string; cnt: number }>;\n\n // Count inbound links per target\n const inboundCounts = db\n .prepare(\n `SELECT target_path, COUNT(*) AS cnt FROM vault_links\n WHERE target_path IS NOT NULL GROUP BY target_path`,\n )\n .all() as Array<{ target_path: string; cnt: number }>;\n\n // Build maps for O(1) lookup\n const outboundMap = new Map<string, number>(\n outboundCounts.map((r) => [r.source_path, r.cnt]),\n );\n const deadMap = new Map<string, number>(\n deadLinkCounts.map((r) => [r.source_path, r.cnt]),\n );\n const inboundMap = new Map<string, number>(\n inboundCounts.map((r) => [r.target_path, r.cnt]),\n );\n\n const upsertHealth = db.prepare(`\n INSERT INTO vault_health\n (vault_path, inbound_count, outbound_count, dead_link_count, is_orphan, computed_at)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(vault_path) DO UPDATE SET\n inbound_count = excluded.inbound_count,\n outbound_count = excluded.outbound_count,\n dead_link_count = excluded.dead_link_count,\n is_orphan = excluded.is_orphan,\n computed_at = excluded.computed_at\n `);\n\n const computedAt = Date.now();\n let orphanCount = 0;\n\n const upsertHealthTx = db.transaction((groups: InodeGroup[]) => {\n for (const group of groups) {\n const path = group.canonical.vaultRelPath;\n const inbound = inboundMap.get(path) ?? 0;\n const outbound = outboundMap.get(path) ?? 0;\n const dead = deadMap.get(path) ?? 0;\n const isOrphan = inbound === 0 ? 1 : 0;\n if (isOrphan) orphanCount++;\n upsertHealth.run(path, inbound, outbound, dead, isOrphan, computedAt);\n }\n });\n upsertHealthTx(inodeGroups);\n\n result.orphansFound = orphanCount;\n result.elapsed = Date.now() - startTime;\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA8DA,MAAM,kBAAkB;;AAGxB,MAAM,kBAAkB;;AAGxB,MAAM,oBAAoB;;;;;AAM1B,MAAM,kBAAkB,IAAI,IAAI;CAE9B;CAEA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CAEA;CACA;CAEA;CACA;CACD,CAAC;AAMF,SAAS,WAAW,SAAyB;AAC3C,QAAO,WAAW,SAAS,CAAC,OAAO,QAAQ,CAAC,OAAO,MAAM;;AAG3D,SAAS,QACP,WACA,MACA,YACA,WACA,SACQ;AACR,QAAO,WAAW,SAAS,CACxB,OAAO,GAAG,UAAU,GAAG,KAAK,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,CACpE,OAAO,MAAM;;AAGlB,SAAS,mBAAkC;AACzC,QAAO,IAAI,SAAS,YAAY,aAAa,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;AAwBxD,SAAgB,iBACd,WACA,MACa;CACb,MAAM,WAAW,MAAM,YAAY;CACnC,MAAM,WAAW,MAAM,YAAY;CAEnC,MAAM,UAAuB,EAAE;CAC/B,MAAM,8BAAc,IAAI,KAAa;CAErC,SAAS,KAAK,KAAa,OAAqB;AAC9C,MAAI,QAAQ,UAAU,SAAU;AAChC,MAAI,QAAQ,SAAU;EAGtB,IAAI;AACJ,MAAI;AACF,aAAU,SAAS,IAAI;UACjB;AACN;;EAGF,MAAM,SAAS,GAAG,QAAQ,IAAI,GAAG,QAAQ;AACzC,MAAI,YAAY,IAAI,OAAO,CAAE;AAC7B,cAAY,IAAI,OAAO;EAEvB,IAAI;AACJ,MAAI;AACF,aAAU,YAAY,KAAK;IAAE,eAAe;IAAM,UAAU;IAAQ,CAAC;UAC/D;AACN;;AAGF,OAAK,MAAM,SAAS,SAAS;AAC3B,OAAI,QAAQ,UAAU,SAAU;AAChC,OAAI,gBAAgB,IAAI,MAAM,KAAK,CAAE;GAErC,MAAM,OAAO,KAAK,KAAK,MAAM,KAAK;AAElC,OAAI,MAAM,gBAAgB,EAAE;IAE1B,IAAI;AACJ,QAAI;AACF,kBAAa,SAAS,KAAK;YACrB;AACN;;AAGF,QAAI,WAAW,aAAa,EAC1B;SAAI,CAAC,gBAAgB,IAAI,MAAM,KAAK,CAClC,MAAK,MAAM,QAAQ,EAAE;eAEd,WAAW,QAAQ,IAAI,MAAM,KAAK,SAAS,MAAM,CAC1D,SAAQ,KAAK;KACX,SAAS;KACT,cAAc,SAAS,WAAW,KAAK;KACvC,OAAO,WAAW;KAClB,QAAQ,WAAW;KACpB,CAAC;cAEK,MAAM,aAAa,CAC5B,MAAK,MAAM,QAAQ,EAAE;YACZ,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,MAAM,EAAE;IACvD,IAAI;AACJ,QAAI;AACF,gBAAW,SAAS,KAAK;YACnB;AACN;;AAEF,YAAQ,KAAK;KACX,SAAS;KACT,cAAc,SAAS,WAAW,KAAK;KACvC,OAAO,SAAS;KAChB,QAAQ,SAAS;KAClB,CAAC;;;;AAKR,KAAI,WAAW,UAAU,CACvB,MAAK,WAAW,EAAE;AAGpB,QAAO;;;;;;;;;AAcT,SAAgB,mBAAmB,OAAkC;CACnE,MAAM,yBAAS,IAAI,KAA0B;AAE7C,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,GAAG,KAAK,OAAO,GAAG,KAAK;EACnC,MAAM,WAAW,OAAO,IAAI,IAAI;AAChC,MAAI,SACF,UAAS,KAAK,KAAK;MAEnB,QAAO,IAAI,KAAK,CAAC,KAAK,CAAC;;CAI3B,MAAM,SAAuB,EAAE;AAE/B,MAAK,MAAM,SAAS,OAAO,QAAQ,EAAE;AACnC,MAAI,MAAM,WAAW,EAAG;EAUxB,MAAM,CAAC,WAAW,GAAG,WAPN,CAAC,GAAG,MAAM,CAAC,MAAM,GAAG,MAAM;GACvC,MAAM,UAAU,EAAE,aAAa,MAAM,MAAM,IAAI,EAAE,EAAE;GACnD,MAAM,UAAU,EAAE,aAAa,MAAM,MAAM,IAAI,EAAE,EAAE;AACnD,OAAI,WAAW,OAAQ,QAAO,SAAS;AACvC,UAAO,EAAE,aAAa,SAAS,EAAE,aAAa;IAC9C;AAGF,SAAO,KAAK;GAAE;GAAW;GAAS,CAAC;;AAGrC,QAAO;;;;;;;;;;;;;;;;;;;;AAyBT,SAAgB,WAAW,SAA+B;CACxD,MAAM,QAAsB,EAAE;CAC9B,MAAM,QAAQ,QAAQ,MAAM,KAAK;CAGjC,IAAI,iBAAiB;AACrB,KAAI,QAAQ,WAAW,MAAM,EAAE;EAC7B,MAAM,aAAa,QAAQ,QAAQ,SAAS,EAAE;AAC9C,MAAI,eAAe,GACjB,kBAAiB,QAAQ,MAAM,GAAG,aAAa,EAAE,CAAC,MAAM,KAAK,CAAC,SAAS;;CAK3E,MAAM,aAAa;CAKnB,MAAM,WAAW;AAEjB,MAAK,IAAI,UAAU,GAAG,UAAU,MAAM,QAAQ,WAAW;EACvD,MAAM,OAAO,MAAM;EACnB,MAAM,aAAa,UAAU;EAC7B,MAAM,gBAAgB,UAAU;AAGhC,aAAW,YAAY;EACvB,IAAI;AACJ,UAAQ,QAAQ,WAAW,KAAK,KAAK,MAAM,MAAM;GAC/C,MAAM,UAAU,MAAM,OAAO;GAC7B,MAAM,QAAQ,MAAM;GAGpB,MAAM,UAAU,MAAM,QAAQ,IAAI;GAClC,MAAM,aAAa,YAAY,KAAK,QAAQ,MAAM,MAAM,GAAG,QAAQ;GACnE,MAAM,QAAQ,YAAY,KAAK,OAAO,MAAM,MAAM,UAAU,EAAE;GAG9D,MAAM,UAAU,WAAW,QAAQ,IAAI;GACvC,MAAM,MAAM,YAAY,KAAK,WAAW,MAAM,GAAG,WAAW,MAAM,GAAG,QAAQ,CAAC,MAAM;AAEpF,OAAI,CAAC,IAAK;AAEV,SAAM,KAAK;IACT;IACA,OAAO,OAAO,MAAM,IAAI;IACxB;IACA,SAAS,WAAW,CAAC;IACrB,UAAU;IACX,CAAC;;AAIJ,MAAI,CAAC,eAAe;AAClB,YAAS,YAAY;AACrB,WAAQ,QAAQ,SAAS,KAAK,KAAK,MAAM,MAAM;IAC7C,MAAM,UAAU,MAAM,OAAO;IAC7B,MAAM,cAAc,MAAM;IAC1B,IAAI,SAAS,MAAM,GAAI,MAAM;AAG7B,QAAI,uBAAuB,KAAK,OAAO,CAAE;AAGzC,QAAI,OAAO,WAAW,IAAI,CAAE;IAG5B,MAAM,UAAU,OAAO,QAAQ,IAAI;AACnC,QAAI,YAAY,GAAI,UAAS,OAAO,MAAM,GAAG,QAAQ;AAGrD,QAAI;AACF,cAAS,mBAAmB,OAAO;YAC7B;IAKR,MAAM,MAAM,OAAO,QAAQ,UAAU,GAAG,CAAC,MAAM;AAC/C,QAAI,CAAC,IAAK;AAIV,UAAM,KAAK;KACT;KACA,OAAO,eAAe;KACtB;KACA;KACA,UAAU;KACX,CAAC;;;;AAKR,QAAO;;;;;;;;;AAiBT,SAAgB,eAAe,OAA2C;CACxE,MAAM,wBAAQ,IAAI,KAAuB;AAEzC,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,OAAO,SAAS,KAAK,cAAc,MAAM,CAAC,aAAa;EAC7D,MAAM,WAAW,MAAM,IAAI,KAAK;AAChC,MAAI,SACF,UAAS,KAAK,KAAK,aAAa;MAEhC,OAAM,IAAI,MAAM,CAAC,KAAK,aAAa,CAAC;;AAIxC,QAAO;;;;;;;;;;;;;;;;;;;AAwBT,SAAgB,gBACd,KACA,WACA,YACe;AACf,KAAI,CAAC,IAAK,QAAO;AAGjB,KAAI,IAAI,SAAS,IAAI,EAAE;EACrB,MAAM,aAAa,UAAU,IAAI;EACjC,MAAM,eAAe,WAAW,SAAS,MAAM,GAAG,aAAa,aAAa;AAG5E,OAAK,MAAM,GAAG,UAAU,UACtB,MAAK,MAAM,KAAK,OAAO;AACrB,OAAI,MAAM,gBAAgB,MAAM,WAAY,QAAO;AACnD,OAAI,EAAE,aAAa,KAAK,aAAa,aAAa,CAAE,QAAO;;;CASjE,MAAM,UAAU,SAAS,IAAI,CAC1B,QAAQ,UAAU,GAAG,CACrB,aAAa,CACb,MAAM;AAET,KAAI,CAAC,QAAS,QAAO;CAErB,MAAM,aAAa,UAAU,IAAI,QAAQ;AAEzC,KAAI,CAAC,cAAc,WAAW,WAAW,EACvC,QAAO;AAGT,KAAI,WAAW,WAAW,EACxB,QAAO,WAAW;CAIpB,MAAM,YAAY,QAAQ,WAAW;CAErC,IAAI,WAA0B;CAC9B,IAAI,gBAAgB;CACpB,IAAI,cAAc;AAElB,MAAK,MAAM,aAAa,YAAY;EAElC,MAAM,YAAY,mBAAmB,WADhB,QAAQ,UAAU,CACsB;EAC7D,MAAM,UAAU,UAAU;AAE1B,MACE,YAAY,iBACX,cAAc,iBAAiB,UAAU,aAC1C;AACA,mBAAgB;AAChB,iBAAc;AACd,cAAW;;;AAIf,QAAO;;;;;;;;AAST,SAAS,mBAAmB,GAAW,GAAmB;AACxD,KAAI,MAAM,OAAO,MAAM,IAAK,QAAO;CACnC,MAAM,SAAS,MAAM,MAAM,EAAE,GAAG,EAAE,MAAM,IAAI;CAC5C,MAAM,SAAS,MAAM,MAAM,EAAE,GAAG,EAAE,MAAM,IAAI;CAC5C,IAAI,QAAQ;CACZ,MAAM,MAAM,KAAK,IAAI,OAAO,QAAQ,OAAO,OAAO;AAClD,MAAK,IAAI,IAAI,GAAG,IAAI,KAAK,IACvB,KAAI,OAAO,OAAO,OAAO,GACvB;KAEA;AAGJ,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCT,eAAsB,WACpB,IACA,gBACA,WAC2B;CAC3B,MAAM,YAAY,KAAK,KAAK;CAE5B,MAAM,SAA2B;EAC/B,cAAc;EACd,eAAe;EACf,cAAc;EACd,iBAAiB;EACjB,gBAAgB;EAChB,gBAAgB;EAChB,cAAc;EACd,SAAS;EACV;CAMD,MAAM,WAAW,iBAAiB,UAAU;CAM5C,MAAM,cAAc,mBAAmB,SAAS;CAMhD,MAAM,YAAY,eAAe,SAAS;CAM1C,MAAM,iBAAiB,GAAG,QACxB,oDACD;CAED,MAAM,oBAAoB,GAAG,QAC3B,iEACD;CAED,MAAM,YAAY,GAAG,QAAQ,sCAAsC;CAEnE,MAAM,eAAe,GAAG,QACtB,8DACD;CAED,MAAM,cAAc,GAAG,QAAQ;;;IAG7B;CAEF,MAAM,YAAY,GAAG,QAAQ;;;IAG3B;CAEF,MAAM,kBAAkB,GAAG,QAAQ;;;;;;;;;IASjC;AAMF,OAAM,kBAAkB;CACxB,IAAI,kBAAkB;AAEtB,MAAK,MAAM,SAAS,aAAa;AAE/B,MAAI,mBAAmB,mBAAmB;AACxC,SAAM,kBAAkB;AACxB,qBAAkB;;AAEpB;EAEA,MAAM,EAAE,cAAc;EAGtB,IAAI;AACJ,MAAI;AACF,aAAU,aAAa,UAAU,SAAS,OAAO;UAC3C;AACN,UAAO;AACP;;EAGF,MAAM,OAAO,WAAW,QAAQ;AAOhC,MAJiB,eAAe,IAAI,UAAU,aAAa,EAI7C,SAAS,MAAM;AAC3B,UAAO;AACP;;EAIF,MAAM,cAAc,kBAAkB,IACpC,gBACA,UAAU,aACX;AAED,KAAG,kBAAkB;AACnB,QAAK,MAAM,OAAO,YAChB,WAAU,IAAI,IAAI,GAAG;AAEvB,gBAAa,IAAI,gBAAgB,UAAU,aAAa;IACxD,EAAE;EAGJ,MAAM,SAAS,cAAc,QAAQ;EACrC,MAAM,YAAY,KAAK,KAAK;EAG5B,MAAM,aAAa,cAAc,KAAK,QAAQ;EAC9C,MAAM,QAAQ,aACV,WAAW,GAAI,MAAM,GACrB,SAAS,UAAU,cAAc,MAAM;AAE3C,KAAG,kBAAkB;AACnB,QAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;IACtC,MAAM,QAAQ,OAAO;IACrB,MAAM,KAAK,QACT,gBACA,UAAU,cACV,GACA,MAAM,WACN,MAAM,QACP;AACD,gBAAY,IACV,IACA,gBACA,SACA,SACA,UAAU,cACV,MAAM,WACN,MAAM,SACN,MAAM,MACN,MAAM,MACN,UACD;AACD,cAAU,IACR,MAAM,MACN,IACA,gBACA,UAAU,cACV,SACA,SACA,MAAM,WACN,MAAM,QACP;;AAEH,mBAAgB,IACd,UAAU,cACV,UAAU,OACV,UAAU,QACV,MACA,OACA,UACD;IACD,EAAE;AAEJ,SAAO;AACP,SAAO,iBAAiB,OAAO;;AAOjC,OAAM,kBAAkB;AAKxB,IAAG,KAAK,4BAA4B;CAEpC,MAAM,cAAc,GAAG,QAAQ;;;IAG7B;AAeF,CAbwB,GAAG,aAAa,WAAyB;AAC/D,OAAK,MAAM,SAAS,OAClB,MAAK,MAAM,SAAS,MAAM,SAAS;AACjC,eAAY,IACV,MAAM,cACN,MAAM,UAAU,cAChB,MAAM,OACN,MAAM,OACP;AACD,UAAO;;GAGX,CACc,YAAY;AAM5B,OAAM,kBAAkB;AAExB,IAAG,KAAK,+BAA+B;CAEvC,MAAM,kBAAkB,GAAG,QAAQ;;IAEjC;CAEF,MAAM,oBAAoB,GAAG,aAC1B,YAAqC;AACpC,OAAK,MAAM,CAAC,MAAM,SAAS,QACzB,iBAAgB,IAAI,MAAM,KAAK;GAGpC;CAED,MAAM,cAAuC,EAAE;AAC/C,MAAK,MAAM,CAAC,MAAM,UAAU,UAC1B,MAAK,MAAM,QAAQ,MACjB,aAAY,KAAK,CAAC,MAAM,KAAK,CAAC;AAGlC,mBAAkB,YAAY;AAM9B,OAAM,kBAAkB;AAExB,IAAG,KAAK,0BAA0B;CAElC,MAAM,aAAa,GAAG,QAAQ;;;;IAI5B;CAGF,MAAM,WAMD,EAAE;AAEP,MAAK,MAAM,SAAS,aAAa;EAC/B,MAAM,EAAE,cAAc;EAEtB,IAAI;AACJ,MAAI;AACF,aAAU,aAAa,UAAU,SAAS,OAAO;UAC3C;AACN;;EAGF,MAAM,cAAc,WAAW,QAAQ;AACvC,OAAK,MAAM,QAAQ,aAAa;GAC9B,MAAM,SAAS,gBAAgB,KAAK,KAAK,WAAW,UAAU,aAAa;GAC3E,IAAI;AACJ,OAAI,KAAK,SACP,YAAW,KAAK,UAAU,aAAa;OAEvC,YAAW,KAAK,UAAU,UAAU;AAEtC,YAAS,KAAK;IACZ,QAAQ,UAAU;IAClB,KAAK,KAAK;IACV;IACA;IACA,YAAY,KAAK;IAClB,CAAC;;;AAmBN,CAfsB,GAAG,aAErB,SAOG;AACH,OAAK,MAAM,OAAO,KAChB,YAAW,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,QAAQ,IAAI,UAAU,IAAI,WAAW;GAGlF,CACa,SAAS;AAEvB,QAAO,iBAAiB,SAAS;AACjC,QAAO,iBAAiB,SAAS,QAAQ,MAAM,EAAE,WAAW,KAAK,CAAC;AAMlE,OAAM,kBAAkB;CAGxB,MAAM,iBAAiB,GACpB,QACC,4EACD,CACA,KAAK;CAGR,MAAM,iBAAiB,GACpB,QACC;uDAED,CACA,KAAK;CAGR,MAAM,gBAAgB,GACnB,QACC;2DAED,CACA,KAAK;CAGR,MAAM,cAAc,IAAI,IACtB,eAAe,KAAK,MAAM,CAAC,EAAE,aAAa,EAAE,IAAI,CAAC,CAClD;CACD,MAAM,UAAU,IAAI,IAClB,eAAe,KAAK,MAAM,CAAC,EAAE,aAAa,EAAE,IAAI,CAAC,CAClD;CACD,MAAM,aAAa,IAAI,IACrB,cAAc,KAAK,MAAM,CAAC,EAAE,aAAa,EAAE,IAAI,CAAC,CACjD;CAED,MAAM,eAAe,GAAG,QAAQ;;;;;;;;;;IAU9B;CAEF,MAAM,aAAa,KAAK,KAAK;CAC7B,IAAI,cAAc;AAalB,CAXuB,GAAG,aAAa,WAAyB;AAC9D,OAAK,MAAM,SAAS,QAAQ;GAC1B,MAAM,OAAO,MAAM,UAAU;GAC7B,MAAM,UAAU,WAAW,IAAI,KAAK,IAAI;GACxC,MAAM,WAAW,YAAY,IAAI,KAAK,IAAI;GAC1C,MAAM,OAAO,QAAQ,IAAI,KAAK,IAAI;GAClC,MAAM,WAAW,YAAY,IAAI,IAAI;AACrC,OAAI,SAAU;AACd,gBAAa,IAAI,MAAM,SAAS,UAAU,MAAM,UAAU,WAAW;;GAEvE,CACa,YAAY;AAE3B,QAAO,eAAe;AACtB,QAAO,UAAU,KAAK,KAAK,GAAG;AAE9B,QAAO"}
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync, appendFileSync, mkdirSync, existsSync, writeFileSync } fr
|
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { PAI_DIR, HISTORY_DIR } from './lib/pai-paths';
|
|
12
12
|
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './lib/metadata-extraction';
|
|
13
|
+
import { isProbeSession } from './lib/project-utils';
|
|
13
14
|
|
|
14
15
|
interface HookEvent {
|
|
15
16
|
source_app: string;
|
|
@@ -90,6 +91,11 @@ function setAgentForSession(sessionId: string, agentName: string): void {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
async function main() {
|
|
94
|
+
// Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
|
|
95
|
+
if (isProbeSession()) {
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
try {
|
|
94
100
|
// Get event type from command line args
|
|
95
101
|
const args = process.argv.slice(2);
|