Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

cloudctx

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cloudctx - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+64
-63
lib/hook.js

@@ -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');

@@ -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 @@

@@ -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 @@

{
"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",