Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ UserPromptSubmit (every prompt)
→ injects top-12 active habits by confidence (~150-350 tokens depending on habit count), dynamically filtered to match the programming languages of files edited in the current session
→ also injects top-3 trigger-matched memories (~40-90 tokens), unless memories are disabled
→ total injection overhead: < 0.5% of a typical 100k context window
→ logs one metadata line per injection to ~/.cc-habits/events.jsonl (timestamp, tool,
session, counts): the proof `cch view` renders as its capture-vs-injection graph
→ set CC_HABITS_INJECT=0 to disable

SessionStart (session begins)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cc-habits",
"version": "0.9.0",
"version": "0.9.1",
"description": "The tool-agnostic memory layer for AI coding agents. Learns your coding habits from real edits and carries them across Claude Code, Cursor, Codex, Gemini, Cline, and any Git workflow.",
"license": "MIT",
"author": "Shreyan Basu Ray",
Expand Down
83 changes: 79 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
readTombstones, addTombstone, initMemoriesMd, readMemoriesMd, writeMemoriesMd,
parseMemories, serialiseMemories, addMemoryTombstone, readMemoryTombstones,
readHistory, appendHistory, logError, detectManualDeletes, applyMemoryUpdates,
getRuleHash, repoStorageContext, findRepoRoot,
type Memory, type Signal, type Habit, type StorageContext,
getRuleHash, repoStorageContext, findRepoRoot, readInjectEvents, eventsFilePath,
type Memory, type Signal, type Habit, type StorageContext, type InjectEvent,
} from './storage';
import { applyUpdates, applyDecay, type AppliedChange } from './confidence';
import { runSelectMenu } from './menu';
Expand All @@ -49,7 +49,7 @@ import { detectInstalledTools, isAntigravityMigrated } from './detect';
import { SUPPORTED_TOOLS } from './supported';
import { explainProviderError } from './provider-errors';

export const VERSION = '0.9.0';
export const VERSION = '0.9.1';

// Turn a provider failure into a plain-language, actionable hint. Returns
// undefined for non-provider errors so the caller can rethrow them.
Expand Down Expand Up @@ -784,6 +784,78 @@ function renderMemoriesSummary(ctx?: StorageContext): void {
process.stdout.write('\n');
}

// Activity graph: capture vs injection over the last 14 days ────────────────
// The transparency proof for injection. Signals come from log.jsonl (what was
// captured), inject events from events.jsonl (what the prompt hook actually
// handed to a tool, recorded at the moment it happened). Both rows render only
// real logged lines; nothing here is inferred or estimated.
const ACTIVITY_DAYS = 14;
const ACTIVITY_BLOCKS = ['░', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];

// One bar character per day, scaled to the row's own peak (labelled beside the
// row so the two rows are not read as sharing a scale).
export function activityRow(counts: number[]): string {
const max = Math.max(0, ...counts);
return counts
.map(n => (n === 0 || max === 0) ? ACTIVITY_BLOCKS[0] : ACTIVITY_BLOCKS[Math.min(8, Math.max(1, Math.ceil((n / max) * 8)))])
.join(' ');
}

