mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat: Read My Mind phase 2, the predictor and the brain button
The feature as pitched in docs/readmymind-plan.md: pressing the header
brain button predicts the prompt you were about to type, from the case's
intent profile plus everything the session already knows.
Backend:
- readmymind-context.ts: pure budgeted context assembler (9 ranked
sources: pending approval dialog, user goals, last assistant turn tail,
recent prompts, tool activity, git workspace signals, away context,
sibling sessions, rethink state; 30 KB budget, whole-section drop from
the bottom of the ranking, trust tiers stated in the prompt)
- readmymind-collectors.ts: transcript tail reader (the live watcher
keeps only a 500-char snippet) and git signal collection (execFile,
2s timeout, skipped for remote-SSH cases)
- readmymind-predictor.ts: one-shot claude -p in a throwaway tmux
session, opus by default (readMyMindModel setting), strict JSON
contract with 1-3 suggestions (continue / verify / redirect), newline
stripping, 90s timeout; mutable singleton so route tests can stub it
- POST /api/sessions/:id/readmymind: claude-mode only (400), one
prediction in flight per session (409 CONFLICT), rethink body
{ steer, rejected }; ownership via findSessionOrFail
Frontend:
- readmymind-ui.js (loadorder 11.3): header brain button, marker-hidden
until readMyMindEnabled is ON, desktop only (phone key is phase 3);
modal with editable suggestion + rationale and Send / Insert /
Rethink / Dismiss; suggestion text rendered via value/textContent only
and nothing ever auto-sends
- App Settings -> Panels checkbox for readMyMindEnabled; en + zh-CN
strings
Verified end to end against a live isolated instance: transcript
capture, a real opus prediction grounded in the stated goals, rethink
steering, the 409, and the browser modal incl. Insert leaving the text
unsubmitted on the composer. 41 new unit/route tests; full test:ci
sweep green (4680 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind collectors: the IO feeding the pure context
|
||||
* assembler (`readmymind-context.ts`).
|
||||
*
|
||||
* - `readTranscriptSignals()`: tail-reads the session's Claude transcript
|
||||
* JSONL for the full last assistant text plus recent tool calls. The live
|
||||
* `TranscriptWatcher` keeps only a 500-char snippet, no tool history, and
|
||||
* starts empty after a server restart, so prediction reads the file itself:
|
||||
* on-demand, bounded, cold-start-proof. The line parse is pure
|
||||
* (`parseTranscriptSignals`) for fixture tests.
|
||||
*
|
||||
* - `collectWorkspaceSignals()`: git branch/status/log via `execFile` in the
|
||||
* session's workingDir with a 2s timeout, plus `.changeset/*.md` presence.
|
||||
* Callers skip it for remote-SSH cases (workingDir is not local; Docker
|
||||
* cases are fine, the workspace is bind-mounted at the same host path).
|
||||
* Non-git dirs resolve to null and the section is simply omitted.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ========== Transcript signals ==========
|
||||
|
||||
/** How much of the transcript tail to read. Turns are append-only JSONL, so the tail holds the newest entries. */
|
||||
export const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
/** Safety cap on the extracted assistant text (the assembler truncates further). */
|
||||
const MAX_ASSISTANT_CHARS = 12_000;
|
||||
|
||||
/** Max recent tool calls retained. */
|
||||
export const MAX_TRANSCRIPT_TOOLS = 10;
|
||||
|
||||
const TOOL_DETAIL_KEYS = ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description'] as const;
|
||||
const MAX_TOOL_DETAIL_CHARS = 80;
|
||||
|
||||
export interface TranscriptSignals {
|
||||
lastAssistantText: string | null;
|
||||
recentTools: PredictionToolCall[];
|
||||
}
|
||||
|
||||
interface TranscriptBlock {
|
||||
type?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
/** One-line argument summary for a tool call, e.g. `Edit src/foo.ts` or `Bash npm test`. */
|
||||
function summarizeToolInput(input: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
for (const key of TOOL_DETAIL_KEYS) {
|
||||
const value = input[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.replace(/\s+/g, ' ').trim().slice(0, MAX_TOOL_DETAIL_CHARS);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transcript JSONL lines into prediction signals. Pure; malformed lines
|
||||
* are skipped (the tail read starts mid-file, so the first line usually is).
|
||||
*/
|
||||
export function parseTranscriptSignals(lines: string[], maxTools: number = MAX_TRANSCRIPT_TOOLS): TranscriptSignals {
|
||||
let lastAssistantText: string | null = null;
|
||||
const tools: (PredictionToolCall & { id?: string })[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let entry: { type?: string; message?: { content?: unknown } };
|
||||
try {
|
||||
entry = JSON.parse(line) as { type?: string; message?: { content?: unknown } };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = entry.message?.content;
|
||||
if (entry.type === 'assistant') {
|
||||
if (typeof content === 'string') {
|
||||
if (content.trim()) lastAssistantText = content.slice(0, MAX_ASSISTANT_CHARS);
|
||||
} else if (Array.isArray(content)) {
|
||||
const texts: string[] = [];
|
||||
for (const block of content as TranscriptBlock[]) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
texts.push(block.text);
|
||||
} else if (block.type === 'tool_use' && block.name) {
|
||||
tools.push({ name: block.name, detail: summarizeToolInput(block.input), id: block.id });
|
||||
}
|
||||
}
|
||||
if (texts.length > 0) lastAssistantText = texts.join('\n').slice(0, MAX_ASSISTANT_CHARS);
|
||||
}
|
||||
} else if (entry.type === 'user' && Array.isArray(content)) {
|
||||
for (const block of content as TranscriptBlock[]) {
|
||||
if (block.type === 'tool_result' && block.is_error && block.tool_use_id) {
|
||||
const tool = tools.find((t) => t.id === block.tool_use_id);
|
||||
if (tool) tool.failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lastAssistantText,
|
||||
recentTools: tools.slice(-maxTools).map(({ name, detail, failed }) => ({ name, detail, failed })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the transcript tail and extract prediction signals. Returns null when
|
||||
* the file is missing or unreadable (the sections are simply omitted).
|
||||
*/
|
||||
export async function readTranscriptSignals(transcriptPath: string): Promise<TranscriptSignals | null> {
|
||||
let handle;
|
||||
try {
|
||||
const info = await stat(transcriptPath);
|
||||
const offset = Math.max(0, info.size - TRANSCRIPT_TAIL_BYTES);
|
||||
const length = info.size - offset;
|
||||
if (length <= 0) return { lastAssistantText: null, recentTools: [] };
|
||||
|
||||
handle = await open(transcriptPath, 'r');
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, offset);
|
||||
const lines = buffer.toString('utf-8').split('\n');
|
||||
// A mid-file start point means the first line is a partial record.
|
||||
if (offset > 0) lines.shift();
|
||||
return parseTranscriptSignals(lines);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Workspace signals ==========
|
||||
|
||||
const GIT_TIMEOUT_MS = 2_000;
|
||||
const MAX_STATUS_LINES = 30;
|
||||
|
||||
/**
|
||||
* Collect git signals from a local workingDir. Null when the dir is not a git
|
||||
* repo (or git is unavailable); individual sub-signals fail soft.
|
||||
*/
|
||||
export async function collectWorkspaceSignals(workingDir: string): Promise<WorkspaceSignals | null> {
|
||||
const git = async (args: string[]): Promise<string> => {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd: workingDir,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 256 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
};
|
||||
|
||||
let branch: string;
|
||||
try {
|
||||
branch = (await git(['branch', '--show-current'])).trim();
|
||||
} catch {
|
||||
return null; // Not a git repo (or no git): the section is omitted.
|
||||
}
|
||||
|
||||
const signals: WorkspaceSignals = { branch: branch || undefined };
|
||||
|
||||
try {
|
||||
const status = (await git(['status', '--short'])).trimEnd();
|
||||
signals.statusShort = status ? status.split('\n').slice(0, MAX_STATUS_LINES).join('\n') : '';
|
||||
} catch {
|
||||
// Fail soft: branch alone is still useful.
|
||||
}
|
||||
|
||||
try {
|
||||
signals.recentCommits = (await git(['log', '--oneline', '-5'])).trimEnd();
|
||||
} catch {
|
||||
// A repo with no commits yet: omit.
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(join(workingDir, '.changeset'));
|
||||
signals.hasChangesets = entries.some((name) => name.endsWith('.md') && name.toLowerCase() !== 'readme.md');
|
||||
} catch {
|
||||
// No .changeset dir: not a changesets repo.
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
Reference in New Issue
Block a user