diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3284aab..9352d52 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -3,7 +3,7 @@ "owner": { "name": "bully911210", "url": "https://github.com/bully911210" }, "metadata": { "description": "Function-hook mods for Claude Code: a limits telemetry row, multi-select question and next-step panes, and README updates after every commit." }, "plugins": [ - { "name": "token-ledger", "source": "./token-ledger", "description": "5-hour, weekly, context and cache-hit figures in one row above the prompt, a ? button for Haiku questions, and a /ledger pane." }, + { "name": "token-ledger", "source": "./token-ledger", "description": "5-hour, weekly, context and cache-hit figures in one row above the prompt, a ? button for Haiku questions that learns your project and feedback, a /brief pane to see and correct it, and a /ledger pane." }, { "name": "next-steps", "source": "./next-steps", "description": "1-3 suggested next prompts after each real turn, tick any number and insert them together." }, { "name": "auto-docs", "source": "./auto-docs", "description": "After each commit, updates README.md (or the docs you list) in a follow-up docs commit." } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 721f79f..8834539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Each mod is versioned on its own (the `version` in its `.claude-plugin/plugin.json`). Dates are UTC. +## 2026-10-17 + +- **token-ledger 0.6.0**: `?` now learns from your project and from you. + - After each turn you type, Haiku folds the exchange into a short per-project brief and a profile of you that carries across projects. + - Your corrections and frustration, and which suggestions you inserted or skipped, are logged as feedback. + - `?` reads that memory plus only the latest messages. Until a brief exists it still gets the full chat. + - New `/brief` command shows what the mod knows and lets you fix it: `/brief brief `, `/brief profile `, `/brief forget`, `/brief reset`. + - South African ID numbers and phone numbers, emails, card numbers, bank accounts and passport numbers are redacted before anything is stored or sent to Haiku. Names and non-South-African phone or ID formats are not detected. Only prompts a person typed are learned from, never notifications or schedules. + - The `?` prompt no longer assumes you are a developer. + ## 2026-10-13 - **auto-docs:** tests now pass on Linux and macOS as well as Windows. diff --git a/README.md b/README.md index 4081fa6..c12d478 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Every picture below was rendered from the element trees these mods really return | Mod | What you get | Commands | | --- | --- | --- | -| [token-ledger](#token-ledger) | A telemetry row above the prompt (5-hour %, burn rate, reset, time left, weekly %, context %, cache hit %, model, effort). A `?` button for five Haiku questions. A `/ledger` pane. | `/ask`, `/ledger` | +| [token-ledger](#token-ledger) | A telemetry row above the prompt (5-hour %, burn rate, reset, time left, weekly %, context %, cache hit %, model, effort). A `?` button for five Haiku questions. A `/ledger` pane. A `/brief` pane for what it has learned about you and your project. | `/ask`, `/ledger`, `/brief` | | [next-steps](#next-steps) | 1-3 suggested next prompts after each real turn, in a pane of their own. | `/next`, `/next off`, `/next on` | | [auto-docs](#auto-docs) | After each commit, a follow-up `docs: sync with ` commit that updates README.md, or the docs you list. | `/docs`, `/docs ` | @@ -59,6 +59,28 @@ Click `?` at the end of the row, or run `/ask`. Haiku 5.5 reads the project (fol Questions worth asking pane with two questions ticked and the Insert 2 questions button +### `/brief`: what `?` has learned + +`?` does not start cold. After each prompt you type, a small Haiku call folds the exchange into three things the mod keeps on your machine: + +- **A project brief:** what the project is, the goal, what has been decided and what is still open. One per project folder. +- **A profile of you:** who you are, your business, how you like to be spoken to. Shared across projects. +- **Feedback:** things you corrected, moments you were frustrated, and which suggestions you inserted or skipped. + +`?` then reads that memory plus the latest messages, so its questions fit your project and don't repeat what you have already decided or skipped. Until a brief exists, `?` still gets the full recent chat. + +`/brief` shows what the mod believes so you can correct it: + +| Command | What it does | +| --- | --- | +| `/brief` | Opens the pane. | +| `/brief brief ` | Replaces this project's brief. | +| `/brief profile ` | Replaces your profile. | +| `/brief forget` | Clears this project's feedback log. | +| `/brief reset` | Clears this project's brief and feedback. Your profile is kept. | + +Privacy: South African ID numbers and phone numbers, email addresses, card numbers, bank accounts and passport numbers are replaced with stable tokens before anything is stored or sent to Haiku. People's names and phone or ID formats from other countries are **not** detected, so don't put those in prompts you don't want a model to see. Only prompts a person typed are learned from, never notifications, schedules or peer messages. Everything is stored locally in the plugin's own store. A manual `/brief brief` edit can be rewritten by a later turn's fold, so re-run it if a fact must stay. + ### `/ledger` Token ledger pane: limits, this session, subagents, last 7 days, cache rewrites after idle, recent turns diff --git a/token-ledger/.claude-plugin/plugin.json b/token-ledger/.claude-plugin/plugin.json index bf260c4..bac790b 100644 --- a/token-ledger/.claude-plugin/plugin.json +++ b/token-ledger/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "token-ledger", - "version": "0.5.0", - "description": "Your 5-hour, weekly, context and cache-hit figures as one line above the prompt, a ? button that asks Haiku 5.5 for questions to ask next, and a /ledger pane with subagent shares. Percentages only, no money.", + "version": "0.6.0", + "description": "Your 5-hour, weekly, context and cache-hit figures as one line above the prompt, a ? button that asks Haiku 5.5 for questions to ask next, drawn from a running brief of the project and what you've told it (see /brief), and a /ledger pane with subagent shares. Percentages only, no money.", "author": { "name": "claude-code-mods contributors", "url": "https://github.com/bully911210/claude-code-mods" diff --git a/token-ledger/hooks/lib/insights.ts b/token-ledger/hooks/lib/insights.ts index afa9c36..1bf6c2b 100644 --- a/token-ledger/hooks/lib/insights.ts +++ b/token-ledger/hooks/lib/insights.ts @@ -6,6 +6,8 @@ export const QUESTION_COUNT = 5 /** Budgets keep the request small and fast; the newest context matters most. */ const MAX_MESSAGES = 40 +/** With a memory block the history lives there, so only the freshest turns are needed. */ +const MAX_MESSAGES_WITH_MEMORY = 6 const MAX_MESSAGE_CHARS = 1_500 const MAX_THINKING_CHARS = 4_000 const MAX_PROJECT_DOC_CHARS = 6_000 @@ -23,17 +25,25 @@ export type InsightContext = { } readonly messages: readonly { readonly role: 'user' | 'assistant'; readonly text: string; readonly tools: readonly string[] }[] readonly thinking: readonly string[] + /** The rendered memory block (see memory.ts renderMemory); leads the prompt when present. */ + readonly memory?: string + /** False when the memory holds no brief yet (only feedback): the chat then still carries the history. */ + readonly memoryHasBrief?: boolean } export const SYSTEM = [ - 'You help a developer get more out of their current Claude Code conversation.', - `Write exactly ${QUESTION_COUNT} questions the developer could ask Claude next.`, - 'Each question must uncover something the conversation has not adequately explored:', - 'information or capabilities the system already has but has not surfaced; non-obvious technical opportunities;', - 'critical assumptions nobody has verified; failure modes, edge cases and architectural weaknesses;', - 'or a high-leverage experiment or investigation that would materially improve the outcome.', - 'Tie every question to specifics in the context (files, tools, decisions, errors, names). No generic prompts.', - 'Make the five meaningfully different from one another. Keep each question under 220 characters and actionable.', + 'The user is a business owner and operator, not a developer. Do not assume the work is software, and do not use developer framing or jargon.', + 'Work out from the context what the project actually is (for example marketing, ads, sales, lead generation, content, brand, operations, reporting, or software) and what outcome the user is chasing.', + `Then write exactly ${QUESTION_COUNT} questions the user could ask Claude next, as the sharpest domain expert for that exact project would: a senior marketer, strategist, operator or analyst, whichever fits.`, + 'Each question must surface expert insight the conversation has not yet explored:', + 'a lever or opportunity being missed; an assumption nobody has tested; the real risk, weakness or blind spot in the current approach;', + 'something Claude or the connected tools could already do or find out but has not yet; or a high-leverage test, check or decision that would materially improve the result.', + 'Tie every question to specifics in the context (the actual project, brand, numbers, files, tools, decisions, names). Never ask a generic question that could apply to any project.', + 'The memory sections at the top of the context (About the user, The project so far, What the user has told us and how they react) are the authoritative picture of the user and the project; trust them over a thin or empty chat.', + 'Signals in the memory: ignored, correction and frustration are things to AVOID repeating or contradicting, so never ask something the user has already decided, corrected or ignored.', + 'Signals marked accepted show the style the user likes, so lean toward similar depth and angle, but do not repeat the same question.', + 'Write for a business owner in plain language.', + 'Make the five meaningfully different from one another. Keep each question under 220 characters, plain-spoken and actionable.', 'Use only what the context shows. Do not claim to have seen anything it does not contain.', 'Reply with JSON only: {"questions":[{"title":"2-5 word label","question":"the full question"}]}', ].join(' ') @@ -53,11 +63,13 @@ export const buildPrompt = (c: InsightContext): string => { const thinkingPart = thinking.length ? [`Claude's visible reasoning notes (most recent):\n${cut(thinking.slice(-3).join('\n---\n'), MAX_THINKING_CHARS)}`] : ["Claude's reasoning is not visible for this conversation; do not guess at it."] - const convo = c.messages.slice(-MAX_MESSAGES).map(m => { + const memory = (c.memory ?? '').trim() + const recent = memory === '' || c.memoryHasBrief === false ? MAX_MESSAGES : MAX_MESSAGES_WITH_MEMORY + const convo = c.messages.slice(-recent).map(m => { const tools = m.tools.length ? ` [tools: ${[...new Set(m.tools)].join(', ')}]` : '' return `${m.role === 'user' ? 'USER' : 'CLAUDE'}${tools}: ${cut(m.text.trim(), MAX_MESSAGE_CHARS)}` }) - const head = ['## Project', ...project, '', '## Reasoning', ...thinkingPart, '', '## Conversation (oldest first)'].join('\n') + const head = [...(memory === '' ? [] : [memory, '']), '## Project', ...project, '', '## Reasoning', ...thinkingPart, '', '## Conversation (oldest first)'].join('\n') // Drop the oldest messages until the whole prompt fits. let body = convo while (body.length > 1 && head.length + body.join('\n').length > MAX_TOTAL_CHARS) body = body.slice(1) diff --git a/token-ledger/hooks/lib/memory.ts b/token-ledger/hooks/lib/memory.ts new file mode 100644 index 0000000..f71c94c --- /dev/null +++ b/token-ledger/hooks/lib/memory.ts @@ -0,0 +1,402 @@ +// Pure logic for the persistent memory behind the ? button: a per-project brief, +// a global profile of the user, and a log of the user's feedback. Storage, the +// Haiku call and the UI live elsewhere; nothing here touches the engine. + +import { redact } from './redact' + +export const BRIEF_MAX_CHARS = 1_500 +export const PROFILE_MAX_CHARS = 1_200 +export const SIGNALS_MAX = 40 +export const SIGNAL_TEXT_MAX = 240 +/** Skipped suggestions are noise next to the user's own words, so each kind has its own room. */ +export const SIGNAL_QUOTA: Readonly> = Object.freeze({ + correction: 12, + frustration: 8, + accepted: 10, + ignored: 8, +}) + +export type SignalKind = 'correction' | 'frustration' | 'accepted' | 'ignored' +export type Signal = { readonly kind: SignalKind; readonly text: string; readonly at: number } +export type ProjectMemory = { + readonly brief: string + readonly signals: readonly Signal[] + readonly updatedAt: number + readonly folds: number +} +export type ProfileMemory = { readonly profile: string; readonly updatedAt: number } + +export const EMPTY_PROJECT: ProjectMemory = Object.freeze({ brief: '', signals: Object.freeze([]), updatedAt: 0, folds: 0 }) +export const EMPTY_PROFILE: ProfileMemory = Object.freeze({ profile: '', updatedAt: 0 }) + +const SIGNAL_KINDS: readonly SignalKind[] = ['correction', 'frustration', 'accepted', 'ignored'] + +const FNV_OFFSET = 0x811c9dc5 +const FNV_PRIME = 0x01000193 +const HASH_HEX_LENGTH = 8 +const UNKNOWN_NAME = 'unknown' +const UNKNOWN_KEY = `${UNKNOWN_NAME}-${'0'.repeat(HASH_HEX_LENGTH)}` +const KEY_NAME_MAX = 40 + +/** A cut is only moved back to a word boundary when that costs less than this share. */ +const MIN_WORD_CUT_SHARE = 0.5 + +const MIN_SHOUT_LETTERS = 12 +const SHOUT_SHARE = 0.6 +const SHOUT_MARKS = /[?!]{3,}/ +const DEFAULT_MIN_ASSISTANT_CHARS = 200 +const DEFAULT_SIGNAL_LIMIT = 12 +/** In the ? prompt, skipped suggestions get at most this many of the lines. */ +const IGNORED_SHOWN_MAX = 4 +/** Only a short message can be read for mood; a pasted document says nothing about it. */ +const CLASSIFY_MAX_CHARS = 500 +const FOLD_USER_MAX = 2_000 +const FOLD_ASSISTANT_MAX = 3_000 +const MAX_REPLY_CHARS = 20_000 +const REDACT_SALT = 'memory' +const NOTHING_RECORDED = 'Nothing recorded yet.' + +/** Whole-word phrases that mean "that is not what I meant"; matched anywhere in a short message. */ +const CORRECTION_ANYWHERE = + /\b(?:i am not|i'm not|not a developer|i said|that'?s not|thats not|that is not|wrong|why did you|i didn'?t ask|i did not ask|rather than)\b/ +/** Phrases that only count as a correction when the message opens with them. */ +const CORRECTION_OPENING = /^\s*(?:no\b[,.!:;]|stop\b|please\s+(?:don'?t|stop)\b|don'?t(?!\s+forget)\b|do not\b)/ + +const isRecord = (v: unknown): v is Readonly> => typeof v === 'object' && v !== null && !Array.isArray(v) + +const isSignalKind = (v: unknown): v is SignalKind => SIGNAL_KINDS.includes(v as SignalKind) + +const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) + +/** Removes a lone high surrogate a cut may have left, so no emoji is split in half. */ +const dropSplitPair = (s: string): string => (/[\ud800-\udbff]$/.test(s) ? s.slice(0, -1) : s) + +/** Trims, then cuts to `max` characters, at a word boundary when one is near. */ +const cutAtWord = (text: string, max: number): string => { + const t = text.trim() + if (t.length <= max) return t + const hard = dropSplitPair(t.slice(0, max)) + if (/\s/.test(t.charAt(max))) return hard.trimEnd() + const lastGap = hard.search(/\s\S*$/) + return (lastGap >= max * MIN_WORD_CUT_SHARE ? hard.slice(0, lastGap) : hard).trimEnd() +} + +/** Trims, then cuts to `max` characters in total, ending with an ellipsis when cut. */ +const cutWithEllipsis = (text: string, max: number): string => { + const t = text.trim() + return t.length <= max ? t : `${dropSplitPair(t.slice(0, max - 1)).trimEnd()}…` +} + +const fnv1a = (input: string): string => { + let hash = FNV_OFFSET + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, FNV_PRIME) >>> 0 + } + return hash.toString(16).padStart(HASH_HEX_LENGTH, '0') +} + +/** Windows paths ignore case and mix slash styles; POSIX paths are case-sensitive, so /work/Acme and /work/acme differ. */ +const isWindowsPath = (raw: string): boolean => raw.includes('\\') || /^[a-z]:[\\/]/i.test(raw) || raw.startsWith('//') + +/** One stable key per folder, however a Windows path is spelled; a POSIX path keeps its case. */ +export function projectKey(root: string): string { + const raw = typeof root === 'string' ? root.trim() : '' + const slashed = raw.replace(/\\/g, '/').replace(/\/+$/, '') + const path = isWindowsPath(raw) ? slashed.toLowerCase() : slashed + if (path === '') return UNKNOWN_KEY + const folder = (path.split('/').pop() ?? '') + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, KEY_NAME_MAX) + return `${folder || UNKNOWN_NAME}-${fnv1a(path)}` +} + +const readSignal = (raw: unknown): readonly Signal[] => { + if (!isRecord(raw) || !isSignalKind(raw.kind) || typeof raw.text !== 'string' || !isFiniteNumber(raw.at)) return [] + const text = cutWithEllipsis(raw.text, SIGNAL_TEXT_MAX) + return text === '' ? [] : [{ kind: raw.kind, text, at: raw.at }] +} + +const readCount = (v: unknown): number => (isFiniteNumber(v) && v >= 0 ? v : 0) + +/** Reads a project memory back from JSON storage; anything malformed becomes empty. */ +export function asProjectMemory(raw: unknown): ProjectMemory { + if (!isRecord(raw)) return EMPTY_PROJECT + const signals = Array.isArray(raw.signals) ? raw.signals.flatMap(readSignal).slice(-SIGNALS_MAX) : [] + return { + brief: typeof raw.brief === 'string' ? cutAtWord(raw.brief, BRIEF_MAX_CHARS) : '', + signals, + updatedAt: readCount(raw.updatedAt), + folds: isFiniteNumber(raw.folds) && Number.isInteger(raw.folds) && raw.folds >= 0 ? raw.folds : 0, + } +} + +/** Reads a profile back from JSON storage; anything malformed becomes empty. */ +export function asProfileMemory(raw: unknown): ProfileMemory { + if (!isRecord(raw)) return EMPTY_PROFILE + return { + profile: typeof raw.profile === 'string' ? cutAtWord(raw.profile, PROFILE_MAX_CHARS) : '', + updatedAt: readCount(raw.updatedAt), + } +} + +/** Cheap local read of how the user is reacting; frustration outranks correction. */ +export function classifyUserMessage(text: string): 'correction' | 'frustration' | null { + if (typeof text !== 'string' || text.trim() === '' || text.length > CLASSIFY_MAX_CHARS) return null + const letters = text.match(/\p{L}/gu) ?? [] + const upper = letters.filter(ch => ch !== ch.toLowerCase()).length + const isShouting = letters.length >= MIN_SHOUT_LETTERS && upper / letters.length >= SHOUT_SHARE + if (isShouting || SHOUT_MARKS.test(text)) return 'frustration' + const lower = text.toLowerCase().replace(/[‘’]/g, "'") + return CORRECTION_ANYWHERE.test(lower) || CORRECTION_OPENING.test(lower) ? 'correction' : null +} + +const scrub = (text: string): string => redact(text, { salt: REDACT_SALT, redactAmounts: false }).text + +/** Keeps the newest signals of each kind up to that kind's room, in their original order. */ +const withinQuota = (signals: readonly Signal[]): readonly Signal[] => { + const seen: Record = {} + const keep = new Set() + for (let i = signals.length - 1; i >= 0; i -= 1) { + const kind = signals[i]?.kind ?? 'ignored' + seen[kind] = (seen[kind] ?? 0) + 1 + if (seen[kind] <= SIGNAL_QUOTA[kind]) keep.add(i) + } + return signals.filter((_, i) => keep.has(i)) +} + +const signalKey = (s: Signal): string => `${s.kind}\u0000${s.text.toLowerCase()}` + +/** A new list with `signal` last: trimmed, cut, deduplicated and capped to the newest. */ +export function addSignal(signals: readonly Signal[], signal: Signal): readonly Signal[] { + const text = cutWithEllipsis(scrub(typeof signal.text === 'string' ? signal.text : ''), SIGNAL_TEXT_MAX) + if (text === '') return [...signals] + const next: Signal = { kind: signal.kind, text, at: signal.at } + const key = signalKey(next) + return withinQuota([...signals.filter(s => signalKey(s) !== key), next]).slice(-SIGNALS_MAX) +} + +/** What the user's choice of suggestions says: picked are accepted, the rest ignored. */ +export function pickSignals( + items: readonly { readonly title: string; readonly question: string }[], + pickedIndexes: readonly number[], + at: number, +): readonly Signal[] { + const picked = new Set(pickedIndexes.filter(i => Number.isInteger(i) && i >= 0 && i < items.length)) + return items.flatMap((item, i): readonly Signal[] => { + const text = typeof item.question === 'string' ? item.question.trim() : '' + return text === '' ? [] : [{ kind: picked.has(i) ? 'accepted' : 'ignored', text, at }] + }) +} + +/** Two exchanges as one, oldest first, keeping the newest words if it would not fit the fold's budget. */ +export function mergeExchanges( + a: { readonly user: string; readonly assistant: string }, + b: { readonly user: string; readonly assistant: string }, +): { readonly user: string; readonly assistant: string } { + const tail = (text: string, max: number): string => (text.length > max ? text.slice(-max) : text) + return { + user: tail(`${a.user}\n\n${b.user}`, FOLD_USER_MAX), + assistant: tail(`${a.assistant}\n\n${b.assistant}`, FOLD_ASSISTANT_MAX), + } +} + +export type FoldTurn = { readonly user: string; readonly assistant: string; readonly signals: readonly Signal[] } +export type FoldResult = { readonly brief: string; readonly profile: string } + +export const FOLD_SYSTEM: string = [ + 'You maintain two short notes about a person and their work.', + 'The user is a business owner and operator, not a developer: do not assume the work is software.', + `PROJECT BRIEF: what this project is, the goal, the decisions made, and what is still open (at most about ${BRIEF_MAX_CHARS} characters).`, + `USER PROFILE: who the user is, their business context, their preferences, how they like to be spoken to, and anything they have corrected (at most about ${PROFILE_MAX_CHARS} characters).`, + 'Rewrite both notes compactly by folding in the new exchange. Drop outdated or superseded facts. Keep concrete names, numbers and decisions.', + 'Treat the feedback lines as evidence of what the user likes, dislikes and has corrected.', + 'Never invent facts that the exchange does not show. Never store secrets, ID numbers, or contact details.', + 'If nothing changed for a note, return its previous text unchanged.', + 'Reply with JSON only: {"brief":"...","profile":"..."}', +].join(' ') + +const cut = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)} [cut]` : s) + +const clean = (s: unknown, max: number): string => + cut(redact(typeof s === 'string' ? s.trim() : '', { salt: REDACT_SALT, redactAmounts: false }).text, max) + +const orEmpty = (s: string): string => (s.trim() === '' ? '(empty)' : s.trim()) + +/** The fold request: current notes, then the new exchange (redacted, within budget). */ +export function buildFoldPrompt(brief: string, profile: string, turn: FoldTurn): string { + const feedback = turn.signals.map(s => `- ${s.kind}: ${clean(s.text, SIGNAL_TEXT_MAX)}`) + return [ + '## Current PROJECT BRIEF', + orEmpty(clean(brief, BRIEF_MAX_CHARS)), + '', + '## Current USER PROFILE', + orEmpty(clean(profile, PROFILE_MAX_CHARS)), + '', + '## New exchange', + `USER: ${clean(turn.user, FOLD_USER_MAX)}`, + `CLAUDE: ${clean(turn.assistant, FOLD_ASSISTANT_MAX)}`, + ...(feedback.length ? ['', '## Feedback the user gave during this exchange', ...feedback] : []), + ].join('\n') +} + +/** Index just past the object that opens at `start`, or -1 when it never closes. */ +const objectEnd = (text: string, start: number): number => { + let depth = 0 + let inString = false + let escaped = false + for (let i = start; i < text.length; i += 1) { + const ch = text.charAt(i) + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + } else if (ch === '"') { + inString = true + } else if (ch === '{') { + depth += 1 + } else if (ch === '}') { + depth -= 1 + if (depth === 0) return i + 1 + } + } + return -1 +} + +/** Every balanced {...} slice of the text, in order of where it starts. */ +const objectCandidates = (text: string): readonly string[] => { + const found: string[] = [] + for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) { + const end = objectEnd(text, start) + if (end !== -1) found.push(text.slice(start, end)) + } + return found +} + +const tryParse = (s: string): unknown => { + try { + return JSON.parse(s) + } catch { + return undefined + } +} + +/** A model asked to "return the previous text" may echo our placeholder; that means no change. */ +const PLACEHOLDER = /^\(?\s*(?:empty|none|n\/a|nothing(?:\s+(?:known|recorded))?(?:\s+yet)?)\s*\)?\.?$/i + +/** A note as the model wrote it: cut to size, with a placeholder read as blank. */ +const note = (text: string, max: number): string => { + const t = cutAtWord(text, max) + return PLACEHOLDER.test(t) ? '' : t +} + +/** Reads the fold reply strictly: both notes must be strings, or the whole reply is rejected. */ +export function parseFold(reply: string): FoldResult | null { + if (typeof reply !== 'string') return null + const parsed = objectCandidates(reply.slice(0, MAX_REPLY_CHARS)) + .map(tryParse) + .find((v): v is Readonly> => isRecord(v) && typeof v.brief === 'string' && typeof v.profile === 'string') + if (!parsed) return null + return { brief: note(String(parsed.brief), BRIEF_MAX_CHARS), profile: note(String(parsed.profile), PROFILE_MAX_CHARS) } +} + +/** Only real exchanges are worth a fold: not slash commands, not one-liners. */ +export function shouldFold(turn: FoldTurn, minAssistantChars: number = DEFAULT_MIN_ASSISTANT_CHARS): boolean { + const user = typeof turn.user === 'string' ? turn.user.trim() : '' + const assistant = typeof turn.assistant === 'string' ? turn.assistant.trim() : '' + return user !== '' && !user.startsWith('/') && assistant.length >= minAssistantChars +} + +/** Folds a result into new memories; an empty note never replaces a known one. */ +export function applyFold( + project: ProjectMemory, + profile: ProfileMemory, + result: FoldResult, + newSignals: readonly Signal[], + at: number, +): { readonly project: ProjectMemory; readonly profile: ProfileMemory } { + const brief = note(scrub(result.brief), BRIEF_MAX_CHARS) + const person = note(scrub(result.profile), PROFILE_MAX_CHARS) + const isFolded = brief !== '' || person !== '' + return { + project: { + brief: brief === '' ? project.brief : brief, + signals: newSignals.reduce(addSignal, project.signals), + updatedAt: at, + folds: isFolded ? project.folds + 1 : project.folds, + }, + profile: { profile: person === '' ? profile.profile : person, updatedAt: at }, + } +} + +export type BriefCommand = + | { readonly action: 'show' } + | { readonly action: 'set-brief'; readonly text: string } + | { readonly action: 'set-profile'; readonly text: string } + | { readonly action: 'forget-signals' } + | { readonly action: 'reset' } + | { readonly action: 'invalid'; readonly reason: string } + +const VALID_WORDS = 'show, brief , profile , forget, reset' + +const invalid = (reason: string): BriefCommand => ({ action: 'invalid', reason }) + +const needsNoText = (word: string, rest: string, command: BriefCommand): BriefCommand => + rest === '' ? command : invalid(`"${word}" takes no extra words. Type just "${word}" to confirm.`) + +const withText = (word: 'brief' | 'profile', text: string, max: number): BriefCommand => + text === '' + ? invalid(`"${word}" needs some text after it, for example: ${word} .`) + : word === 'brief' + ? { action: 'set-brief', text: cutAtWord(scrub(text), max) } + : { action: 'set-profile', text: cutAtWord(scrub(text), max) } + +/** Reads the arguments of the memory command; only the action word ignores case. */ +export function parseBriefCommand(args: string): BriefCommand { + const input = typeof args === 'string' ? args.trim() : '' + if (input === '') return { action: 'show' } + const [, first = '', rest = ''] = input.match(/^(\S+)\s*([\s\S]*)$/) ?? [] + switch (first.toLowerCase()) { + case 'show': + return { action: 'show' } + case 'brief': + return withText('brief', rest, BRIEF_MAX_CHARS) + case 'profile': + return withText('profile', rest, PROFILE_MAX_CHARS) + case 'forget': + return needsNoText('forget', rest, { action: 'forget-signals' }) + case 'reset': + return needsNoText('reset', rest, { action: 'reset' }) + default: + return invalid(`Unknown option "${first}". Use one of: ${VALID_WORDS}.`) + } +} + +const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim() + +const section = (title: string, body: string): string => `## ${title}\n${body}` + +/** The newest `limit` signals, with skipped suggestions held to a few so they never crowd out the user's words. */ +const forPrompt = (signals: readonly Signal[], limit: number): readonly Signal[] => { + const skipped = new Set( + signals + .map((s, i) => [s, i] as const) + .filter(([s]) => s.kind === 'ignored') + .slice(-IGNORED_SHOWN_MAX) + .map(([, i]) => i), + ) + return signals.filter((s, i) => s.kind !== 'ignored' || skipped.has(i)).slice(-limit) +} + +/** The memory block that opens the ? prompt. */ +export function renderMemory(project: ProjectMemory, profile: ProfileMemory, signalLimit: number = DEFAULT_SIGNAL_LIMIT): string { + const limit = Number.isFinite(signalLimit) ? Math.max(0, Math.floor(signalLimit)) : DEFAULT_SIGNAL_LIMIT + const recent = limit === 0 ? [] : forPrompt(project.signals, limit) + const feedback = recent.length ? recent.map(s => `- [${s.kind}] ${oneLine(s.text)}`).join('\n') : 'No feedback recorded yet.' + return [ + section('About the user', profile.profile.trim() || NOTHING_RECORDED), + section('The project so far', project.brief.trim() || NOTHING_RECORDED), + section('What the user has told us and how they react', feedback), + ].join('\n\n') +} diff --git a/token-ledger/hooks/lib/redact.ts b/token-ledger/hooks/lib/redact.ts new file mode 100644 index 0000000..bad1052 --- /dev/null +++ b/token-ledger/hooks/lib/redact.ts @@ -0,0 +1,174 @@ +// Copied from next-steps/hooks/lib/redact.ts (itself from popia-redactor); keep them in step. +// Pure detection and tokenising of South African personal information. +// No engine calls here, so the tests cover it directly. + +export type Kind = 'EMAIL' | 'SA_ID' | 'CARD' | 'PHONE' | 'BANK_ACC' | 'PASSPORT' | 'AMOUNT' | 'MEDIA' + +export type RedactOptions = { + readonly salt: string + readonly redactAmounts: boolean +} + +export type RedactResult = { + readonly text: string + readonly counts: Readonly>> +} + +/** Tokens hold letters only, so no later digit pattern can match inside one. */ +export const TOKEN_PATTERN = /\[(?:EMAIL|SA_ID|CARD|PHONE|BANK_ACC|PASSPORT|AMOUNT):[a-p]{8}\]/ + +export const luhnValid = (digits: string): boolean => { + if (!/^\d+$/.test(digits)) return false + let sum = 0 + for (let i = 0; i < digits.length; i += 1) { + let d = Number(digits[digits.length - 1 - i]) + if (i % 2 === 1) { + d *= 2 + if (d > 9) d -= 9 + } + sum += d + } + return sum % 10 === 0 +} + +const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + +/** 13 digits: YYMMDD, 4-digit sequence, citizenship 0/1/2, race digit, Luhn check. */ +export const isSaId = (digits: string): boolean => { + if (!/^\d{13}$/.test(digits)) return false + const month = Number(digits.slice(2, 4)) + const day = Number(digits.slice(4, 6)) + if (month < 1 || month > 12) return false + if (day < 1 || day > (DAYS_IN_MONTH[month - 1] ?? 0)) return false + if (!'012'.includes(digits[10] ?? '')) return false + return luhnValid(digits) +} + +/** FNV-1a over salt + value, spelled in the letters a-p: stable per session, no digits. */ +export const tokenFor = (kind: Kind, value: string, salt: string): string => { + let hash = 0x811c9dc5 + const input = `${salt}\u241f${kind}\u241f${value}` + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + const letters = hash + .toString(16) + .padStart(8, '0') + .replace(/[0-9a-f]/g, c => String.fromCharCode(97 + parseInt(c, 16))) + return `[${kind}:${letters}]` +} + +type Rule = { + readonly kind: Kind + readonly pattern: RegExp + /** Which capture group holds the sensitive part; 0 for the whole match. */ + readonly group: number + readonly accept: (value: string) => boolean +} + +const digitsOf = (s: string): string => s.replace(/\D/g, '') + +/** `logo@2x.png`, `pkg@1.2.3.tgz`: file names and package specs, not addresses. */ +const FILE_LIKE = /\.(?:png|jpe?g|gif|svg|webp|ico|tgz|zip|gz|js|mjs|cjs|ts|tsx|jsx|json|css|scss|map|lock|md|html?|py|rb|go|rs|java|php|vue|txt|log|csv|xml|ya?ml)$/i + +/** The same person spelled two ways ("082 123 4567", "+27821234567") gets one token. */ +const normalise = (kind: Kind, value: string): string => { + if (kind === 'EMAIL' || kind === 'PASSPORT') return value.toLowerCase() + const digits = digitsOf(value) + if (kind === 'PHONE') return digits.replace(/^(?:0027|27|0)/, '') + return digits +} + +const RULES: readonly Rule[] = [ + { + kind: 'EMAIL', + pattern: /(? !FILE_LIKE.test(v) && !/^git@/i.test(v), + }, + { + kind: 'SA_ID', + pattern: /(? isSaId(digitsOf(v)), + }, + { + kind: 'CARD', + pattern: /(? { + const d = digitsOf(v) + return d.length >= 13 && d.length <= 19 && /^[2-6]/.test(d) && luhnValid(d) + }, + }, + { + kind: 'PHONE', + pattern: /(? true, + }, + { + kind: 'BANK_ACC', + pattern: + /\b(?:acc(?:ount)?|acct|rekening(?:nommer)?)(?:\s*(?:no|nr|number|num|#))?\.?\s*[:#]?\s*(\d(?:[ -]?\d){5,15})(?!\d)/gi, + group: 1, + accept: () => true, + }, + { + kind: 'PASSPORT', + pattern: /\bpas(?:s)?(?:port|poort)(?:\s*(?:no|nr|number|#))?\.?\s*[:#]?\s*([A-Z]{0,2}\d{7,9})\b/gi, + group: 1, + accept: () => true, + }, +] + +const AMOUNT_RULE: Rule = { + kind: 'AMOUNT', + pattern: /(? true, +} + +const applyRule = ( + text: string, + rule: Rule, + salt: string, +): { readonly text: string; readonly count: number } => { + let count = 0 + const out = text.replace(rule.pattern, (match: string, ...rest: unknown[]) => { + const part = rule.group === 0 ? match : rest[rule.group - 1] + if (typeof part !== 'string' || !rule.accept(part)) return match + count += 1 + const token = tokenFor(rule.kind, normalise(rule.kind, part), salt) + return rule.group === 0 ? token : match.replace(part, token) + }) + return { text: out, count } +} + +/** Returns a new string with every detected item swapped for its token. */ +export const redact = (text: string, options: RedactOptions): RedactResult => { + const rules = options.redactAmounts ? [...RULES, AMOUNT_RULE] : RULES + return rules.reduce( + (acc, rule) => { + const step = applyRule(acc.text, rule, options.salt) + if (step.count === 0) return acc + return { + text: step.text, + counts: { ...acc.counts, [rule.kind]: (acc.counts[rule.kind] ?? 0) + step.count }, + } + }, + { text, counts: {} }, + ) +} + +export const totalOf = (counts: Readonly>>): number => + Object.values(counts).reduce((a, b) => a + (b ?? 0), 0) + +export const mergeCounts = ( + a: Readonly>>, + b: Readonly>>, +): Partial> => { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set + return Object.fromEntries([...keys].map(k => [k, (a[k] ?? 0) + (b[k] ?? 0)])) +} diff --git a/token-ledger/hooks/memoryEngine.ts b/token-ledger/hooks/memoryEngine.ts new file mode 100644 index 0000000..dab8465 --- /dev/null +++ b/token-ledger/hooks/memoryEngine.ts @@ -0,0 +1,249 @@ +// The engine side of the memory: where it is kept, when it is folded, and the +// /brief command. The pure rules (what a signal is, how a fold is read) live in +// lib/memory.ts and are tested there. +// +// Nothing here touches the engine's `$`: the engine only follows `$` inside +// register.tsx, so register.tsx hands this file a small set of ports, each a +// closure that spells its engine call out in full. + +import type { EngineInterface } from 'claude-code' + +import { INSIGHT_MODEL } from './lib/insights' +import { + addSignal, + applyFold, + asProfileMemory, + asProjectMemory, + buildFoldPrompt, + classifyUserMessage, + EMPTY_PROJECT, + FOLD_SYSTEM, + mergeExchanges, + parseBriefCommand, + parseFold, + projectKey, + renderMemory, + shouldFold, +} from './lib/memory' +import type { FoldResult, ProfileMemory, ProjectMemory, Signal } from './lib/memory' +import type { MemoryView } from './memoryView' + +export const MEMORY_PANE = 'brief' + +const PROFILE_STORE_KEY = 'mem:profile' +const FOLD_MAX_TOKENS = 2_000 +const FOLD_TIMEOUT_MS = 60_000 + +/** Everything the memory needs from the engine, built in register.tsx. */ +export type MemoryPorts = { + readonly storeGet: (key: string) => Promise + readonly storeSet: (key: string, value: unknown) => Promise + readonly root: () => Promise + readonly now: () => Promise + readonly complete: (request: Parameters[0]) => ReturnType + /** Puts the view in the value the /brief pane draws. */ + readonly show: (view: MemoryView) => Promise + /** Opens the /brief pane; true when a surface placed it. */ + readonly openPane: () => Promise + readonly closePane: () => Promise +} + +type Loaded = { + readonly storeKey: string + readonly name: string + readonly project: ProjectMemory + readonly profile: ProfileMemory +} + +const folderName = (root: string): string => root.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? root + +async function load(io: MemoryPorts): Promise { + const root = await io.root() + const storeKey = `mem:project:${projectKey(root)}` + const [project, profile] = await Promise.all([io.storeGet(storeKey), io.storeGet(PROFILE_STORE_KEY)]) + return { storeKey, name: folderName(root), project: asProjectMemory(project), profile: asProfileMemory(profile) } +} + +/** Writes both notes, then redraws the /brief pane from what was written. */ +async function save(io: MemoryPorts, loaded: Loaded, project: ProjectMemory, profile: ProfileMemory): Promise { + await io.storeSet(loaded.storeKey, project) + await io.storeSet(PROFILE_STORE_KEY, profile) + await show(io, { ...loaded, project, profile }) +} + +async function show(io: MemoryPorts, l: Loaded): Promise { + await io.show({ + projectName: l.name, + brief: l.project.brief, + profile: l.profile.profile, + signals: [...l.project.signals], + folds: l.project.folds, + updatedAt: Math.max(l.project.updatedAt, l.profile.updatedAt), + }) +} + +/** Loads the current project's memory into the pane's value. */ +export async function refreshMemoryView(io: MemoryPorts): Promise { + await show(io, await load(io)) +} + +/** + * The block `?` reads. `text` is '' when nothing is known yet. `hasBrief` is false + * while only feedback exists, so `?` keeps the chat history until a brief stands in for it. + */ +export async function memoryBlock(io: MemoryPorts): Promise<{ readonly text: string; readonly hasBrief: boolean }> { + const { project, profile } = await load(io) + const hasBrief = project.brief !== '' + if (!hasBrief && profile.profile === '' && project.signals.length === 0) return { text: '', hasBrief } + return { text: renderMemory(project, profile), hasBrief } +} + +// Every read-modify-write of the stored notes runs through this one line, so a +// slow fold, a dismissed suggestion list and a /brief edit can never overwrite each other. +let writeLine: Promise = Promise.resolve() + +function serial(job: () => Promise): Promise { + const run = writeLine.then(job, job) + writeLine = run.catch(() => undefined) + return run +} + +/** Adds feedback signals (picked or skipped suggestions) to this project's log. */ +export async function recordSignals(io: MemoryPorts, signals: readonly Signal[]): Promise { + if (signals.length === 0) return + await serial(async () => { + const l = await load(io) + const merged = signals.reduce((all, s) => addSignal(all, s), l.project.signals) + await save(io, l, { ...l.project, signals: merged }, l.profile) + }) +} + +/** Humans type prompts in the composer or through the remote bridge; notifications, schedules and peers are not them. */ +const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge'] + +// The last prompt the person typed, waiting for its turn to end. Taken once, so a +// continuation turn with no new prompt cannot learn from the old one again. +let typedPrompt = '' + +type PromptSeen = { + readonly text: string + readonly origin?: { readonly kind?: string } + readonly isMeta?: boolean + readonly command?: unknown +} + +/** Remembers a prompt only when a person typed it and it is not a slash command. */ +export function noteUserPrompt(p: PromptSeen): void { + const isHuman = HUMAN_ORIGINS.includes(p.origin?.kind ?? '') + const text = p.text.trim() + if (!isHuman || p.isMeta === true || p.command !== undefined || text === '' || text.startsWith('/')) return + typedPrompt = p.text +} + +const takePrompt = (): string => { + const text = typedPrompt + typedPrompt = '' + return text +} + +type Exchange = { readonly user: string; readonly assistant: string } + +// One fold at a time. A turn that ends meanwhile waits here, so none is dropped. +let isFolding = false +const waiting: Exchange[] = [] +const WAITING_MAX = 3 + +/** Haiku folds one exchange into fresh copies of the notes; the model call itself holds no lock. */ +async function foldOne(io: MemoryPorts, turn: Exchange): Promise { + const before = await load(io) + const reply = await io.complete({ + model: INSIGHT_MODEL, + system: FOLD_SYSTEM, + prompt: buildFoldPrompt(before.project.brief, before.profile.profile, { ...turn, signals: [] }), + maxTokens: FOLD_MAX_TOKENS, + effort: 'low', + timeoutMs: FOLD_TIMEOUT_MS, + }) + const result: FoldResult | null = reply.isAnswered ? parseFold(reply.text) : null + if (result === null || (result.brief === '' && result.profile === '')) return + const at = await io.now() + await serial(async () => { + const fresh = await load(io) + const next = applyFold(fresh.project, fresh.profile, result, [], at) + await save(io, fresh, next.project, next.profile) + }) +} + +/** + * After a real turn: remember the person's feedback at once, then let Haiku fold + * the exchange into the brief and profile in the background. Any failure leaves + * the old memory exactly as it was. + */ +export async function foldAfterTurn(io: MemoryPorts, answer: string): Promise { + const user = takePrompt() + if (user === '') return + try { + const kind = classifyUserMessage(user) + if (kind !== null) await recordSignals(io, [{ kind, text: user, at: await io.now() }]) + const turn: Exchange = { user, assistant: answer } + if (!shouldFold({ ...turn, signals: [] })) return + if (isFolding) { + // Past the limit, a turn joins the newest one waiting instead of being dropped. + const last = waiting.at(-1) + if (waiting.length < WAITING_MAX || last === undefined) waiting.push(turn) + else waiting[waiting.length - 1] = mergeExchanges(last, turn) + return + } + isFolding = true + try { + for (let next: Exchange | undefined = turn; next !== undefined; next = waiting.shift()) { + await foldOne(io, next).catch(() => undefined) + } + } finally { + isFolding = false + } + } catch { + // Memory is a help, never a risk to the turn: keep what was there. + } +} + +/** Runs one /brief command and answers in a line; `show` also opens the pane. */ +export async function runBriefCommand(io: MemoryPorts, args: string): Promise { + const cmd = parseBriefCommand(args) + if (cmd.action === 'invalid') return cmd.reason + if (cmd.action === 'show') { + await show(io, await load(io)) + const isPlaced = await io.openPane() + return isPlaced ? 'Showing what the mod knows about you and this project.' : 'Widen the terminal to see the memory pane.' + } + const now = await io.now() + return serial(async () => { + const l = await load(io) + switch (cmd.action) { + case 'set-brief': + await save(io, l, { ...l.project, brief: cmd.text, updatedAt: now }, l.profile) + return 'Project brief replaced.' + case 'set-profile': + await save(io, l, l.project, { profile: cmd.text, updatedAt: now }) + return 'Your profile replaced.' + case 'forget-signals': + await save(io, l, { ...l.project, signals: [], updatedAt: now }, l.profile) + return 'Feedback log cleared for this project.' + case 'reset': + await save(io, l, EMPTY_PROJECT, l.profile) + return "This project's brief and feedback cleared. Your profile is kept (replace it with /brief profile )." + } + }) +} + +/** The pane's own buttons. */ +export function memoryHandlers(io: MemoryPorts): { readonly forget: () => void; readonly close: () => void } { + return { + forget: () => { + void runBriefCommand(io, 'forget').catch(() => undefined) + }, + close: () => { + void io.closePane().catch(() => undefined) + }, + } +} diff --git a/token-ledger/hooks/memoryView.tsx b/token-ledger/hooks/memoryView.tsx new file mode 100644 index 0000000..99ee299 --- /dev/null +++ b/token-ledger/hooks/memoryView.tsx @@ -0,0 +1,70 @@ +// Pure drawing of the /brief pane: what the mod believes about the project and +// the user, so it can be read and corrected. Handlers come in from the hooks module. + +import type { Elements } from 'claude-code' + +import type { Signal } from './lib/memory' + +type Table = Pick + +export type MemoryView = { + readonly projectName: string + readonly brief: string + readonly profile: string + readonly signals: readonly Signal[] + readonly folds: number + readonly updatedAt: number +} + +const SIGNALS_SHOWN = 10 +const NOTHING = 'Nothing recorded yet. It builds up as you work.' + +const KIND_LABEL: Readonly> = { + correction: 'you corrected', + frustration: 'you were frustrated', + accepted: 'you used', + ignored: 'you skipped', +} + +const when = (at: number): string => + at > 0 ? new Date(at).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : 'never' + +export const memoryPane = ( + els: Table, + m: MemoryView, + on: { readonly forget: () => void; readonly close: () => void }, +) => { + const { Box, Text, Button } = els + const recent = m.signals.slice(-SIGNALS_SHOWN).reverse() + return ( + + About you + {m.profile === '' ? NOTHING : m.profile} + + {m.projectName === '' ? 'This project' : m.projectName} + {m.brief === '' ? NOTHING : m.brief} + + + What you've told it + {recent.length === 0 && No feedback recorded yet.} + {recent.map((s, i) => ( + + {KIND_LABEL[s.kind]}: {s.text} + + ))} + + + + Updated {when(m.updatedAt)} · {m.folds} turn{m.folds === 1 ? '' : 's'} folded in + + + Fix it: /brief brief <text> · /brief profile <text> · /brief forget · /brief reset + + + +