export function renderActivitySection(ctx?: StorageContext): void {
let events: InjectEvent[] = [];
let signals: Signal[] = [];
try { events = readInjectEvents(ctx); } catch { events = []; }
try { signals = readSignals(undefined, ctx); } catch { signals = []; }
if (events.length === 0 && signals.length === 0) return; // nothing to show yet

// Bucket by UTC date, matching the ISO timestamps both files store.
const days: string[] = [];
const now = Date.now();
for (let i = ACTIVITY_DAYS - 1; i >= 0; i--) {
days.push(new Date(now - i * 86_400_000).toISOString().slice(0, 10));
}
const idx = new Map(days.map((d, i) => [d, i]));
const cap: number[] = new Array(ACTIVITY_DAYS).fill(0);
const inj: number[] = new Array(ACTIVITY_DAYS).fill(0);
for (const s of signals) {
const i = idx.get((s.ts ?? '').slice(0, 10));
if (i !== undefined) cap[i] = (cap[i] ?? 0) + 1;
}
for (const e of events) {
const i = idx.get((e.ts ?? '').slice(0, 10));
if (i !== undefined) inj[i] = (inj[i] ?? 0) + 1;
}
const capTotal = cap.reduce((a, b) => a + b, 0);
const injTotal = inj.reduce((a, b) => a + b, 0);

process.stdout.write(c(BOLD, ' ── Activity: capture vs injection ') + c(DIM, '─'.repeat(14)) + '\n');
process.stdout.write(
` ${c(DIM, 'captured')} ${c(CYAN, activityRow(cap))} ` +
c(DIM, `${capTotal} edit${capTotal === 1 ? '' : 's'} · peak ${Math.max(0, ...cap)}/day`) + '\n',
);
process.stdout.write(
` ${c(DIM, 'injected')} ${c(GREEN, activityRow(inj))} ` +
c(DIM, `${injTotal} prompt${injTotal === 1 ? '' : 's'} · peak ${Math.max(0, ...inj)}/day`) + '\n',
);
process.stdout.write(c(DIM, ` ${days[0]!.slice(5)} ${'─'.repeat(15)}▶ ${days[ACTIVITY_DAYS - 1]!.slice(5)}`) + '\n');

const last = events[events.length - 1];
if (last) {
const hab = `${last.habits} habit${last.habits === 1 ? '' : 's'}`;
const mem = last.memories > 0 ? `, ${last.memories} memor${last.memories === 1 ? 'y' : 'ies'}` : '';
const sess = term(String(last.session_id)).slice(0, 12);
process.stdout.write(
` ${c(GREEN, '✓')} last injected ${formatTimeAgo(last.ts)} into ${c(BOLD, term(last.source))}` +
c(DIM, ` · ${hab}${mem}${sess ? ` · session ${sess}` : ''}`) + '\n',
);
process.stdout.write(c(DIM, ` proof, one line per injection: ${eventsFilePath(ctx)}\n`));
} else {
process.stdout.write(c(DIM, ' No injections yet. Habits inject after they graduate (2+ sessions);\n'));
process.stdout.write(c(DIM, ' every injection is then logged to events.jsonl and charted here.\n'));
}
process.stdout.write('\n');
}

// `cch view` with no subcommand: the one-glance unified view. Compact habits
// (grouped) plus graduated memories, with no menu and no flags to remember. The
// focused subviews (`cch view memories|prefs|habits`) and `--lang` still exist,
Expand All @@ -801,7 +873,10 @@ export function cmdView(langFilter?: string, requestedScope?: ViewScope): number
renderScopeBanner(scope);
const code = renderHabitsView(langFilter, scope.ctx);
// Memories are not language-scoped, so only fold them into the unfiltered view.
if (!langFilter) renderMemoriesSummary(scope.ctx);
if (!langFilter) {
renderMemoriesSummary(scope.ctx);
renderActivitySection(scope.ctx);
}
return code;
}

Expand Down
30 changes: 28 additions & 2 deletions src/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Output ONLY a JSON array. No prose. Each object:
CRITICAL:
- Only extract habits observable from 2+ signals OR extremely clear single signals.
- Stick to syntactic/stylistic patterns. Do not infer architectural intent.
- Prose/documentation edits (markdown) are habits too: extract the writing conventions they show (heading case, voice and person, callout style, formatting of UI terms) under a category like "Documentation" or "Writing Style".
- CONSOLIDATE RELATED PREFERENCES: Do not split a single coding style pattern into multiple, hyper-specific rules (e.g., do not write one rule for 'parameter type annotations' and another for 'return type annotations'; instead, consolidate them under a single comprehensive rule like 'Use explicit TypeScript type annotations for function signatures'). Prefer broad, consolidated instructions.
- DO NOT EXTRACT BUG FIXES OR MISTAKES: If a change represents a specific agent bug fix (such as forgetting a null check, failing to close a stream, or writing incorrect API arguments), do NOT capture it as a habit. Those belong exclusively in memories, not habits. Only capture repeating, positive coding style/formatting preferences.
- Never output content marked <REDACTED:...>.
Expand Down Expand Up @@ -93,13 +94,37 @@ export async function extractRules(

try {
const updates = JSON.parse(cleaned) as unknown;
if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate);
if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate).filter(u => !isPromptInstructionEcho(u.rule));
} catch {
// malformed, treat as no updates
}
return [];
}

// Instruction-echo guard for habit rules. Small local models sometimes quote the
// prompt's own instruction text back as a "habit" (observed with llama3.2:1b:
// "Always follow the 'Single Declarative Sentence' rule in coding"). These
// normalized fragments are distinctive to the prompts above and never occur in
// a genuine style preference, so a rule containing one is a prompt echo. The
// mirror of the memory-side isPromptExampleEcho() guard.
const PROMPT_INSTRUCTION_FRAGMENTS: readonly string[] = [
'single declarative sentence',
'consolidate related preferences',
'do not extract bug fixes',
'stating the preference',
'stating the convention',
'extremely clear single signals',
];

