mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
feat: session events pre-indexing with zero context injection
Replace full XML injection (~2700 tokens) with file-based auto-indexing + ~275 token directive (90% context reduction). Session events are written as structured markdown to disk, auto-indexed into FTS5 by the MCP server, and searchable on-demand via search() tool. Key changes: - hooks/sessionstart.mjs: rewrite compact/resume to write events markdown file + inject only a lightweight directive with summary table - packages/core/src/server.ts: add maybeIndexSessionEvents() to auto-detect and index *-events.md files on every getStore() call - hooks/session-helpers.mjs: add getSessionEventsPath() export - packages/session/src/extract.ts: increase rule_content truncation 500→5000 to capture full CLAUDE.md content - hooks/sessionstart.mjs: proactive CLAUDE.md capture at startup (reads 3 known paths) since Claude Code loads them invisibly as system context - hooks/sessionstart.mjs: downgrade rule_content headings (H1→H4, H2→H5) to preserve FTS5 chunk hierarchy under ## Project Rules - CONTRIBUTING.md: add architecture overview, server.bundle.mjs gotcha, rebuild guide, key files table - Delete stale server.bundle.mjs (was shadowing build/server.js) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2ffccbee3a
commit
a3c579e4e1
+78
-11
@@ -12,6 +12,51 @@ I genuinely love open source and I'm grateful to have you here. Don't hesitate t
|
||||
|
||||
This guide covers the local development workflow so you can test changes in a live Claude Code session before submitting a PR.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
context-mode is a monorepo with three packages:
|
||||
|
||||
```
|
||||
packages/
|
||||
shared/ → SQLite base class, types, truncation utils (imported by core + session)
|
||||
core/ → MCP server, executor, store, security, runtime, CLI (builds to build/)
|
||||
session/ → Session event DB, snapshot builder, extractors (builds to packages/session/dist/)
|
||||
hooks/ → Plain JS hooks (.mjs) — loaded fresh on each invocation, no build needed
|
||||
```
|
||||
|
||||
**Build output is flat**: `packages/core/src/server.ts` compiles to `build/server.js` (not `build/core/server.js`). This is intentional — `start.mjs` expects `build/server.js` at the repo root.
|
||||
|
||||
> **Critical for local dev:** `start.mjs` loads `server.bundle.mjs` (CI-built) over `build/server.js` if the bundle exists (line 79). **Delete `server.bundle.mjs` in your local clone** or your `build/server.js` changes will never be loaded:
|
||||
> ```bash
|
||||
> rm server.bundle.mjs # forces start.mjs to use build/server.js
|
||||
> ```
|
||||
> The bundle is committed by CI for production — don't gitignore it, just delete it locally.
|
||||
|
||||
### Session Continuity Architecture
|
||||
|
||||
Session events flow through a two-database system:
|
||||
|
||||
1. **SessionDB** (persistent, per-project): `~/.claude/context-mode/sessions/<hash>.db`
|
||||
- PostToolUse hook captures events in real-time
|
||||
- PreCompact hook builds resume snapshots
|
||||
- UserPromptSubmit hook captures user prompts
|
||||
|
||||
2. **ContentStore** (ephemeral, per-process): `/tmp/context-mode-<PID>.db`
|
||||
- FTS5 full-text search index for tool outputs
|
||||
- Auto-indexes session events file written by SessionStart hook
|
||||
- Dies when MCP server process exits
|
||||
|
||||
**Session restore flow** (compact/resume):
|
||||
```
|
||||
SessionStart hook → reads SessionDB → writes events as markdown file
|
||||
→ injects ~275 token directive (summary + search queries)
|
||||
MCP server → detects markdown file on next getStore() call
|
||||
→ auto-indexes into FTS5 → deletes file
|
||||
LLM → searches source:"session-events" for details on demand
|
||||
```
|
||||
|
||||
Raw session events are **never injected into context**. Only a compact summary table + search queries are injected. The LLM searches for details via the existing `search()` MCP tool.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed
|
||||
@@ -26,9 +71,11 @@ This guide covers the local development workflow so you can test changes in a li
|
||||
git clone https://github.com/mksglu/claude-context-mode.git
|
||||
cd claude-context-mode
|
||||
npm install
|
||||
npm run build
|
||||
npm run build # produces build/, packages/shared/dist/, packages/session/dist/
|
||||
```
|
||||
|
||||
> **Critical:** `npm run build` must succeed before testing. It uses `tsc -b` (project references) to build all three packages in dependency order: shared → core → session. If `packages/session/dist/` or `packages/shared/dist/` are missing, session continuity hooks will fail with `SessionStart hook error`.
|
||||
|
||||
### 2. Symlink the cache to your local clone
|
||||
|
||||
Claude Code's plugin system manages `~/.claude/plugins/installed_plugins.json` and **will revert manual edits on restart**. The reliable approach is to replace the cache directory with a symlink to your local clone.
|
||||
@@ -56,9 +103,11 @@ Replace `/path/to/your/clone/claude-context-mode` with your actual local path.
|
||||
|
||||
> **Why symlink?** The plugin system overwrites `installed_plugins.json` on every session start, reverting any manual path changes. A symlink lets the plugin system keep its managed path while the actual code resolves to your local clone.
|
||||
|
||||
> **Critical:** The symlink must point to the root of your clone (where `hooks/`, `build/`, and `packages/` all live). Hooks registered in `hooks.json` use `${CLAUDE_PLUGIN_ROOT}` which resolves to this directory.
|
||||
|
||||
### 3. Update PreToolUse hook in settings
|
||||
|
||||
The symlink in step 2 ensures `hooks.json` (which registers PostToolUse, PreCompact, and SessionStart) resolves to your local clone via the plugin system. You only need to override PreToolUse in `~/.claude/settings.json` since its broader matcher is needed for dev mode:
|
||||
The symlink in step 2 ensures `hooks.json` (which registers PostToolUse, PreCompact, SessionStart, and UserPromptSubmit) resolves to your local clone via the plugin system. You only need to override PreToolUse in `~/.claude/settings.json` since its broader matcher is needed for dev mode:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -80,7 +129,7 @@ The symlink in step 2 ensures `hooks.json` (which registers PostToolUse, PreComp
|
||||
|
||||
Replace `/path/to/your/clone/claude-context-mode` with your actual local path.
|
||||
|
||||
> **Important:** Do NOT add PostToolUse, PreCompact, or SessionStart to `settings.json` — they are already registered in `hooks.json` and the symlink makes them resolve to your local clone. Adding them to both causes double invocations, split session IDs, and SQLite locking errors.
|
||||
> **Important:** Do NOT add PostToolUse, PreCompact, SessionStart, or UserPromptSubmit to `settings.json` — they are already registered in `hooks.json` and the symlink makes them resolve to your local clone. Adding them to both causes double invocations, split session IDs, and SQLite locking errors.
|
||||
|
||||
### 4. Bump the version for verification
|
||||
|
||||
@@ -88,7 +137,7 @@ Change the version in your local clone to something recognizable:
|
||||
|
||||
```bash
|
||||
# In package.json: "version": "0.9.22-dev"
|
||||
# In src/server.ts: const VERSION = "0.9.22-dev";
|
||||
# In packages/core/src/server.ts: const VERSION = "0.9.22-dev";
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
@@ -138,7 +187,7 @@ Then revert hooks in `~/.claude/settings.json` and restart Claude Code.
|
||||
### Build and test your changes
|
||||
|
||||
```bash
|
||||
# TypeScript compilation
|
||||
# TypeScript compilation (project references: shared → core → session)
|
||||
npm run build
|
||||
|
||||
# Run all tests (parallel via Vitest)
|
||||
@@ -151,15 +200,33 @@ npm run typecheck
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### See changes in Claude Code
|
||||
### What needs rebuild?
|
||||
|
||||
After modifying source code:
|
||||
| Changed | Rebuild needed? | Why |
|
||||
|---------|:-:|---|
|
||||
| `hooks/*.mjs` | No | Plain JS, loaded fresh each invocation |
|
||||
| `packages/core/src/*` | Yes | Compiles to `build/` (MCP server, executor, store) |
|
||||
| `packages/shared/src/*` | Yes | Compiles to `packages/shared/dist/`, imported by core + session |
|
||||
| `packages/session/src/*` | Yes | Compiles to `packages/session/dist/`, imported by hooks |
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
After rebuilding, restart your Claude Code session. The MCP server reloads on session start.
|
||||
|
||||
Then restart your Claude Code session. The MCP server reloads on session start.
|
||||
> **Tip:** If you only changed hook files (`hooks/*.mjs`), just restart Claude Code — no rebuild needed. Hooks are plain JS loaded fresh on each invocation.
|
||||
|
||||
### Key files to know
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `packages/core/src/server.ts` | MCP server, tool handlers, auto-indexing of session events |
|
||||
| `packages/core/src/store.ts` | FTS5 content store (index, search, chunking) |
|
||||
| `packages/core/src/executor.ts` | Polyglot code executor (JS, Python, Shell, etc.) |
|
||||
| `packages/session/src/db.ts` | SessionDB — persistent session event storage |
|
||||
| `packages/session/src/extract.ts` | Event extractors for PostToolUse hook |
|
||||
| `hooks/sessionstart.mjs` | Session lifecycle (startup/compact/resume/clear) |
|
||||
| `hooks/posttooluse.mjs` | Real-time event capture from tool calls |
|
||||
| `hooks/precompact.mjs` | Resume snapshot builder (fires before compact) |
|
||||
| `hooks/pretooluse.mjs` | Tool routing + context window protection |
|
||||
| `hooks/session-helpers.mjs` | Shared utilities (stdin reader, session ID, DB paths) |
|
||||
|
||||
## TDD Workflow
|
||||
|
||||
|
||||
@@ -48,3 +48,16 @@ export function getSessionDBPath() {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return join(dir, `${hash}.db`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the per-project session events file path.
|
||||
* Used by sessionstart hook (write) and MCP server (read + auto-index).
|
||||
* Path: ~/.claude/context-mode/sessions/<SHA256(CLAUDE_PROJECT_DIR)[:16]>-events.md
|
||||
*/
|
||||
export function getSessionEventsPath() {
|
||||
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const hash = createHash("sha256").update(projectDir).digest("hex").slice(0, 16);
|
||||
const dir = join(homedir(), ".claude", "context-mode", "sessions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return join(dir, `${hash}-events.md`);
|
||||
}
|
||||
|
||||
+216
-199
@@ -14,78 +14,190 @@
|
||||
*/
|
||||
|
||||
import { ROUTING_BLOCK } from "./routing-block.mjs";
|
||||
import { readStdin, getSessionId, getSessionDBPath } from "./session-helpers.mjs";
|
||||
import { readStdin, getSessionId, getSessionDBPath, getSessionEventsPath } from "./session-helpers.mjs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// Resolve absolute path for imports
|
||||
const HOOK_DIR = new URL(".", import.meta.url).pathname;
|
||||
const PKG_SESSION = join(HOOK_DIR, "..", "packages", "session", "dist");
|
||||
|
||||
const raw = await readStdin();
|
||||
let additionalContext = ROUTING_BLOCK;
|
||||
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const input = JSON.parse(raw);
|
||||
const source = input.source ?? "startup";
|
||||
|
||||
// ── Helper: build structured session knowledge injection for the LLM ──
|
||||
// The LLM doesn't care about counts — it cares about WHAT data was preserved.
|
||||
// ZERO truncation: full event data flows through. The LLM indexes it into
|
||||
// context-mode for persistent searchable access across compacts.
|
||||
function buildSessionKnowledge(source, events, snapshot, stats) {
|
||||
const isCompact = source === "compact";
|
||||
|
||||
// ── Group events by category — no truncation, no limits ──
|
||||
// ── Helper: group events and extract metadata ──
|
||||
function groupEvents(events) {
|
||||
const grouped = {};
|
||||
let lastPrompt = "";
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.category === "prompt") {
|
||||
// Always keep the most recent user prompt
|
||||
lastPrompt = ev.data;
|
||||
continue;
|
||||
}
|
||||
if (!grouped[ev.category]) grouped[ev.category] = [];
|
||||
grouped[ev.category].push(ev);
|
||||
}
|
||||
|
||||
// ── Deduplicate file basenames for quick reference ──
|
||||
const fileNames = new Set();
|
||||
for (const ev of (grouped.file || [])) {
|
||||
const path = ev.data.includes(" in ") ? ev.data.split(" in ").pop() : ev.data;
|
||||
const base = path?.split("/").pop()?.trim();
|
||||
if (base && !base.includes("*")) fileNames.add(base);
|
||||
}
|
||||
return { grouped, lastPrompt, fileNames };
|
||||
}
|
||||
|
||||
let block = `\n<session_knowledge source="${isCompact ? "compact" : "continue"}" events="${events.length}">`;
|
||||
// ── Write session events as markdown to disk for MCP server auto-indexing ──
|
||||
// Structured with H2 headings per category — optimal for FTS5 chunking.
|
||||
// The MCP server's getStore() detects this file and indexes it automatically.
|
||||
function writeSessionEventsFile(events, stats) {
|
||||
const eventsPath = getSessionEventsPath();
|
||||
const { grouped, lastPrompt, fileNames } = groupEvents(events);
|
||||
|
||||
// ── Behavioral directives ──
|
||||
block += `\n <behavioral_directives>`;
|
||||
// ── Build marketing summary lines from actual data ──
|
||||
const summaryLines = [];
|
||||
if (fileNames.size > 0) summaryLines.push(`${fileNames.size} files tracked (${[...fileNames].slice(0, 5).join(", ")}${fileNames.size > 5 ? "..." : ""})`);
|
||||
if (grouped.rule?.length > 0) summaryLines.push(`CLAUDE.md rules preserved`);
|
||||
if (grouped.decision?.length > 0) summaryLines.push(`${grouped.decision.length} user decision(s) remembered`);
|
||||
if (grouped.task?.length > 0) summaryLines.push(`${grouped.task.length} task(s) in progress`);
|
||||
if (grouped.git?.length > 0) summaryLines.push(`git state tracked`);
|
||||
if (grouped.error?.length > 0) summaryLines.push(`${grouped.error.length} error(s) logged`);
|
||||
if (lastPrompt) summaryLines.push(`last request preserved`);
|
||||
const lines = [];
|
||||
lines.push("# Session Resume");
|
||||
lines.push("");
|
||||
lines.push(`Events: ${events.length} | Timestamp: ${new Date().toISOString()}`);
|
||||
lines.push("");
|
||||
|
||||
if (isCompact) {
|
||||
block += `\n Your context was compacted. The data below is your prior session state, preserved by context-mode.`;
|
||||
block += `\n IMPORTANT: This data IS your memory. Treat it as ground truth for this session.`;
|
||||
block += `\n MANDATORY FIRST ACTION: Index the <session_events> block below into context-mode for persistent searchable access:`;
|
||||
block += `\n mcp__plugin_context-mode_context-mode__index(source: "session-resume", content: <session_events content>)`;
|
||||
block += `\n Then continue the user's last request.`;
|
||||
} else {
|
||||
// source === "resume" (--continue)
|
||||
block += `\n Previous session knowledge loaded via --continue, preserved by context-mode.`;
|
||||
block += `\n Use as context. Verify file state before editing.`;
|
||||
block += `\n RECOMMENDED: Index the <session_events> block into context-mode for searchable access:`;
|
||||
block += `\n mcp__plugin_context-mode_context-mode__index(source: "previous-session", content: <session_events content>)`;
|
||||
if (fileNames.size > 0) {
|
||||
lines.push("## Active Files");
|
||||
lines.push("");
|
||||
for (const name of fileNames) lines.push(`- ${name}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── User-facing summary — LLM MUST display this table ──
|
||||
if (grouped.rule?.length > 0) {
|
||||
lines.push("## Project Rules");
|
||||
lines.push("");
|
||||
for (const ev of grouped.rule) {
|
||||
if (ev.type === "rule_content") {
|
||||
// Downgrade headings so they nest under ## Project Rules
|
||||
// H1 → H4, H2 → H5, H3 → H6 (prevents FTS5 chunk hierarchy breakage)
|
||||
const downgraded = ev.data.replace(/^(#{1,3}) /gm, (_, hashes) => "#".repeat(hashes.length + 3) + " ");
|
||||
lines.push(downgraded);
|
||||
lines.push("");
|
||||
} else {
|
||||
lines.push(`- ${ev.data}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.task?.length > 0) {
|
||||
lines.push("## Tasks In Progress");
|
||||
lines.push("");
|
||||
for (const ev of grouped.task) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.decision?.length > 0) {
|
||||
lines.push("## User Decisions");
|
||||
lines.push("");
|
||||
for (const ev of grouped.decision) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.git?.length > 0) {
|
||||
lines.push("## Git Operations");
|
||||
lines.push("");
|
||||
for (const ev of grouped.git) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.env?.length > 0 || grouped.cwd?.length > 0) {
|
||||
lines.push("## Environment");
|
||||
lines.push("");
|
||||
if (grouped.cwd?.length > 0) {
|
||||
lines.push(`- cwd: ${grouped.cwd[grouped.cwd.length - 1].data}`);
|
||||
}
|
||||
for (const ev of (grouped.env || [])) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.error?.length > 0) {
|
||||
lines.push("## Errors Encountered");
|
||||
lines.push("");
|
||||
for (const ev of grouped.error) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.mcp?.length > 0) {
|
||||
const toolCounts = {};
|
||||
for (const ev of grouped.mcp) {
|
||||
const tool = ev.data.split(":")[0].trim();
|
||||
toolCounts[tool] = (toolCounts[tool] || 0) + 1;
|
||||
}
|
||||
lines.push("## MCP Tool Usage");
|
||||
lines.push("");
|
||||
for (const [tool, count] of Object.entries(toolCounts)) {
|
||||
lines.push(`- ${tool}: ${count} calls`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.subagent?.length > 0) {
|
||||
lines.push("## Subagent Tasks");
|
||||
lines.push("");
|
||||
for (const ev of grouped.subagent) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.skill?.length > 0) {
|
||||
const uniqueSkills = new Set(grouped.skill.map(e => e.data));
|
||||
lines.push("## Active Skills");
|
||||
lines.push("");
|
||||
lines.push(`- ${[...uniqueSkills].join(", ")}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.intent?.length > 0) {
|
||||
lines.push("## Session Intent");
|
||||
lines.push("");
|
||||
lines.push(`- ${grouped.intent[grouped.intent.length - 1].data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.role?.length > 0) {
|
||||
lines.push("## User Role");
|
||||
lines.push("");
|
||||
lines.push(`- ${grouped.role[grouped.role.length - 1].data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (grouped.data?.length > 0) {
|
||||
lines.push("## Data References");
|
||||
lines.push("");
|
||||
for (const ev of grouped.data) lines.push(`- ${ev.data}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (lastPrompt) {
|
||||
lines.push("## Last User Prompt");
|
||||
lines.push("");
|
||||
lines.push(lastPrompt);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
writeFileSync(eventsPath, lines.join("\n"), "utf-8");
|
||||
return { grouped, lastPrompt, fileNames };
|
||||
}
|
||||
|
||||
// ── Build compact directive — summary table + search queries only ──
|
||||
// Raw event data is NOT injected. It's in the markdown file for FTS5 auto-indexing.
|
||||
function buildSessionDirective(source, eventMeta) {
|
||||
const { grouped, lastPrompt, fileNames } = eventMeta;
|
||||
const isCompact = source === "compact";
|
||||
|
||||
let block = `\n<session_knowledge source="${isCompact ? "compact" : "continue"}" indexed="true">`;
|
||||
|
||||
block += `\n <status>Session events pre-indexed into context-mode (source: "session-events"). Raw data is searchable — do NOT call index().</status>`;
|
||||
|
||||
// ── Summary table — LLM MUST display this ──
|
||||
const tableRows = [];
|
||||
if (fileNames.size > 0) {
|
||||
const preview = [...fileNames].slice(0, 5).join(", ");
|
||||
@@ -97,20 +209,17 @@ try {
|
||||
}
|
||||
if (grouped.decision?.length > 0) {
|
||||
const dPreview = grouped.decision.slice(0, 2).map(e => e.data.substring(0, 50)).join("; ");
|
||||
tableRows.push(`| Your decisions | ${grouped.decision.length} | ${dPreview} |`);
|
||||
tableRows.push(`| Decisions | ${grouped.decision.length} | ${dPreview} |`);
|
||||
}
|
||||
if (grouped.task?.length > 0) {
|
||||
tableRows.push(`| Tasks in progress | ${grouped.task.length} | Continuing where you left off |`);
|
||||
tableRows.push(`| Tasks | ${grouped.task.length} | In progress |`);
|
||||
}
|
||||
if (grouped.git?.length > 0) {
|
||||
const ops = [...new Set(grouped.git.map(e => e.data))].slice(0, 3).join(", ");
|
||||
tableRows.push(`| Git state | ${grouped.git.length} | ${ops} |`);
|
||||
}
|
||||
if (grouped.error?.length > 0) {
|
||||
tableRows.push(`| Errors caught | ${grouped.error.length} | Being monitored |`);
|
||||
}
|
||||
if (grouped.env?.length > 0) {
|
||||
tableRows.push(`| Environment | ${grouped.env.length} | Setup preserved |`);
|
||||
tableRows.push(`| Errors | ${grouped.error.length} | Monitored |`);
|
||||
}
|
||||
if (lastPrompt) {
|
||||
const promptPreview = lastPrompt.length > 60 ? lastPrompt.substring(0, 57) + "..." : lastPrompt;
|
||||
@@ -118,7 +227,8 @@ try {
|
||||
}
|
||||
|
||||
if (tableRows.length > 0) {
|
||||
block += `\n DISPLAY TO USER: Before doing anything else, show this status table to the user exactly as formatted:`;
|
||||
block += `\n <display_table>`;
|
||||
block += `\n MANDATORY OUTPUT — Display this table as your FIRST output:`;
|
||||
block += `\n ---`;
|
||||
block += `\n **context-mode** — session ${isCompact ? "restored" : "loaded"}`;
|
||||
block += `\n `;
|
||||
@@ -129,164 +239,47 @@ try {
|
||||
}
|
||||
if (isCompact) {
|
||||
block += `\n `;
|
||||
block += `\n *Context was compacted — all session knowledge restored. Continuing seamlessly.*`;
|
||||
block += `\n *Context compacted — session knowledge restored.*`;
|
||||
} else {
|
||||
block += `\n `;
|
||||
block += `\n *Previous session loaded via --continue. Session knowledge preserved by context-mode.*`;
|
||||
block += `\n *Previous session loaded via --continue.*`;
|
||||
}
|
||||
block += `\n ---`;
|
||||
block += `\n </display_table>`;
|
||||
}
|
||||
block += `\n </behavioral_directives>`;
|
||||
|
||||
// ── Last user prompt — the LLM must continue from here ──
|
||||
// ── Last user prompt — always in context (critical for compact) ──
|
||||
if (lastPrompt) {
|
||||
block += `\n <last_user_prompt>`;
|
||||
block += `\n ${lastPrompt}`;
|
||||
block += `\n </last_user_prompt>`;
|
||||
block += `\n <last_user_prompt>${lastPrompt}</last_user_prompt>`;
|
||||
if (isCompact) {
|
||||
block += `\n <continue_from>Continue working on the request above. Do NOT ask the user to repeat themselves.</continue_from>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full session events grouped by category — ZERO truncation ──
|
||||
block += `\n <session_events>`;
|
||||
// ── Mandatory search — LLM must restore working memory from FTS5 ──
|
||||
const queries = [];
|
||||
if (fileNames.size > 0) queries.push("active files tracked");
|
||||
if (grouped.task?.length > 0) queries.push("tasks in progress");
|
||||
if (grouped.decision?.length > 0) queries.push("user decisions preferences");
|
||||
if (grouped.rule?.length > 0) queries.push("project rules CLAUDE.md");
|
||||
if (grouped.git?.length > 0) queries.push("git operations branch commit");
|
||||
if (grouped.error?.length > 0) queries.push("errors encountered");
|
||||
if (!queries.length) queries.push("session resume overview");
|
||||
|
||||
// Files (P1)
|
||||
if (fileNames.size > 0) {
|
||||
block += `\n <active_files>`;
|
||||
for (const name of fileNames) {
|
||||
block += `\n <file>${name}</file>`;
|
||||
}
|
||||
block += `\n </active_files>`;
|
||||
}
|
||||
|
||||
// Rules + content (P1)
|
||||
if (grouped.rule?.length > 0) {
|
||||
block += `\n <rules>`;
|
||||
for (const ev of grouped.rule) {
|
||||
if (ev.type === "rule_content") {
|
||||
block += `\n <rule_content>${ev.data}</rule_content>`;
|
||||
} else {
|
||||
block += `\n <rule_path>${ev.data}</rule_path>`;
|
||||
}
|
||||
}
|
||||
block += `\n </rules>`;
|
||||
}
|
||||
|
||||
// Tasks (P1)
|
||||
if (grouped.task?.length > 0) {
|
||||
block += `\n <tasks>`;
|
||||
for (const ev of grouped.task) {
|
||||
block += `\n <task>${ev.data}</task>`;
|
||||
}
|
||||
block += `\n </tasks>`;
|
||||
}
|
||||
|
||||
// Decisions (P2)
|
||||
if (grouped.decision?.length > 0) {
|
||||
block += `\n <decisions>`;
|
||||
for (const ev of grouped.decision) {
|
||||
block += `\n <decision>${ev.data}</decision>`;
|
||||
}
|
||||
block += `\n </decisions>`;
|
||||
}
|
||||
|
||||
// Git (P2)
|
||||
if (grouped.git?.length > 0) {
|
||||
block += `\n <git_operations>`;
|
||||
for (const ev of grouped.git) {
|
||||
block += `\n <op>${ev.data}</op>`;
|
||||
}
|
||||
block += `\n </git_operations>`;
|
||||
}
|
||||
|
||||
// Environment (P2)
|
||||
if (grouped.env?.length > 0) {
|
||||
block += `\n <environment>`;
|
||||
for (const ev of grouped.env) {
|
||||
block += `\n <env>${ev.data}</env>`;
|
||||
}
|
||||
block += `\n </environment>`;
|
||||
}
|
||||
|
||||
// Working directory (P2)
|
||||
if (grouped.cwd?.length > 0) {
|
||||
const lastCwd = grouped.cwd[grouped.cwd.length - 1];
|
||||
block += `\n <cwd>${lastCwd.data}</cwd>`;
|
||||
}
|
||||
|
||||
// Errors (P2)
|
||||
if (grouped.error?.length > 0) {
|
||||
block += `\n <errors>`;
|
||||
for (const ev of grouped.error) {
|
||||
block += `\n <error>${ev.data}</error>`;
|
||||
}
|
||||
block += `\n </errors>`;
|
||||
}
|
||||
|
||||
// MCP tools (P3)
|
||||
if (grouped.mcp?.length > 0) {
|
||||
const toolCounts = {};
|
||||
for (const ev of grouped.mcp) {
|
||||
const tool = ev.data.split(":")[0].trim();
|
||||
toolCounts[tool] = (toolCounts[tool] || 0) + 1;
|
||||
}
|
||||
block += `\n <mcp_tools>`;
|
||||
for (const [tool, count] of Object.entries(toolCounts)) {
|
||||
block += `\n <tool name="${tool}" calls="${count}" />`;
|
||||
}
|
||||
block += `\n </mcp_tools>`;
|
||||
}
|
||||
|
||||
// Subagents (P3)
|
||||
if (grouped.subagent?.length > 0) {
|
||||
block += `\n <subagents>`;
|
||||
for (const ev of grouped.subagent) {
|
||||
block += `\n <task>${ev.data}</task>`;
|
||||
}
|
||||
block += `\n </subagents>`;
|
||||
}
|
||||
|
||||
// Skills (P3)
|
||||
if (grouped.skill?.length > 0) {
|
||||
const uniqueSkills = new Set(grouped.skill.map(e => e.data));
|
||||
block += `\n <skills>${[...uniqueSkills].join(", ")}</skills>`;
|
||||
}
|
||||
|
||||
// Intent (P3)
|
||||
if (grouped.intent?.length > 0) {
|
||||
const lastIntent = grouped.intent[grouped.intent.length - 1];
|
||||
block += `\n <intent>${lastIntent.data}</intent>`;
|
||||
}
|
||||
|
||||
// Role (P3)
|
||||
if (grouped.role?.length > 0) {
|
||||
const lastRole = grouped.role[grouped.role.length - 1];
|
||||
block += `\n <role>${lastRole.data}</role>`;
|
||||
}
|
||||
|
||||
// User data references (P4)
|
||||
if (grouped.data?.length > 0) {
|
||||
block += `\n <data_refs>`;
|
||||
for (const ev of grouped.data) {
|
||||
block += `\n <ref>${ev.data}</ref>`;
|
||||
}
|
||||
block += `\n </data_refs>`;
|
||||
}
|
||||
|
||||
block += `\n </session_events>`;
|
||||
|
||||
// ── Session metadata ──
|
||||
if (stats?.compact_count > 0) {
|
||||
block += `\n <session_meta compact_count="${stats.compact_count}" />`;
|
||||
}
|
||||
block += `\n <mandatory_actions>`;
|
||||
block += `\n BEFORE doing anything else:`;
|
||||
block += `\n 1. Display the summary table to the user.`;
|
||||
block += `\n 2. Restore working memory — search session events:`;
|
||||
block += `\n mcp__plugin_context-mode_context-mode__search(queries: ${JSON.stringify(queries)}, source: "session-events")`;
|
||||
block += `\n 3. Then continue the user's last request.`;
|
||||
block += `\n </mandatory_actions>`;
|
||||
|
||||
block += `\n</session_knowledge>`;
|
||||
return block;
|
||||
}
|
||||
|
||||
if (source === "compact") {
|
||||
// Session was compacted — inject structured resume context
|
||||
// Session was compacted — write events to file for auto-indexing, inject directive only
|
||||
const { SessionDB } = await import(join(PKG_SESSION, "db.js"));
|
||||
const dbPath = getSessionDBPath();
|
||||
const db = new SessionDB({ dbPath });
|
||||
@@ -295,21 +288,19 @@ try {
|
||||
const stats = db.getSessionStats(sessionId);
|
||||
const events = db.getEvents(sessionId);
|
||||
|
||||
let snapshot = null;
|
||||
if (resume && !resume.consumed) {
|
||||
snapshot = resume.snapshot;
|
||||
db.markResumeConsumed(sessionId);
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
additionalContext += buildSessionKnowledge("compact", events, snapshot, stats);
|
||||
const eventMeta = writeSessionEventsFile(events, stats);
|
||||
additionalContext += buildSessionDirective("compact", eventMeta);
|
||||
}
|
||||
|
||||
db.close();
|
||||
} else if (source === "resume") {
|
||||
// User used --continue — inject previous session knowledge
|
||||
// User used --continue — write previous session events to file, inject directive only
|
||||
const { SessionDB } = await import(join(PKG_SESSION, "db.js"));
|
||||
const { buildResumeSnapshot } = await import(join(PKG_SESSION, "snapshot.js"));
|
||||
const dbPath = getSessionDBPath();
|
||||
const db = new SessionDB({ dbPath });
|
||||
|
||||
@@ -326,28 +317,54 @@ try {
|
||||
const events = db.getEvents(prevId);
|
||||
|
||||
if (events.length > 0) {
|
||||
const resume = db.getResume(prevId);
|
||||
const snapshot = resume?.snapshot || buildResumeSnapshot(events, {
|
||||
compactCount: recentSession.compact_count ?? 0,
|
||||
});
|
||||
const stats = db.getSessionStats(prevId);
|
||||
|
||||
additionalContext += buildSessionKnowledge("resume", events, snapshot, stats);
|
||||
const eventMeta = writeSessionEventsFile(events, db.getSessionStats(prevId));
|
||||
additionalContext += buildSessionDirective("resume", eventMeta);
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
} else if (source === "startup") {
|
||||
// Fresh session (no --continue) — clean slate, no injection.
|
||||
// Fresh session (no --continue) — clean slate, capture CLAUDE.md rules.
|
||||
const { SessionDB } = await import(join(PKG_SESSION, "db.js"));
|
||||
const dbPath = getSessionDBPath();
|
||||
const db = new SessionDB({ dbPath });
|
||||
try { unlinkSync(getSessionEventsPath()); } catch { /* no stale file */ }
|
||||
db.cleanupOldSessions();
|
||||
|
||||
// Proactively capture CLAUDE.md files — Claude Code loads them as system
|
||||
// context at startup, invisible to PostToolUse hooks. We read them from
|
||||
// disk so they survive compact/resume via the session events pipeline.
|
||||
const sessionId = getSessionId(input);
|
||||
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const claudeMdPaths = [
|
||||
join(homedir(), ".claude", "CLAUDE.md"),
|
||||
join(projectDir, "CLAUDE.md"),
|
||||
join(projectDir, ".claude", "CLAUDE.md"),
|
||||
];
|
||||
for (const p of claudeMdPaths) {
|
||||
try {
|
||||
const content = readFileSync(p, "utf-8");
|
||||
if (content.trim()) {
|
||||
db.insertEvent(sessionId, { type: "rule", category: "rule", data: p, priority: 1 });
|
||||
db.insertEvent(sessionId, { type: "rule_content", category: "rule", data: content, priority: 1 });
|
||||
}
|
||||
} catch { /* file doesn't exist — skip */ }
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
// "clear" — no action needed
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Session continuity is best-effort — never block session start
|
||||
try {
|
||||
const { appendFileSync } = await import("node:fs");
|
||||
const { join: pjoin } = await import("node:path");
|
||||
const { homedir } = await import("node:os");
|
||||
appendFileSync(
|
||||
pjoin(homedir(), ".claude", "context-mode", "sessionstart-debug.log"),
|
||||
`[${new Date().toISOString()}] ${err?.message || err}\n${err?.stack || ""}\n`,
|
||||
);
|
||||
} catch { /* ignore logging failure */ }
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
@@ -38,8 +38,33 @@ const executor = new PolyglotExecutor({
|
||||
|
||||
// Lazy singleton — no DB overhead unless index/search is used
|
||||
let _store: ContentStore | null = null;
|
||||
|
||||
/**
|
||||
* Auto-index session events files written by SessionStart hook.
|
||||
* Scans ~/.claude/context-mode/sessions/ for *-events.md files.
|
||||
* CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks —
|
||||
* so we glob-scan instead of computing a specific hash.
|
||||
* Files are consumed (deleted) after indexing to prevent double-indexing.
|
||||
* Called on every getStore() — readdirSync is sub-millisecond when no files match.
|
||||
*/
|
||||
function maybeIndexSessionEvents(store: ContentStore): void {
|
||||
try {
|
||||
const sessionsDir = join(homedir(), ".claude", "context-mode", "sessions");
|
||||
if (!existsSync(sessionsDir)) return;
|
||||
const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
|
||||
for (const file of files) {
|
||||
const filePath = join(sessionsDir, file);
|
||||
try {
|
||||
store.index({ path: filePath, source: "session-events" });
|
||||
unlinkSync(filePath);
|
||||
} catch { /* best-effort per file */ }
|
||||
}
|
||||
} catch { /* best-effort — session continuity never blocks tools */ }
|
||||
}
|
||||
|
||||
function getStore(): ContentStore {
|
||||
if (!_store) _store = new ContentStore();
|
||||
maybeIndexSessionEvents(_store);
|
||||
return _store;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function extractFileAndRule(input: HookInput): SessionEvent[] {
|
||||
events.push({
|
||||
type: "rule_content",
|
||||
category: "rule",
|
||||
data: truncate(tool_response, 500),
|
||||
data: truncate(tool_response, 5000),
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user