mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16: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>
139 lines
6.7 KiB
TypeScript
139 lines
6.7 KiB
TypeScript
/**
|
|
* @fileoverview Read My Mind routes: intent profiles + the predictor.
|
|
*
|
|
* Per-case intent profiles feeding the Read My Mind predictor
|
|
* (docs/readmymind-plan.md):
|
|
* - `GET /api/sessions/:id/intent`: the profile for the session's case
|
|
* - `PUT /api/sessions/:id/intent`: replace the goals text
|
|
* - `DELETE /api/sessions/:id/intent`: forget the case's profile
|
|
* - `POST /api/sessions/:id/readmymind`: predict the user's next prompt
|
|
*
|
|
* The profile is keyed by owner + workingDir, so multi-user scoping is
|
|
* structural; session ownership is still enforced via `findSessionOrFail`
|
|
* (with `req`, so a foreign session id 404s) to keep the session-routes
|
|
* no-existence-leak policy.
|
|
*
|
|
* Deliberately session-scoped rather than a raw `/api/intents/:key` surface:
|
|
* the session resolves owner + workingDir server-side, so a caller can never
|
|
* address another case's profile by guessing keys.
|
|
*
|
|
* Predict gathers every signal Codeman already has (intent profile, pending
|
|
* approval dialog, transcript tail, git state, run-summary events, sibling
|
|
* sessions), assembles a budgeted prompt via the pure
|
|
* `buildPredictionContext()`, and runs the one-shot predictor. Claude-mode
|
|
* only (400: capture and transcripts exist for nothing else), one prediction
|
|
* in flight per session (409 CONFLICT), and suggestions are only ever
|
|
* RETURNED, never sent: the human click in the modal is the boundary, which
|
|
* is also the prompt-injection mitigation for observed content.
|
|
*
|
|
* Registrations use the bare `app.<method>('path', ...)` + `req.params as`
|
|
* shape (session-routes style): these endpoints are documented in the agent
|
|
* skill, and the endpoints.md drift test's scanner does not see registrations
|
|
* with a generic between the method and the path.
|
|
*/
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
|
import { IntentGoalsSchema, ReadMyMindPredictSchema } from '../schemas.js';
|
|
import { parseBody, findSessionOrFail } from '../route-helpers.js';
|
|
import { intentStore } from '../../intent-store.js';
|
|
import { approvalInbox } from '../approval-inbox.js';
|
|
import { hooksAvailableForMode } from '../session-wait-registry.js';
|
|
import { buildPredictionContext, type PredictionContextInputs } from '../../readmymind-context.js';
|
|
import { collectWorkspaceSignals, readTranscriptSignals } from '../../readmymind-collectors.js';
|
|
import { readMyMindPredictor } from '../../readmymind-predictor.js';
|
|
import type { ConfigPort, InfraPort, SessionPort } from '../ports/index.js';
|
|
|
|
/** One prediction in flight per session; a second POST while running is a 409. */
|
|
const predictionsInFlight = new Set<string>();
|
|
|
|
export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort & InfraPort): void {
|
|
app.get('/api/sessions/:id/intent', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
return { success: true, data: { intent: intentStore.getProfile(session.owner, session.workingDir) } };
|
|
});
|
|
|
|
app.put('/api/sessions/:id/intent', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const body = parseBody(IntentGoalsSchema, req.body);
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
return { success: true, data: { intent: intentStore.setGoals(session.owner, session.workingDir, body.goals) } };
|
|
});
|
|
|
|
app.delete('/api/sessions/:id/intent', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
return { success: true, data: { deleted: intentStore.deleteProfile(session.owner, session.workingDir) } };
|
|
});
|
|
|
|
app.post('/api/sessions/:id/readmymind', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const body = parseBody(ReadMyMindPredictSchema, req.body ?? {});
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
|
|
if (!hooksAvailableForMode(session.mode)) {
|
|
reply.code(400);
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Read My Mind predicts claude-mode sessions only');
|
|
}
|
|
if (predictionsInFlight.has(id)) {
|
|
reply.code(409);
|
|
return createErrorResponse(ApiErrorCode.CONFLICT, 'A prediction is already running for this session');
|
|
}
|
|
|
|
predictionsInFlight.add(id);
|
|
try {
|
|
const profile = intentStore.getProfile(session.owner, session.workingDir);
|
|
const pending = approvalInbox.getForSession(id);
|
|
const transcriptPath = ctx.getTranscriptPath(id);
|
|
const transcript = transcriptPath ? await readTranscriptSignals(transcriptPath) : null;
|
|
// Remote-SSH cases skip git: workingDir is not local. Docker cases are
|
|
// fine (the workspace is bind-mounted at the same host path).
|
|
const workspace = session.remote ? null : await collectWorkspaceSignals(session.workingDir);
|
|
|
|
const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts;
|
|
const tracker = ctx.runSummaryTrackers.get(id);
|
|
const awayEvents = (tracker?.getRecentEvents(15) ?? [])
|
|
.filter((ev) => lastPromptTs === undefined || ev.timestamp >= lastPromptTs)
|
|
.map((ev) => ({ timestamp: ev.timestamp, title: ev.title, details: ev.details }));
|
|
|
|
const siblings = [...ctx.sessions.values()]
|
|
.filter((s) => s.id !== id && s.workingDir === session.workingDir && s.status !== 'stopped')
|
|
.map((s) => ({ name: s.name, mode: s.mode, working: s.isWorking }));
|
|
|
|
const inputs: PredictionContextInputs = {
|
|
pendingDialog: pending
|
|
? {
|
|
kind: pending.kind,
|
|
toolName: pending.toolName,
|
|
message: pending.message,
|
|
context: pending.context,
|
|
options: pending.options,
|
|
}
|
|
: undefined,
|
|
goals: profile.goals,
|
|
lastAssistantText: transcript?.lastAssistantText ?? undefined,
|
|
recentPrompts: profile.recentPrompts.map((p) => ({ ts: p.ts, text: p.text })),
|
|
recentTools: transcript?.recentTools,
|
|
workspace: workspace ?? undefined,
|
|
awaySinceMs: lastPromptTs !== undefined ? Date.now() - lastPromptTs : undefined,
|
|
awayEvents,
|
|
siblings,
|
|
steer: body.steer,
|
|
rejected: body.rejected,
|
|
};
|
|
|
|
const { prompt } = buildPredictionContext(inputs);
|
|
const model = await ctx.getReadMyMindModel();
|
|
const result = await readMyMindPredictor.predict({ sessionId: id, prompt, model });
|
|
return { success: true, data: { suggestions: result.suggestions, durationMs: result.durationMs } };
|
|
} catch (err) {
|
|
reply.code(502);
|
|
const message = err instanceof Error ? err.message : 'Prediction failed';
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, message);
|
|
} finally {
|
|
predictionsInFlight.delete(id);
|
|
}
|
|
});
|
|
}
|