mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|