Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ensureSessionOwner,
} from "./summary-state.js";
import { bundleDirFromImportMeta, spawnWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { appendSessionEvent } from "./session-event-cache.js";
import { tryStopCounterTrigger } from "../skillify/triggers.js";
import { reactSkillOpt } from "./shared/skillopt-hook.js";
import { EmbedClient } from "../embeddings/client.js";
Expand Down Expand Up @@ -184,6 +185,13 @@ async function main(): Promise<void> {

log("capture ok → cloud");

// Mirror the event into the local per-session cache (row-for-row identical
// to the `message` column just INSERTed). The wiki-worker reads this instead
// of re-scanning the entire fat `message` column for the current session on
// every periodic / session-end summary trigger. Best-effort; DB stays the
// source of truth. Only reached after a successful INSERT above.
appendSessionEvent(input.session_id, line);

// Commit-driven KPI auto-extract is disabled for now — the
// fire-and-forget sub-agent spawned per `git commit` (see
// src/hooks/commit-kpi-extract.ts) consumed a high amount of tokens
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/cursor/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
releaseLock,
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnCursorWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { appendSessionEvent } from "../session-event-cache.js";
import { tryStopCounterTrigger } from "../../skillify/triggers.js";
import type { Config } from "../../config.js";
import { getInstalledVersion } from "../../utils/version-check.js";
Expand Down Expand Up @@ -180,6 +181,12 @@ async function main(): Promise<void> {

log("capture ok → cloud");

// Mirror the event into the local per-session cache (row-for-row identical
// to the `message` column just INSERTed) so the wiki-worker reads it instead
// of re-scanning the fat `message` column. Best-effort; only after a
// successful INSERT above.
appendSessionEvent(sessionId, line);

maybeTriggerPeriodicSummary(sessionId, cwd, config);

// Skillify Stop counter — afterAgentResponse is the assistant-complete event.
Expand Down
1 change: 1 addition & 0 deletions src/hooks/cursor/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function spawnCursorWikiWorker(opts: SpawnOptions): void {
sessionsTable: config.sessionsTableName,
sessionId,
userName: config.userName,
orgName: config.orgName,
project: projectName,
pluginVersion,
tmpDir,
Expand Down
62 changes: 51 additions & 11 deletions src/hooks/cursor/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { buildTrailingPromptInvocation } from "../wiki-worker-spawn.js";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { readSessionEventCache } from "../session-event-cache.js";
import { buildSessionPath } from "../../utils/session-path.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
Expand All @@ -36,6 +38,7 @@ interface WorkerConfig {
sessionsTable: string;
sessionId: string;
userName: string;
orgName: string;
project: string;
pluginVersion?: string;
tmpDir: string;
Expand Down Expand Up @@ -114,27 +117,55 @@ function cleanup(): void {

async function main(): Promise<void> {
try {
// 1. Fetch session events from sessions table
wlog("fetching session events");
const rows = await query(
// 1. Load session events. Prefer the local per-session event cache the
// capture hook appends to as the session runs — it is row-for-row
// identical to the sessions-table `message` column but avoids re-scanning
// the entire fat `message` column on the backend for THIS session on every
// periodic / session-end trigger (the dominant cold-start cost on long
// mega-sessions). Falls back to the DB whenever the cache is absent
// (session resumed on another machine), empty, or — once the offset is
// known — shorter than the offset already summarized.
const dbFetch = () => query(
`SELECT message, creation_date FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE E'${esc(`/sessions/%${cfg.sessionId}%`)}' ORDER BY creation_date ASC`
);

let usedLocalCache = false;
let rows: Record<string, unknown>[];
const cachedLines = readSessionEventCache(cfg.sessionId);
if (cachedLines && cachedLines.length > 0) {
rows = cachedLines.map(message => ({ message }));
usedLocalCache = true;
wlog(`loaded ${rows.length} events from local cache`);
} else {
wlog("fetching session events");
rows = await dbFetch();
}

if (rows.length === 0) {
wlog("no session events found — exiting");
return;
}

const jsonlLines = rows.length;
let jsonlLines = rows.length;

const pathRows = await query(
`SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
);
const jsonlServerPath = pathRows.length > 0
? pathRows[0].path as string
: `/sessions/unknown/${cfg.sessionId}.jsonl`;
// Derive the server path locally when using the cache (avoids a second
// self-session `SELECT DISTINCT path` scan); the DB branch keeps its lookup.
let jsonlServerPath: string;
if (usedLocalCache) {
jsonlServerPath = buildSessionPath(
{ userName: cfg.userName, orgName: cfg.orgName, workspaceId: cfg.workspaceId },
cfg.sessionId,
);
} else {
const pathRows = await query(
`SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
);
jsonlServerPath = pathRows.length > 0
? pathRows[0].path as string
: `/sessions/unknown/${cfg.sessionId}.jsonl`;
}

// 2. Determine how many rows were already summarized (resumed session).
// The sidecar count is authoritative: finalizeSummary writes it after every
Expand Down Expand Up @@ -176,6 +207,15 @@ async function main(): Promise<void> {
if (sidecarCount > prevOffset) prevOffset = sidecarCount;
}

// Safety net: a local cache shorter than the summarized offset is an
// incomplete copy (session resumed on another machine) — re-load the full
// session from the DB so no genuinely-new rows get sliced to nothing.
if (usedLocalCache && rows.length < prevOffset) {
wlog(`local cache (${rows.length}) < summarized offset (${prevOffset}) — refetching from DB`);
rows = await dbFetch();
jsonlLines = rows.length;
}

Comment on lines +210 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale jsonlServerPath after safety-net DB refetch.

When the cache-shorter-than-offset safety net fires, rows/jsonlLines are re-derived from the DB, but jsonlServerPath (computed earlier at lines 155-159 via buildSessionPath, under the assumption the cache is authoritative) is never recomputed. The exact scenario that triggers this branch — a session resumed on another machine — is also the scenario most likely to make the locally-derived path diverge from the actual DB-stored path, since the whole point of the DB fallback here is "don't trust local assumptions."

🔧 Proposed fix: recompute path when falling back
+    const resolveDbPath = async (): Promise<string> => {
+      const pathRows = await query(
+        `SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
+        `WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
+      );
+      return pathRows.length > 0
+        ? pathRows[0].path as string
+        : `/sessions/unknown/${cfg.sessionId}.jsonl`;
+    };
     if (usedLocalCache && rows.length < prevOffset) {
       wlog(`local cache (${rows.length}) < summarized offset (${prevOffset}) — refetching from DB`);
       rows = await dbFetch();
       jsonlLines = rows.length;
+      jsonlServerPath = await resolveDbPath();
     }

(and reuse resolveDbPath() in the existing else branch above instead of duplicating the query inline.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Safety net: a local cache shorter than the summarized offset is an
// incomplete copy (session resumed on another machine) — re-load the full
// session from the DB so no genuinely-new rows get sliced to nothing.
if (usedLocalCache && rows.length < prevOffset) {
wlog(`local cache (${rows.length}) < summarized offset (${prevOffset}) — refetching from DB`);
rows = await dbFetch();
jsonlLines = rows.length;
}
const resolveDbPath = async (): Promise<string> => {
const pathRows = await query(
`SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
);
return pathRows.length > 0
? pathRows[0].path as string
: `/sessions/unknown/${cfg.sessionId}.jsonl`;
};
// Safety net: a local cache shorter than the summarized offset is an
// incomplete copy (session resumed on another machine) — re-load the full
// session from the DB so no genuinely-new rows get sliced to nothing.
if (usedLocalCache && rows.length < prevOffset) {
wlog(`local cache (${rows.length}) < summarized offset (${prevOffset}) — refetching from DB`);
rows = await dbFetch();
jsonlLines = rows.length;
jsonlServerPath = await resolveDbPath();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/cursor/wiki-worker.ts` around lines 210 - 218, Update the
safety-net branch in the cursor worker so the DB fallback refreshes
jsonlServerPath alongside rows and jsonlLines, using resolveDbPath() to derive
the authoritative stored path. In the existing else branch, replace the
duplicated inline path query with resolveDbPath() as well, preserving the
current local-cache path behavior when no fallback occurs.

// Feed the agent only the rows added since the last summary. Reprocessing
// the full session on every run is what drives ENOBUFS / 120s-timeout
// failures on long (4000+ event) sessions — a stuck offset re-summarizes
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/hermes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
releaseLock,
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { appendSessionEvent } from "../session-event-cache.js";
import { tryStopCounterTrigger } from "../../skillify/triggers.js";
import type { Config } from "../../config.js";
import { getInstalledVersion } from "../../utils/version-check.js";
Expand Down Expand Up @@ -163,6 +164,12 @@ async function main(): Promise<void> {

log("capture ok → cloud");

// Mirror the event into the local per-session cache (row-for-row identical
// to the `message` column just INSERTed) so the wiki-worker reads it instead
// of re-scanning the fat `message` column. Best-effort; only after a
// successful INSERT above.
appendSessionEvent(sessionId, line);

// SkillOpt: a pre_llm_call prompt is the user's reaction to a recently-used org skill.
// Swallowed; no-op unless a judgment window is open for this session.
reactSkillOpt(sessionId, reactPrompt, "hermes");
Expand Down
1 change: 1 addition & 0 deletions src/hooks/hermes/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function spawnHermesWikiWorker(opts: SpawnOptions): void {
sessionsTable: config.sessionsTableName,
sessionId,
userName: config.userName,
orgName: config.orgName,
project: projectName,
pluginVersion,
tmpDir,
Expand Down
62 changes: 51 additions & 11 deletions src/hooks/hermes/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { finalizeSummary, releaseLock, readState } from "../summary-state.js";
import { readSessionEventCache } from "../session-event-cache.js";
import { buildSessionPath } from "../../utils/session-path.js";
import { capLinesByBytes, stampOffset, WIKI_JSONL_MAX_BYTES } from "../wiki-offset.js";
import { uploadSummary } from "../upload-summary.js";
import { log as _log } from "../../utils/debug.js";
Expand All @@ -35,6 +37,7 @@ interface WorkerConfig {
sessionsTable: string;
sessionId: string;
userName: string;
orgName: string;
project: string;
pluginVersion?: string;
tmpDir: string;
Expand Down Expand Up @@ -114,27 +117,55 @@ function cleanup(): void {

async function main(): Promise<void> {
try {
// 1. Fetch session events from sessions table
wlog("fetching session events");
const rows = await query(
// 1. Load session events. Prefer the local per-session event cache the
// capture hook appends to as the session runs — it is row-for-row
// identical to the sessions-table `message` column but avoids re-scanning
// the entire fat `message` column on the backend for THIS session on every
// periodic / session-end trigger (the dominant cold-start cost on long
// mega-sessions). Falls back to the DB whenever the cache is absent
// (session resumed on another machine), empty, or — once the offset is
// known — shorter than the offset already summarized.
const dbFetch = () => query(
`SELECT message, creation_date FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE E'${esc(`/sessions/%${cfg.sessionId}%`)}' ORDER BY creation_date ASC`
);

let usedLocalCache = false;
let rows: Record<string, unknown>[];
const cachedLines = readSessionEventCache(cfg.sessionId);
if (cachedLines && cachedLines.length > 0) {
rows = cachedLines.map(message => ({ message }));
usedLocalCache = true;
wlog(`loaded ${rows.length} events from local cache`);
} else {
wlog("fetching session events");
rows = await dbFetch();
}

if (rows.length === 0) {
wlog("no session events found — exiting");
return;
}

const jsonlLines = rows.length;
let jsonlLines = rows.length;

const pathRows = await query(
`SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
);
const jsonlServerPath = pathRows.length > 0
? pathRows[0].path as string
: `/sessions/unknown/${cfg.sessionId}.jsonl`;
// Derive the server path locally when using the cache (avoids a second
// self-session `SELECT DISTINCT path` scan); the DB branch keeps its lookup.
let jsonlServerPath: string;
if (usedLocalCache) {
jsonlServerPath = buildSessionPath(
{ userName: cfg.userName, orgName: cfg.orgName, workspaceId: cfg.workspaceId },
cfg.sessionId,
);
} else {
const pathRows = await query(
`SELECT DISTINCT path FROM "${cfg.sessionsTable}" ` +
`WHERE path LIKE '${esc(`/sessions/%${cfg.sessionId}%`)}' LIMIT 1`
);
jsonlServerPath = pathRows.length > 0
? pathRows[0].path as string
: `/sessions/unknown/${cfg.sessionId}.jsonl`;
}

// 2. Determine how many rows were already summarized (resumed session).
// The sidecar count is authoritative: finalizeSummary writes it after every
Expand Down Expand Up @@ -176,6 +207,15 @@ async function main(): Promise<void> {
if (sidecarCount > prevOffset) prevOffset = sidecarCount;
}

// Safety net: a local cache shorter than the summarized offset is an
// incomplete copy (session resumed on another machine) — re-load the full
// session from the DB so no genuinely-new rows get sliced to nothing.
if (usedLocalCache && rows.length < prevOffset) {
wlog(`local cache (${rows.length}) < summarized offset (${prevOffset}) — refetching from DB`);
rows = await dbFetch();
jsonlLines = rows.length;
}

Comment on lines +210 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale jsonlServerPath after safety-net DB refetch (mirrors cursor worker).

Identical issue to src/hooks/cursor/wiki-worker.ts lines 210-218: jsonlServerPath stays locally-derived even after this branch refetches authoritative rows from the DB — precisely in the "resumed on another machine" scenario this branch is meant to guard against.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/hermes/wiki-worker.ts` around lines 210 - 218, Update the
safety-net refetch branch in the wiki worker so that after dbFetch replaces rows
for an incomplete local cache, jsonlServerPath is also recomputed from the
authoritative DB-backed data. Keep the existing cache-length check and refetch
behavior unchanged, and mirror the corresponding cursor worker handling.

// Feed the agent only the rows added since the last summary. Reprocessing
// the full session on every run is what drives ENOBUFS / 120s-timeout
// failures on long (4000+ event) sessions — a stuck offset re-summarizes
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { resolveDirConfig } from "../dir-config.js";
import { log as _log } from "../utils/debug.js";
import { bundleDirFromImportMeta, spawnWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { tryAcquireLock, releaseLock, markSessionEnded } from "./summary-state.js";
import { pruneStaleSessionEventCaches } from "./session-event-cache.js";
import { forceSessionEndTrigger } from "../skillify/triggers.js";
import { parseTranscript } from "../notifications/transcript-parser.js";
import { appendUsageRecord } from "../notifications/usage-tracker.js";
Expand Down Expand Up @@ -67,6 +68,10 @@ async function main(): Promise<void> {
// the activity window to lapse). Independent of the wiki-worker lock below.
markSessionEnded(sessionId);

// Housekeeping: drop long-dead per-session event caches (14-day TTL). This
// session's own cache has a fresh mtime and is left intact for the worker.
try { pruneStaleSessionEventCaches(); } catch { /* best-effort */ }

const base = loadConfig();
if (!base) { log("no config"); return; }

Expand Down
Loading