mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-08 18:33:28 +08:00
feat(skill): ck — context-keeper v2, persistent per-project memory
Adds the ck (Context Keeper) skill — deterministic Node.js scripts that give Claude Code persistent, per-project memory across sessions. Architecture: - commands/ — 8 Node.js scripts handle all command logic (init, save, resume, info, list, forget, migrate, shared). Claude calls scripts and displays output — no LLM interpretation of command logic. - hooks/session-start.mjs — injects ~100 token compact summary on session start (not kilobytes). Detects unsaved sessions, git activity since last save, goal mismatch vs CLAUDE.md. - context.json as source of truth — CONTEXT.md is generated from it. Full session history, session IDs, git activity per save. Commands: /ck:init /ck:save /ck:resume /ck:info /ck:list /ck:forget /ck:migrate Source: https://github.com/sreedhargs89/context-keeper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
217
skills/ck/hooks/session-start.mjs
Normal file
217
skills/ck/hooks/session-start.mjs
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ck — Context Keeper v2
|
||||
* session-start.mjs — inject compact project context on session start.
|
||||
*
|
||||
* Injects ~100 tokens (not ~2,500 like v1).
|
||||
* SKILL.md is injected separately (still small at ~50 lines).
|
||||
*
|
||||
* Features:
|
||||
* - Compact 5-line summary for registered projects
|
||||
* - Unsaved session detection → "Last session wasn't saved. Run /ck:save."
|
||||
* - Git activity since last session
|
||||
* - Goal mismatch detection vs CLAUDE.md
|
||||
* - Mini portfolio for unregistered directories
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const CK_HOME = resolve(homedir(), '.claude', 'ck');
|
||||
const PROJECTS_FILE = resolve(CK_HOME, 'projects.json');
|
||||
const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json');
|
||||
const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function readJson(p) {
|
||||
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function daysAgo(dateStr) {
|
||||
if (!dateStr) return 'unknown';
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000);
|
||||
if (diff === 0) return 'today';
|
||||
if (diff === 1) return '1 day ago';
|
||||
return `${diff} days ago`;
|
||||
}
|
||||
|
||||
function stalenessIcon(dateStr) {
|
||||
if (!dateStr) return '○';
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000);
|
||||
return diff < 1 ? '●' : diff <= 5 ? '◐' : '○';
|
||||
}
|
||||
|
||||
function gitLogSince(projectPath, sinceDate) {
|
||||
if (!sinceDate || !existsSync(resolve(projectPath, '.git'))) return null;
|
||||
try {
|
||||
const result = execSync(`git -C "${projectPath}" log --oneline --since="${sinceDate}"`, {
|
||||
timeout: 3000, stdio: 'pipe', encoding: 'utf8',
|
||||
}).trim();
|
||||
const commits = result.split('\n').filter(Boolean).length;
|
||||
return commits > 0 ? `${commits} commit${commits !== 1 ? 's' : ''} since last session` : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function extractClaudeMdGoal(projectPath) {
|
||||
const p = resolve(projectPath, 'CLAUDE.md');
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
const md = readFileSync(p, 'utf8');
|
||||
const m = md.match(/## Current Goal\n([\s\S]*?)(?=\n## |$)/);
|
||||
return m ? m[1].trim().split('\n')[0].trim() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ─── Session ID from stdin ────────────────────────────────────────────────────
|
||||
|
||||
function readSessionId() {
|
||||
try {
|
||||
const raw = readFileSync(0, 'utf8');
|
||||
return JSON.parse(raw).session_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const cwd = process.env.PWD || process.cwd();
|
||||
const sessionId = readSessionId();
|
||||
|
||||
// Load skill (always inject — now only ~50 lines)
|
||||
const skill = existsSync(SKILL_FILE) ? readFileSync(SKILL_FILE, 'utf8') : '';
|
||||
|
||||
const projects = readJson(PROJECTS_FILE) || {};
|
||||
const entry = projects[cwd];
|
||||
|
||||
// Write current-session.json
|
||||
try {
|
||||
writeFileSync(CURRENT_SESSION, JSON.stringify({
|
||||
sessionId,
|
||||
projectPath: cwd,
|
||||
projectName: entry?.name || null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}, null, 2), 'utf8');
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
const parts = [];
|
||||
if (skill) parts.push(skill);
|
||||
|
||||
// ── REGISTERED PROJECT ────────────────────────────────────────────────────
|
||||
if (entry?.contextDir) {
|
||||
const contextFile = resolve(CK_HOME, 'contexts', entry.contextDir, 'context.json');
|
||||
const context = readJson(contextFile);
|
||||
|
||||
if (context) {
|
||||
const latest = context.sessions?.[context.sessions.length - 1] || {};
|
||||
const sessionDate = latest.date || context.createdAt;
|
||||
const sessionCount = context.sessions?.length || 0;
|
||||
|
||||
// ── Compact summary block (~100 tokens) ──────────────────────────────
|
||||
const summaryLines = [
|
||||
`ck: ${context.name} | ${daysAgo(sessionDate)} | ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`,
|
||||
`Goal: ${context.goal || '—'}`,
|
||||
latest.leftOff ? `Left off: ${latest.leftOff.split('\n')[0]}` : null,
|
||||
latest.nextSteps?.length ? `Next: ${latest.nextSteps.slice(0, 2).join(' · ')}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
// ── Unsaved session detection ─────────────────────────────────────────
|
||||
const prevSession = readJson(CURRENT_SESSION);
|
||||
if (prevSession?.sessionId && prevSession.sessionId !== sessionId) {
|
||||
// Check if previous session ID exists in sessions array
|
||||
const alreadySaved = context.sessions?.some(s => s.id === prevSession.sessionId);
|
||||
if (!alreadySaved) {
|
||||
summaryLines.push(`⚠ Last session wasn't saved — run /ck:save to capture it`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git activity ──────────────────────────────────────────────────────
|
||||
const gitLine = gitLogSince(cwd, sessionDate);
|
||||
if (gitLine) summaryLines.push(`Git: ${gitLine}`);
|
||||
|
||||
// ── Goal mismatch detection ───────────────────────────────────────────
|
||||
const claudeMdGoal = extractClaudeMdGoal(cwd);
|
||||
if (claudeMdGoal && context.goal &&
|
||||
claudeMdGoal.toLowerCase().trim() !== context.goal.toLowerCase().trim()) {
|
||||
summaryLines.push(`⚠ Goal mismatch — ck: "${context.goal.slice(0, 40)}" · CLAUDE.md: "${claudeMdGoal.slice(0, 40)}"`);
|
||||
summaryLines.push(` Run /ck:save with updated goal to sync`);
|
||||
}
|
||||
|
||||
parts.push([
|
||||
`---`,
|
||||
`## ck: ${context.name}`,
|
||||
``,
|
||||
summaryLines.join('\n'),
|
||||
].join('\n'));
|
||||
|
||||
// Instruct Claude to display compact briefing at session start
|
||||
parts.push([
|
||||
`---`,
|
||||
`## ck: SESSION START`,
|
||||
``,
|
||||
`IMPORTANT: Display the following as your FIRST message, verbatim:`,
|
||||
``,
|
||||
'```',
|
||||
summaryLines.join('\n'),
|
||||
'```',
|
||||
``,
|
||||
`After the block, add one line: "Ready — what are we working on?"`,
|
||||
`If you see ⚠ warnings above, mention them briefly after the block.`,
|
||||
].join('\n'));
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
// ── NOT IN A REGISTERED PROJECT ────────────────────────────────────────────
|
||||
const entries = Object.entries(projects);
|
||||
if (entries.length === 0) return parts;
|
||||
|
||||
// Load and sort by most recent
|
||||
const recent = entries
|
||||
.map(([path, info]) => {
|
||||
const ctx = readJson(resolve(CK_HOME, 'contexts', info.contextDir, 'context.json'));
|
||||
const latest = ctx?.sessions?.[ctx.sessions.length - 1] || {};
|
||||
return { name: info.name, path, lastDate: latest.date || '', summary: latest.summary || '—', ctx };
|
||||
})
|
||||
.sort((a, b) => (b.lastDate > a.lastDate ? 1 : -1))
|
||||
.slice(0, 3);
|
||||
|
||||
const miniRows = recent.map(p => {
|
||||
const icon = stalenessIcon(p.lastDate);
|
||||
const when = daysAgo(p.lastDate);
|
||||
const name = p.name.padEnd(16).slice(0, 16);
|
||||
const whenStr = when.padEnd(12).slice(0, 12);
|
||||
const summary = p.summary.slice(0, 32);
|
||||
return ` ${name} ${icon} ${whenStr} ${summary}`;
|
||||
});
|
||||
|
||||
const miniStatus = [
|
||||
`ck — recent projects:`,
|
||||
` ${'PROJECT'.padEnd(16)} S ${'LAST SEEN'.padEnd(12)} LAST SESSION`,
|
||||
` ${'─'.repeat(68)}`,
|
||||
...miniRows,
|
||||
``,
|
||||
`Run /ck:list · /ck:resume <name> · /ck:init to register this folder`,
|
||||
].join('\n');
|
||||
|
||||
parts.push([
|
||||
`---`,
|
||||
`## ck: SESSION START`,
|
||||
``,
|
||||
`IMPORTANT: Display the following as your FIRST message, verbatim:`,
|
||||
``,
|
||||
'```',
|
||||
miniStatus,
|
||||
'```',
|
||||
].join('\n'));
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
const parts = main();
|
||||
if (parts.length > 0) {
|
||||
console.log(JSON.stringify({ additionalContext: parts.join('\n\n---\n\n') }));
|
||||
}
|
||||
Reference in New Issue
Block a user