function isPromptInstructionEcho(rule: string): boolean {
try {
const norm = normalizeForEcho(rule);
return PROMPT_INSTRUCTION_FRAGMENTS.some(f => norm.includes(f));
} catch {
return false; // fail-open: guard failure must never drop real habits
}
}

// The provider response is untrusted (a self-hosted/MITM'd or simply buggy endpoint
// could return arbitrary JSON). Validate shape and coerce to exactly the known
// fields, never spread provider-controlled objects into downstream logic.
Expand Down Expand Up @@ -382,6 +407,7 @@ Output ONLY a JSON array. No prose. Each object:
CRITICAL:
- Only extract a convention visible CONSISTENTLY across multiple files or many times in one file. Skip one-offs.
- Stick to observable syntactic/stylistic patterns (naming, quoting, typing, import style, comment style, error handling shape). Do NOT infer architecture, business logic, or intent.
- Markdown/prose files are first-class input: infer the WRITING conventions they consistently follow (heading case, voice and person, callout style, frontmatter shape, formatting of UI terms) under a category like "Documentation" or "Writing Style".
- CONSOLIDATE: prefer broad rules over many hyper-specific ones.
- Use "reinforce" when a sampled convention matches an existing habit; otherwise "create".
- Do NOT extract bug fixes, TODOs, or one-off mistakes. Only durable positive conventions.
Expand Down Expand Up @@ -467,7 +493,7 @@ export async function extractHabitsFromRepo(
const cleaned = stripCodeFences(raw);
try {
const updates = JSON.parse(cleaned) as unknown;
if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate);
if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate).filter(u => !isPromptInstructionEcho(u.rule));
} catch {
// malformed, treat as no updates
}
Expand Down
40 changes: 32 additions & 8 deletions src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import fs from 'fs';
import path from 'path';
import {
appendSignal, readSignals, countSignals, readHabitsMd, parseHabits, writeHabitsMd,
appendSignal, readSignals, countSignals, appendInjectEvent, readHabitsMd, parseHabits, writeHabitsMd,
serialiseHabits, logError, sanitizeFilePath, detectManualDeletes, writeSnapshot,
addTombstone,
appendHistory, appendProvenance, readMemoriesMd, applyMemoryUpdates, parseMemories,
Expand Down Expand Up @@ -628,7 +628,7 @@ function renderHabitsInjection(habits: InjectionHabit[]): string | null {

const lines: string[] = [
'<coding-habits>',
"Apply the developer's learned coding habits below when writing or editing code.",
"Apply the developer's learned habits below when writing or editing code or documentation.",
'These are durable preferences; honor them unless the user says otherwise.',
];
for (const [category, rules] of byCat) {
Expand Down Expand Up @@ -862,7 +862,7 @@ function safeRead(fn: () => string): string {
try { return fn(); } catch { return ''; }
}

export function processUserPromptSubmit(data: Record<string, unknown>, ctx?: StorageContext): string | null {
export function processUserPromptSubmit(data: Record<string, unknown>, ctx?: StorageContext, source = 'claude-code'): string | null {
if (!injectionEnabled()) return null;
const promptText = typeof data['prompt'] === 'string' ? data['prompt'] : '';
const sessionId = String(data['session_id'] ?? data['sessionId'] ?? data['session'] ?? '');
Expand All @@ -880,20 +880,44 @@ export function processUserPromptSubmit(data: Record<string, unknown>, ctx?: Sto
: buildInjectionContext(globalMd, langs, ctx);

let memoriesContext: string | null = null;
let memoriesInjected = 0;
if (memoriesEnabled(ctx)) {
try {
const globalMem = readMemoriesMd(ctx);
const relevant = repoCtx
? selectMergedInjectionMemories(globalMem, safeRead(() => readMemoriesMd(repoCtx)), promptText, ctx, repoCtx)
: selectInjectionMemories(globalMem, promptText, ctx);
memoriesContext = buildMemoryInjectionContext(relevant);
memoriesInjected = memoriesContext ? relevant.length : 0;
} catch {
// injection failures must never block a prompt
}
}

if (habitsContext && memoriesContext) return habitsContext + '\n' + memoriesContext;
return habitsContext ?? memoriesContext;
const combined = habitsContext && memoriesContext
? habitsContext + '\n' + memoriesContext
: habitsContext ?? memoriesContext;

// Proof of injection: record that learned context was actually returned to
// the tool (events.jsonl, rendered by `cch view` as the activity graph).
// Counts only, derived from the rendered block: each habit renders as one
// "- rule." line. Fail-open: a logging failure must never block the prompt.
if (combined) {
try {
appendInjectEvent({
ts: new Date().toISOString(),
type: 'inject',
session_id: sessionId,
source,
habits: habitsContext ? habitsContext.split('\n').filter(l => l.startsWith('- ')).length : 0,
memories: memoriesInjected,
scope: repoCtx ? 'merged' : 'global',
}, ctx);
} catch {
// never let audit logging break injection
}
}
return combined;
}

// Builds the SessionStart reminder. Habits/memories are already injected via the
Expand Down Expand Up @@ -1045,7 +1069,7 @@ export async function handleStop(): Promise<void> {
process.exit(0);
}

export function handleUserPromptSubmit(): void {
export function handleUserPromptSubmit(adapter = 'claude-code'): void {
let raw = '';
let oversized = false;
process.stdin.setEncoding('utf-8');
Expand All @@ -1061,7 +1085,7 @@ export function handleUserPromptSubmit(): void {
if (!oversized) {
try {
const data = raw.trim() ? (JSON.parse(raw) as Record<string, unknown>) : {};
const context = processUserPromptSubmit(data);
const context = processUserPromptSubmit(data, undefined, adapter);
// Plain stdout is added to Claude's context for UserPromptSubmit. Exit 0
// always, injection must never block or fail a prompt.
if (context) process.stdout.write(context + '\n');
Expand Down Expand Up @@ -1094,7 +1118,7 @@ export function hookMain(): void {
try {
if (event === 'post-tool-use') handlePostToolUse(adapter);
else if (event === 'stop') await handleStop();
else if (event === 'user-prompt-submit') handleUserPromptSubmit();
else if (event === 'user-prompt-submit') handleUserPromptSubmit(adapter);
else if (event === 'session-start') handleSessionStart(adapter);
else if (KNOWN_UNSUPPORTED_EVENTS.has(event)) {
// Deliberate no-op (e.g. subagent-stop). See hook-schema.ts for why:
Expand Down
20 changes: 18 additions & 2 deletions src/repo-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ const SOURCE_EXTS = new Set([
'.vue', '.svelte',
]);

// Prose content worth sampling for writing-style inference: a technical writer's
// article corpus carries house style (voice, heading case, structure, callout
// conventions) exactly the way source files carry code style. Sampled through
// the same stride/byte budget as source files. Instruction docs (CLAUDE.md and
// friends) are excluded here because readDocFiles already reads them whole.
const PROSE_EXTS = new Set(['.md', '.mdx']);

// Path fragments that mark generated, vendored, or minified content to skip.
const SKIP_FRAGMENTS = [
'node_modules/', 'dist/', 'build/', 'vendor/', '.min.', 'coverage/',
Expand Down Expand Up @@ -196,8 +203,17 @@ function walk(dir: string, root: string, depth: number): string[] {

// Pick a representative, size-bounded sample spread across directories.
function sampleSourceFiles(repoRoot: string): RepoFile[] {
const docSet = new Set(DOC_CANDIDATES.map(d => d.toLowerCase()));
const all = listCandidateFiles(repoRoot)
.filter(rel => SOURCE_EXTS.has(path.extname(rel).toLowerCase()))
.filter(rel => {
const ext = path.extname(rel).toLowerCase();
if (SOURCE_EXTS.has(ext)) return true;
// Prose pages count, but not the instruction docs readDocFiles handles,
// and not README (project boilerplate, weak style signal).
return PROSE_EXTS.has(ext)
&& !docSet.has(rel.toLowerCase())
&& !path.basename(rel).toLowerCase().startsWith('readme');
})
.filter(rel => !skip(rel));
if (all.length === 0) return [];

Expand Down Expand Up @@ -287,7 +303,7 @@ export async function scanRepo(opts: ScanOptions = {}): Promise<RepoScanResult>
process.stdout.write(
`\n⚠️ cc-habits repository scan\n` +
` Reads ${files.length} source file${files.length === 1 ? '' : 's'}${docNote} ` +
`(code and instruction docs only; data, lockfiles, and assets are skipped).\n` +
`(code, prose pages, and instruction docs; data, lockfiles, and assets are skipped).\n` +
` Redacted context is sent to ${resolveProviderLabel()} and consumes tokens.\n`
);
if (opts.confirm) {
Expand Down
Loading
Loading