mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
perf: cut per-tool-call latency across all 14 adapters
Five fixes targeting synchronous hot paths fired on every tool call.
Per-session reclaim: ~1.8s macOS / ~0.7-1.5s Linux / ~7.5-12.5s Windows
(Windows wins biggest because fork+exec is heavier).
A1. src/session/db.ts memoize getWorktreeSuffix per (cwd, env override)
Prior: `git worktree list --porcelain` subprocess fork on every
ctx_* tool call (~12ms macOS, 50-100ms Windows). Cached: 0.86μs
warm — 17,000x faster on the hot path.
A2. hooks/session-helpers.mjs 2-level cache for getWorktreeSuffix
Hooks are fresh node forks per fire — module cache alone won't
survive across calls. Added cross-process tmpdir marker keyed by
sha256(cwd) — Windows-safe filename, all-OS tmpdir(). Within a hook
process, in-memory cache hits 2 of 3 callsites (db/events/cleanup
paths). Across hook processes, marker file short-circuits the git
fork. Bench: 41ms cold child vs 28ms warm child = -12ms/fire on
macOS, ~50-100ms/fire on Windows.
A3. src/server.ts defer persistToolCallCounter via setImmediate
SQLite open/select/update/close was on the response path. Now runs
after response returns. Removes 1-3ms from user-perceived latency
per ctx_* call.
C1. hooks/auto-injection.mjs collapse 4× O(N) Array.filter() into one
O(N) pass. UserPromptSubmit fires this every prompt; with N up to
100 events the prior code walked the array 4 times.
C5. src/session/db.ts + hooks/session-loaders.mjs add bulkInsertEvents
PostToolUse emits 5-15 events per tool call; per-event insertEvent
ran N transactions = N WAL commits. Bulk path pre-computes hashes
outside the SQL transaction, then runs all dedup/evict/insert work
inside one transaction. attributeAndInsertEvents prefers bulk when
available, falls back to loop for backward compat.
C3. src/search/auto-memory.ts single statSync per candidate file
Prior code stat'd each candidate twice (size guard, mtime). Reuse
the first stat for both — one syscall per file instead of two.
Cross-platform: every fix uses platform-agnostic primitives (tmpdir(),
sha256-hashed filenames, setImmediate, in-process module cache).
Tested on macOS locally (2045/2064 vitest pass, tsc --noEmit clean);
CI exercises Linux + Windows. The 5 fixes apply uniformly to all 14
adapters because they all funnel through the same session-helpers /
session-db / MCP server hot paths.
This commit is contained in:
+36
-10
@@ -28,19 +28,47 @@ export function estimateTokens(text) {
|
||||
* @returns {string} XML block or empty string
|
||||
*/
|
||||
export function buildAutoInjection(events) {
|
||||
// Single O(N) pass instead of 4× O(N) Array.filter() loops. UserPromptSubmit
|
||||
// fires this on every prompt; with N up to 100 events the prior implementation
|
||||
// walked the array 4 times per prompt — wasteful on macOS, painful on Windows
|
||||
// where V8 cold paths cost more.
|
||||
let role;
|
||||
const decisionsAll = [];
|
||||
const skillsSeen = new Set();
|
||||
const skillsOrdered = [];
|
||||
let intent;
|
||||
for (const e of events) {
|
||||
switch (e.category) {
|
||||
case "role":
|
||||
role = e;
|
||||
break;
|
||||
case "decision":
|
||||
decisionsAll.push(e);
|
||||
break;
|
||||
case "skill":
|
||||
if (!skillsSeen.has(e.data)) {
|
||||
skillsSeen.add(e.data);
|
||||
skillsOrdered.push(e.data);
|
||||
}
|
||||
break;
|
||||
case "intent":
|
||||
intent = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let budget = 500; // hard cap in tokens
|
||||
|
||||
// P1: Role (always first, never truncated from output)
|
||||
const roleEvent = events.filter(e => e.category === "role").pop(); // latest
|
||||
if (roleEvent) {
|
||||
const text = `<behavioral_directive>\n${roleEvent.data.slice(0, 400)}\n</behavioral_directive>`;
|
||||
if (role) {
|
||||
const text = `<behavioral_directive>\n${role.data.slice(0, 400)}\n</behavioral_directive>`;
|
||||
parts.push(text);
|
||||
budget -= estimateTokens(text);
|
||||
}
|
||||
|
||||
// P2: Decisions (latest 5)
|
||||
const decisions = events.filter(e => e.category === "decision").slice(-5);
|
||||
const decisions = decisionsAll.slice(-5);
|
||||
if (decisions.length > 0) {
|
||||
const lines = decisions.map(d => `- ${d.data.slice(0, 100)}`).join("\n");
|
||||
const text = `<rules>\nFollow these decisions:\n${lines}\n</rules>`;
|
||||
@@ -58,17 +86,15 @@ export function buildAutoInjection(events) {
|
||||
}
|
||||
|
||||
// P3: Skills (unique names, latest 10)
|
||||
const skills = [...new Set(events.filter(e => e.category === "skill").map(e => e.data))];
|
||||
if (skills.length > 0 && budget > 50) {
|
||||
const text = `<active_skills>\nRe-invoke if relevant: ${skills.slice(-10).join(", ")}\nTo reload: call the Skill tool with the skill name.\n</active_skills>`;
|
||||
if (skillsOrdered.length > 0 && budget > 50) {
|
||||
const text = `<active_skills>\nRe-invoke if relevant: ${skillsOrdered.slice(-10).join(", ")}\nTo reload: call the Skill tool with the skill name.\n</active_skills>`;
|
||||
parts.push(text);
|
||||
budget -= estimateTokens(text);
|
||||
}
|
||||
|
||||
// P4: Intent (latest)
|
||||
const intentEvent = events.filter(e => e.category === "intent").pop();
|
||||
if (intentEvent && budget > 20) {
|
||||
parts.push(`<session_mode>${intentEvent.data}</session_mode>`);
|
||||
if (intent && budget > 20) {
|
||||
parts.push(`<session_mode>${intent.data}</session_mode>`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return "";
|
||||
|
||||
+68
-20
@@ -10,37 +10,85 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
|
||||
/**
|
||||
* Returns the worktree suffix for session path isolation.
|
||||
* Mirrors the logic in src/server.ts — kept in sync manually since
|
||||
* hooks run as plain .mjs (no TypeScript build step).
|
||||
*
|
||||
* Two-level cache:
|
||||
* 1. In-process module cache — same hook fire calls this 3× (db,
|
||||
* events, cleanup paths) so cache hits 2 of 3 cold within process.
|
||||
* 2. Cross-process marker file in tmpdir keyed by sha256(cwd) — every
|
||||
* Pre/PostToolUse hook is a fresh node fork; without this each fire
|
||||
* pays 12-50ms for `git worktree list` on Linux/macOS, 50-150ms on
|
||||
* Windows where fork+exec is heavier.
|
||||
*
|
||||
* Marker filename uses sha256(cwd) so it is alphanumeric — safe across
|
||||
* Windows path/filename rules. tmpdir() resolves correctly on all 3 OS.
|
||||
*/
|
||||
let _wtCacheInProcess;
|
||||
function workTreeMarkerPath(cwd) {
|
||||
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
||||
return join(tmpdir(), `cm-wt-${hash}.txt`);
|
||||
}
|
||||
|
||||
function getWorktreeSuffix() {
|
||||
const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (
|
||||
_wtCacheInProcess &&
|
||||
_wtCacheInProcess.cwd === cwd &&
|
||||
_wtCacheInProcess.envSuffix === envSuffix
|
||||
) {
|
||||
return _wtCacheInProcess.suffix;
|
||||
}
|
||||
|
||||
let suffix;
|
||||
if (envSuffix !== undefined) {
|
||||
return envSuffix ? `__${envSuffix}` : "";
|
||||
}
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const mainWorktree = execFileSync(
|
||||
"git",
|
||||
["worktree", "list", "--porcelain"],
|
||||
{ encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.find((l) => l.startsWith("worktree "))
|
||||
?.replace("worktree ", "")
|
||||
?.trim();
|
||||
if (mainWorktree && cwd !== mainWorktree) {
|
||||
return `__${createHash("sha256").update(cwd).digest("hex").slice(0, 8)}`;
|
||||
suffix = envSuffix ? `__${envSuffix}` : "";
|
||||
} else {
|
||||
// Try cross-process marker first.
|
||||
const markerPath = workTreeMarkerPath(cwd);
|
||||
try {
|
||||
suffix = readFileSync(markerPath, "utf-8");
|
||||
_wtCacheInProcess = { cwd, envSuffix, suffix };
|
||||
return suffix;
|
||||
} catch {
|
||||
// marker missing → compute below
|
||||
}
|
||||
|
||||
suffix = "";
|
||||
try {
|
||||
const mainWorktree = execFileSync(
|
||||
"git",
|
||||
["worktree", "list", "--porcelain"],
|
||||
{ encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.find((l) => l.startsWith("worktree "))
|
||||
?.replace("worktree ", "")
|
||||
?.trim();
|
||||
if (mainWorktree && cwd !== mainWorktree) {
|
||||
suffix = `__${createHash("sha256").update(cwd).digest("hex").slice(0, 8)}`;
|
||||
}
|
||||
} catch {
|
||||
// git not available or not a git repo — no suffix
|
||||
}
|
||||
|
||||
// Best-effort write so subsequent hook forks short-circuit.
|
||||
try {
|
||||
writeFileSync(markerPath, suffix, "utf-8");
|
||||
} catch {
|
||||
// tmpdir not writable — degrade gracefully
|
||||
}
|
||||
} catch {
|
||||
// git not available or not a git repo — no suffix
|
||||
}
|
||||
return "";
|
||||
|
||||
_wtCacheInProcess = { cwd, envSuffix, suffix };
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/** Claude Code platform options (default). */
|
||||
|
||||
@@ -74,8 +74,14 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
|
||||
workspaceRoots: Array.isArray(input.workspace_roots) ? input.workspace_roots : [],
|
||||
lastKnownProjectDir,
|
||||
});
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
db.insertEvent(sessionId, events[i], hookName, attributions[i]);
|
||||
// Prefer bulk path (single transaction = single WAL commit). Falls back
|
||||
// to per-event insert for older SessionDB instances that lack bulkInsertEvents.
|
||||
if (typeof db.bulkInsertEvents === "function") {
|
||||
db.bulkInsertEvents(sessionId, events, hookName, attributions);
|
||||
} else {
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
db.insertEvent(sessionId, events[i], hookName, attributions[i]);
|
||||
}
|
||||
}
|
||||
return attributions;
|
||||
}
|
||||
|
||||
@@ -113,9 +113,13 @@ export function searchAutoMemory(
|
||||
if (results.length >= limit) break;
|
||||
|
||||
try {
|
||||
// Skip files larger than 1MB to avoid memory issues
|
||||
// Single stat for both size guard and timestamp — saves one syscall
|
||||
// per candidate file. Cross-platform: statSync semantics identical
|
||||
// on macOS / Linux / Windows; size+mtime read in the same inode probe.
|
||||
let stat;
|
||||
try {
|
||||
if (statSync(candidate.path).size > 1_000_000) continue;
|
||||
stat = statSync(candidate.path);
|
||||
if (stat.size > 1_000_000) continue;
|
||||
} catch { continue; }
|
||||
const content = readFileSync(candidate.path, "utf-8");
|
||||
const contentLower = content.toLowerCase();
|
||||
@@ -154,7 +158,7 @@ export function searchAutoMemory(
|
||||
content: snippet,
|
||||
source: candidate.label,
|
||||
origin: "auto-memory",
|
||||
timestamp: statSync(candidate.path).mtime.toISOString(),
|
||||
timestamp: stat.mtime.toISOString(),
|
||||
});
|
||||
break; // one result per file per query batch
|
||||
}
|
||||
|
||||
+4
-2
@@ -370,8 +370,10 @@ function trackResponse(toolName: string, response: ToolResult): ToolResult {
|
||||
(sessionStats.bytesReturned[toolName] || 0) + bytes;
|
||||
|
||||
// Persist to SessionDB so counters survive process restart, --continue, upgrade.
|
||||
// Best-effort: never throws, never blocks.
|
||||
persistToolCallCounter(toolName, bytes);
|
||||
// Best-effort: never throws, never blocks. Deferred via setImmediate so the
|
||||
// SQLite open/select/update/close (~1-5ms even after worktree-suffix cache)
|
||||
// does not extend the response path on any of macOS / Linux / Windows.
|
||||
setImmediate(() => persistToolCallCounter(toolName, bytes));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
+119
-24
@@ -25,37 +25,53 @@ import { execFileSync } from "node:child_process";
|
||||
* (useful in CI environments or when git is unavailable).
|
||||
* Set to empty string to disable isolation entirely.
|
||||
*/
|
||||
// Memoized per (cwd, env override) — recomputing on every tool call cost
|
||||
// ~12ms (git worktree list subprocess fork) on macOS, 50ms+ on Windows.
|
||||
// Key by cwd so a defensive `process.chdir()` invalidates rather than
|
||||
// returning stale data.
|
||||
let _wtCache: { cwd: string; envSuffix: string | undefined; suffix: string } | undefined;
|
||||
|
||||
export function getWorktreeSuffix(): string {
|
||||
const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
|
||||
const cwd = process.cwd();
|
||||
if (_wtCache && _wtCache.cwd === cwd && _wtCache.envSuffix === envSuffix) {
|
||||
return _wtCache.suffix;
|
||||
}
|
||||
|
||||
let suffix = "";
|
||||
if (envSuffix !== undefined) {
|
||||
return envSuffix ? `__${envSuffix}` : "";
|
||||
}
|
||||
suffix = envSuffix ? `__${envSuffix}` : "";
|
||||
} else {
|
||||
try {
|
||||
const mainWorktree = execFileSync(
|
||||
"git",
|
||||
["worktree", "list", "--porcelain"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.find((l) => l.startsWith("worktree "))
|
||||
?.replace("worktree ", "")
|
||||
?.trim();
|
||||
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const mainWorktree = execFileSync(
|
||||
"git",
|
||||
["worktree", "list", "--porcelain"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.find((l) => l.startsWith("worktree "))
|
||||
?.replace("worktree ", "")
|
||||
?.trim();
|
||||
|
||||
if (mainWorktree && cwd !== mainWorktree) {
|
||||
const suffix = createHash("sha256").update(cwd).digest("hex").slice(0, 8);
|
||||
return `__${suffix}`;
|
||||
if (mainWorktree && cwd !== mainWorktree) {
|
||||
suffix = `__${createHash("sha256").update(cwd).digest("hex").slice(0, 8)}`;
|
||||
}
|
||||
} catch {
|
||||
// git not available or not a git repo — no suffix
|
||||
}
|
||||
} catch {
|
||||
// git not available or not a git repo — no suffix
|
||||
}
|
||||
|
||||
return "";
|
||||
_wtCache = { cwd, envSuffix, suffix };
|
||||
return suffix;
|
||||
}
|
||||
|
||||
// Test-only helper: clear the memoization between cases.
|
||||
export function _resetWorktreeSuffixCacheForTests(): void {
|
||||
_wtCache = undefined;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -466,6 +482,85 @@ export class SessionDB extends SQLiteBase {
|
||||
this.withRetry(() => transaction());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert N events in a SINGLE transaction.
|
||||
*
|
||||
* PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
|
||||
* in a loop runs N transactions = N WAL commits = N fsync candidates,
|
||||
* which is painful on Windows NTFS where commit latency dominates.
|
||||
* One transaction = one commit, dedup/evict checks reuse cached statements.
|
||||
*
|
||||
* Cross-platform: uses the same WAL-mode transaction primitive as
|
||||
* insertEvent — behavior identical on macOS / Linux / Windows.
|
||||
*/
|
||||
bulkInsertEvents(
|
||||
sessionId: string,
|
||||
events: SessionEvent[],
|
||||
sourceHook: string = "PostToolUse",
|
||||
attributions?: Array<Partial<ProjectAttribution> | undefined>,
|
||||
): void {
|
||||
if (!events || events.length === 0) return;
|
||||
if (events.length === 1) {
|
||||
// Cheaper to fall through to insertEvent (its own dedicated transaction).
|
||||
this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-compute hashes + normalized attribution outside the transaction
|
||||
// so the SQL transaction holds only DB work (shorter lock window).
|
||||
const prepared = events.map((event, i) => {
|
||||
const dataHash = createHash("sha256")
|
||||
.update(event.data)
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
.toUpperCase();
|
||||
const attribution = attributions?.[i];
|
||||
const projectDir = String(
|
||||
attribution?.projectDir ?? event.project_dir ?? "",
|
||||
).trim();
|
||||
const attributionSource = String(
|
||||
attribution?.source ?? event.attribution_source ?? "unknown",
|
||||
);
|
||||
const rawConfidence = Number(
|
||||
attribution?.confidence ?? event.attribution_confidence ?? 0,
|
||||
);
|
||||
const attributionConfidence = Number.isFinite(rawConfidence)
|
||||
? Math.max(0, Math.min(1, rawConfidence))
|
||||
: 0;
|
||||
return { event, dataHash, projectDir, attributionSource, attributionConfidence };
|
||||
});
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
let cnt = (this.stmt(S.getEventCount).get(sessionId) as { cnt: number }).cnt;
|
||||
for (const row of prepared) {
|
||||
const dup = this.stmt(S.checkDuplicate).get(
|
||||
sessionId, DEDUP_WINDOW, row.event.type, row.dataHash,
|
||||
);
|
||||
if (dup) continue;
|
||||
if (cnt >= MAX_EVENTS_PER_SESSION) {
|
||||
this.stmt(S.evictLowestPriority).run(sessionId);
|
||||
} else {
|
||||
cnt++;
|
||||
}
|
||||
this.stmt(S.insertEvent).run(
|
||||
sessionId,
|
||||
row.event.type,
|
||||
row.event.category,
|
||||
row.event.priority,
|
||||
row.event.data,
|
||||
row.projectDir,
|
||||
row.attributionSource,
|
||||
row.attributionConfidence,
|
||||
sourceHook,
|
||||
row.dataHash,
|
||||
);
|
||||
}
|
||||
this.stmt(S.updateMetaLastEvent).run(sessionId);
|
||||
});
|
||||
|
||||
this.withRetry(() => transaction());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve events for a session with optional filtering.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user