mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Follow-up from a full-branch review pass (Opus) of the omp-mode integration: - Add pinning tests for resolveOmpConfigForCreate() (session-routes.ts), exported to make it testable: the exact "resume this OMP row from history" pipeline that mangleOmpWorkingDir's earlier bug lived in had zero coverage despite being the resolver module's whole reason to exist. - Log a warning when findLatestOmpSessionId() finds nothing on disk and continuation silently degrades to omp's own ambiguous --continue, in both call sites (session create and respawn pinning) - previously silent, making the degradation invisible to anyone debugging it. - Require an absolute cwd before trusting a session file's working directory in omp-transcript.ts's parser, so a corrupted/malformed session file can't point a downstream resume at a relative or empty path. - Document (don't speculatively fix) an unverified symlinked-$HOME edge case in mangleOmpWorkingDir(): the review's suggested realpath() fix assumes omp itself resolves symlinks before mangling, which is unconfirmed - guessing wrong there would trade one silent mismatch for a different one. - Incidental: fixed unrelated pre-existing prettier drift in session-routes.ts (antigravity/opencode dynamic import line-wrapping) that was blocking the pre-commit formatting gate on this file. Confirmed as a non-issue: the model-name regex allowing "/" is intentional (provider/model ids like "crof/glm-5.2" were used successfully in live testing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
/**
|
|
* @fileoverview Scan `~/.omp/agent/sessions/*/*.jsonl` for Past Sessions rows,
|
|
* the omp analog of what `scanProjectDir()` (session-routes.ts) does for
|
|
* Claude's own `~/.claude/projects` transcripts.
|
|
*
|
|
* Without this, an omp conversation exists ONLY as a Codeman-level live/
|
|
* persisted session record — delete that (a "Kill Tmux" close, or any other
|
|
* cleanup) and the conversation vanishes from Past Sessions entirely, even
|
|
* though `omp` itself never forgot it. Claude conversations don't have that
|
|
* problem because Codeman already reads them back from Claude's own
|
|
* transcript files independent of its own session bookkeeping; this gives
|
|
* omp conversations the same treatment.
|
|
*
|
|
* Each omp session file's SECOND line is a `{"type":"session","id":...,
|
|
* "cwd":...}` header carrying the real (unmangled) working directory and the
|
|
* session's own id directly — no need to reverse-engineer the mangled
|
|
* directory name the way Claude Code's own scanner has to (see
|
|
* `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt
|
|
* text comes from each `{"type":"message","message":{"role":"user",...}}`
|
|
* entry, giving a real first-message title instead of a bare case name.
|
|
*
|
|
* Unlike Claude's transcripts (which can run to tens of MB of tool-call
|
|
* output), an omp session file is the conversation only, so this reads each
|
|
* file whole rather than doing head/tail windows — bounded by a size cap so
|
|
* one unexpectedly huge file can't blow up memory.
|
|
*
|
|
* @module omp-transcript
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
function ompSessionsRoot(): string {
|
|
return join(homedir(), '.omp', 'agent', 'sessions');
|
|
}
|
|
|
|
/** Skip anything absurdly large rather than parsing it whole into memory. */
|
|
const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024;
|
|
|
|
/** Defensive cap on total files scanned across every directory, mirroring
|
|
* the Claude scanner's own instinct not to let one pathological tree stall
|
|
* a request — a real omp install has, at most, a few hundred of these. */
|
|
const MAX_OMP_SESSION_FILES = 2000;
|
|
|
|
export interface OmpHistorySession {
|
|
sessionId: string;
|
|
workingDir: string;
|
|
sizeBytes: number;
|
|
/** ISO timestamp, from the file's own mtime. */
|
|
lastModified: string;
|
|
firstPrompt?: string;
|
|
lastPrompt?: string;
|
|
}
|
|
|
|
function extractUserPromptText(message: unknown): string | undefined {
|
|
if (!message || typeof message !== 'object') return undefined;
|
|
const m = message as { role?: unknown; content?: unknown };
|
|
if (m.role !== 'user' || !Array.isArray(m.content)) return undefined;
|
|
const parts: string[] = [];
|
|
for (const block of m.content) {
|
|
if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') {
|
|
const text = (block as { text?: unknown }).text;
|
|
if (typeof text === 'string') parts.push(text);
|
|
}
|
|
}
|
|
const joined = parts.join(' ').trim();
|
|
return joined || undefined;
|
|
}
|
|
|
|
/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */
|
|
function parseOmpSessionFile(filePath: string): OmpHistorySession | null {
|
|
let stat: ReturnType<typeof statSync>;
|
|
try {
|
|
stat = statSync(filePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null;
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let sessionId: string | undefined;
|
|
let workingDir: string | undefined;
|
|
let firstPrompt: string | undefined;
|
|
let lastPrompt: string | undefined;
|
|
|
|
for (const line of raw.split('\n')) {
|
|
if (!line) continue;
|
|
let entry: unknown;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const e = entry as Record<string, unknown>;
|
|
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string' && e.cwd.startsWith('/')) {
|
|
// A corrupted or malformed session file could carry a relative or empty
|
|
// cwd; requiring an absolute path keeps a downstream resume attempt
|
|
// from being pointed at a nonsense working directory.
|
|
sessionId = e.id;
|
|
workingDir = e.cwd;
|
|
} else if (e.type === 'message') {
|
|
const prompt = extractUserPromptText(e.message);
|
|
if (prompt) {
|
|
if (!firstPrompt) firstPrompt = prompt;
|
|
lastPrompt = prompt;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sessionId || !workingDir) return null;
|
|
return {
|
|
sessionId,
|
|
workingDir,
|
|
sizeBytes: stat.size,
|
|
lastModified: stat.mtime.toISOString(),
|
|
firstPrompt,
|
|
lastPrompt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Scan every omp conversation on disk into Past-Sessions rows. Best-effort
|
|
* throughout: a missing `~/.omp` (never installed/used), an unreadable
|
|
* directory, or one corrupt file yields fewer rows rather than throwing —
|
|
* this feeds the same unified merge the Claude transcript scanner does, and
|
|
* one broken source must never blank the whole Past Sessions list.
|
|
*/
|
|
export function scanOmpSessionsHistory(): OmpHistorySession[] {
|
|
const root = ompSessionsRoot();
|
|
let dirEntries: string[];
|
|
try {
|
|
dirEntries = readdirSync(root);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const out: OmpHistorySession[] = [];
|
|
for (const dirName of dirEntries) {
|
|
if (out.length >= MAX_OMP_SESSION_FILES) break;
|
|
const dirPath = join(root, dirName);
|
|
let dirStat: ReturnType<typeof statSync>;
|
|
try {
|
|
dirStat = statSync(dirPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!dirStat.isDirectory()) continue;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = readdirSync(dirPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const file of files) {
|
|
if (out.length >= MAX_OMP_SESSION_FILES) break;
|
|
if (!file.endsWith('.jsonl')) continue;
|
|
try {
|
|
const parsed = parseOmpSessionFile(join(dirPath, file));
|
|
if (parsed) out.push(parsed);
|
|
} catch {
|
|
// One bad file must not sink the whole scan.
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|