+64
-63
@@ -1,2 +0,2 @@ | ||
| import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; | ||
| import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; | ||
| import { join } from 'path'; | ||
@@ -25,2 +25,7 @@ import { homedir } from 'os'; | ||
| const MARKER_DIR = join(getDataDir(), '.compaction-markers'); | ||
| // Per-session sync state: caches the JSONL byte offset we last processed plus | ||
| // the cumulative count of compaction markers seen. Lets the hook skip a full | ||
| // re-read of the JSONL on every prompt — critical for resumed sessions whose | ||
| // JSONL can be tens of MB. | ||
| const SYNC_STATE_DIR = join(getDataDir(), '.sync-state'); | ||
@@ -87,8 +92,8 @@ const REMINDER = `CloudCtx is your memory (BM25-searchable past messages). Search BEFORE asking or guessing any unfamiliar term, tool, project, person, file, or API: | ||
| // Quick sync current session so we have latest messages in DB | ||
| syncCurrentSession(sessionId); | ||
| // Incrementally sync new JSONL records and get the cached cumulative | ||
| // compaction-marker count. Single tail-read; no-op if file unchanged. | ||
| const jsonlCompactions = syncCurrentSessionIncremental(sessionId); | ||
| // Detect compaction: check if the JSONL has a compaction summary message | ||
| // OR check for legacy agent-acompact files | ||
| const compactionDetected = checkCompaction(sessionId, prompt); | ||
| // Detect compaction: combine cached JSONL count with legacy agent-acompact files | ||
| const compactionDetected = checkCompaction(sessionId, jsonlCompactions); | ||
@@ -210,37 +215,71 @@ if (compactionDetected) { | ||
| function syncCurrentSession(sessionId) { | ||
| function readSyncState(sessionId) { | ||
| const file = join(SYNC_STATE_DIR, sessionId); | ||
| if (!existsSync(file)) return null; | ||
| try { | ||
| const [sizeStr, compStr] = readFileSync(file, 'utf-8').trim().split(','); | ||
| const size = parseInt(sizeStr, 10); | ||
| const compactions = parseInt(compStr, 10); | ||
| if (Number.isNaN(size) || Number.isNaN(compactions)) return null; | ||
| return { size, compactions }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function writeSyncState(sessionId, size, compactions) { | ||
| try { | ||
| mkdirSync(SYNC_STATE_DIR, { recursive: true }); | ||
| writeFileSync(join(SYNC_STATE_DIR, sessionId), `${size},${compactions}`); | ||
| } catch {} | ||
| } | ||
| // Incrementally process only the JSONL bytes appended since last hook fire. | ||
| // Returns cumulative JSONL compaction-marker count (cached + newly seen). | ||
| // On a no-op fire (file unchanged) this opens no DB connection at all. | ||
| function syncCurrentSessionIncremental(sessionId) { | ||
| try { | ||
| const projectsDir = join(CLAUDE_DIR, 'projects'); | ||
| if (!existsSync(projectsDir)) return; | ||
| if (!existsSync(projectsDir)) return 0; | ||
| for (const projectName of readdirSync(projectsDir)) { | ||
| const jsonlPath = join(projectsDir, projectName, `${sessionId}.jsonl`); | ||
| if (existsSync(jsonlPath)) { | ||
| const db = getDb(); | ||
| processConversationFile(db, jsonlPath, projectName); | ||
| db.close(); | ||
| return; | ||
| if (!existsSync(jsonlPath)) continue; | ||
| const stat = statSync(jsonlPath); | ||
| const state = readSyncState(sessionId); | ||
| // Recover from anomaly (file shrank, e.g. corruption or manual edit) by | ||
| // re-processing from byte 0. INSERT OR IGNORE keeps the DB consistent. | ||
| const fromOffset = (state && stat.size >= state.size) ? state.size : 0; | ||
| const baseCompactions = (state && stat.size >= state.size) ? state.compactions : 0; | ||
| // No new bytes — skip DB open entirely. | ||
| if (fromOffset === stat.size && state) { | ||
| return state.compactions; | ||
| } | ||
| const db = getDb(); | ||
| const result = processConversationFile(db, jsonlPath, projectName, fromOffset); | ||
| db.close(); | ||
| const totalCompactions = baseCompactions + (result.jsonlCompactionMarkers || 0); | ||
| writeSyncState(sessionId, result.newOffset ?? stat.size, totalCompactions); | ||
| return totalCompactions; | ||
| } | ||
| } catch {} | ||
| return 0; | ||
| } | ||
| function checkCompaction(sessionId, prompt) { | ||
| function checkCompaction(sessionId, jsonlCompactions) { | ||
| mkdirSync(MARKER_DIR, { recursive: true }); | ||
| const markerFile = join(MARKER_DIR, sessionId); | ||
| // Method 1: Detect compaction from the prompt content | ||
| // After compaction, the next user message often contains "continued from a previous conversation" | ||
| // or the user's first message after /compact | ||
| // We detect this by checking if the JSONL has a compaction summary record | ||
| const compactedViaJsonl = checkJsonlForCompaction(sessionId); | ||
| // Combine cached JSONL count (from incremental sync) with legacy | ||
| // agent-acompact files count (cheap directory listing). | ||
| const totalCompactions = (jsonlCompactions || 0) + checkCompactionFiles(sessionId); | ||
| // Method 2: Legacy detection via agent-acompact files | ||
| const compactedViaFiles = checkCompactionFiles(sessionId); | ||
| const totalCompactions = compactedViaJsonl + compactedViaFiles; | ||
| if (totalCompactions === 0) return false; | ||
| // Check marker to avoid re-injecting | ||
| // Marker tracks last-injected count to avoid re-firing on the same compaction. | ||
| let seenCount = 0; | ||
@@ -261,40 +300,2 @@ if (existsSync(markerFile)) { | ||
| function checkJsonlForCompaction(sessionId) { | ||
| const projectsDir = join(CLAUDE_DIR, 'projects'); | ||
| if (!existsSync(projectsDir)) return 0; | ||
| try { | ||
| for (const projectName of readdirSync(projectsDir)) { | ||
| const jsonlPath = join(projectsDir, projectName, `${sessionId}.jsonl`); | ||
| if (existsSync(jsonlPath)) { | ||
| // Count compaction summaries in the JSONL | ||
| const content = readFileSync(jsonlPath, 'utf-8'); | ||
| let count = 0; | ||
| for (const line of content.split('\n')) { | ||
| if (!line.trim()) continue; | ||
| try { | ||
| const record = JSON.parse(line); | ||
| // Compaction summary appears as a user message with this specific text | ||
| if (record.type === 'user') { | ||
| const msgContent = record.message?.content; | ||
| if (typeof msgContent === 'string' && msgContent.includes('This session is being continued from a previous conversation')) { | ||
| count++; | ||
| } else if (Array.isArray(msgContent)) { | ||
| for (const block of msgContent) { | ||
| if (block?.type === 'text' && block.text?.includes('This session is being continued from a previous conversation')) { | ||
| count++; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch {} | ||
| } | ||
| return count; | ||
| } | ||
| } | ||
| } catch {} | ||
| return 0; | ||
| } | ||
| function checkCompactionFiles(sessionId) { | ||
@@ -301,0 +302,0 @@ const projectsDir = join(CLAUDE_DIR, 'projects'); |
+37
-4
@@ -192,10 +192,43 @@ import { getReadonlyDb, getDb, dbExists, migrate } from './db.js'; | ||
| const spawnOpts = { stdio: 'inherit', env: process.env }; | ||
| // Honor the saved project_path by chdir'ing before exec — execvp inherits | ||
| // the calling process's cwd, and claude --resume only finds sessions whose | ||
| // project matches the spawn cwd. | ||
| if (selected.project_path && existsSync(selected.project_path)) { | ||
| spawnOpts.cwd = selected.project_path; | ||
| try { process.chdir(selected.project_path); } catch {} | ||
| } | ||
| const child = spawn(claudeBin, ['--resume', selected.session_id], spawnOpts); | ||
| // Replace the cloudctx process with claude via POSIX execvp. This is THE | ||
| // fix for the typing-lag bug: previously, cloudctx (Bun) spawned claude | ||
| // (also Bun, since CC ships as `bun build --compile`) and waited on it. | ||
| // Two Bun event loops running simultaneously — both with refs to the | ||
| // inherited TTY fd — caused intermittent keystroke loss in the child. | ||
| // execvp eliminates the parent entirely: cloudctx's PID literally becomes | ||
| // claude. No competing event loop, no TTY contention possible. | ||
| await execReplace(claudeBin, [claudeBin, '--resume', selected.session_id]); | ||
| // execReplace only returns on failure (success replaces this process). | ||
| console.error(`Failed to exec claude at ${claudeBin}`); | ||
| process.exit(1); | ||
| } | ||
| child.on('exit', (code) => process.exit(code || 0)); | ||
| // Wraps libc execvp via Bun FFI. On success, the current process is replaced | ||
| // and this function never returns. On failure, returns the errno. | ||
| async function execReplace(file, argv) { | ||
| const { dlopen, FFIType, suffix, ptr } = await import('bun:ffi'); | ||
| const libc = dlopen(`libc.${suffix}`, { | ||
| execvp: { args: [FFIType.cstring, FFIType.ptr], returns: FFIType.i32 }, | ||
| }); | ||
| // Build a NULL-terminated array of pointers to the argv C-strings. | ||
| // Keep the string Buffers alive (rooted in this scope) until execvp returns — | ||
| // GC'ing them mid-call would free the memory libc is reading from. | ||
| const cstrings = argv.map(s => Buffer.from(s + '\0', 'utf-8')); | ||
| const argvBuf = Buffer.alloc((cstrings.length + 1) * 8); // 8 bytes per ptr (64-bit) | ||
| for (let i = 0; i < cstrings.length; i++) { | ||
| const p = ptr(cstrings[i]); | ||
| argvBuf.writeBigUInt64LE(typeof p === 'bigint' ? p : BigInt(p), i * 8); | ||
| } | ||
| // Final slot stays zeroed = NULL terminator. | ||
| const fileBuf = Buffer.from(file + '\0', 'utf-8'); | ||
| return libc.symbols.execvp(fileBuf, argvBuf); | ||
| } | ||
@@ -202,0 +235,0 @@ |
+51
-4
@@ -1,2 +0,2 @@ | ||
| import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; | ||
| import { readFileSync, readdirSync, existsSync, statSync, openSync, readSync, closeSync } from 'fs'; | ||
| import { join, basename } from 'path'; | ||
@@ -85,6 +85,53 @@ import { homedir } from 'os'; | ||
| export function processConversationFile(db, filepath, projectName) { | ||
| // Reads file from `fromOffset` to EOF. Returns parsed records + the new EOF | ||
| // byte offset so callers can persist it for next-call incremental reads. | ||
| // JSONL is append-only line-by-line, so a stored EOF is always a clean line | ||
| // boundary on the next read. | ||
| function parseJsonlFileFromOffset(filepath, fromOffset = 0) { | ||
| const records = []; | ||
| let newOffset = fromOffset; | ||
| let jsonlCompactionMarkers = 0; | ||
| try { | ||
| const stat = statSync(filepath); | ||
| if (fromOffset >= stat.size) return { records, newOffset: stat.size, jsonlCompactionMarkers }; | ||
| const fd = openSync(filepath, 'r'); | ||
| try { | ||
| const len = stat.size - fromOffset; | ||
| const buf = Buffer.alloc(len); | ||
| readSync(fd, buf, 0, len, fromOffset); | ||
| const text = buf.toString('utf-8'); | ||
| for (const line of text.split('\n')) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed) continue; | ||
| try { | ||
| const record = JSON.parse(trimmed); | ||
| records.push(record); | ||
| // Count compaction markers inline so the hook doesn't need a second pass. | ||
| if (record.type === 'user') { | ||
| const c = record.message?.content; | ||
| if (typeof c === 'string' && c.includes('This session is being continued from a previous conversation')) { | ||
| jsonlCompactionMarkers++; | ||
| } else if (Array.isArray(c)) { | ||
| for (const block of c) { | ||
| if (block?.type === 'text' && block.text?.includes('This session is being continued from a previous conversation')) { | ||
| jsonlCompactionMarkers++; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch {} | ||
| } | ||
| newOffset = stat.size; | ||
| } finally { | ||
| closeSync(fd); | ||
| } | ||
| } catch {} | ||
| return { records, newOffset, jsonlCompactionMarkers }; | ||
| } | ||
| export function processConversationFile(db, filepath, projectName, fromOffset = 0) { | ||
| const sessionId = basename(filepath, '.jsonl'); | ||
| const isAgent = basename(filepath).startsWith('agent-'); | ||
| const records = parseJsonlFile(filepath); | ||
| const { records, newOffset, jsonlCompactionMarkers } = parseJsonlFileFromOffset(filepath, fromOffset); | ||
@@ -171,3 +218,3 @@ const insertMsg = db.prepare(` | ||
| return { messagesAdded, toolsAdded }; | ||
| return { messagesAdded, toolsAdded, newOffset, jsonlCompactionMarkers }; | ||
| } | ||
@@ -174,0 +221,0 @@ |
+1
-1
| { | ||
| "name": "cloudctx", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Persistent memory for Claude Code. One command, full recall.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
94819
4.62%2337
3.32%15
7.14%