nomoremeatproxy 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -3
- package/bin/nomeat.mjs +365 -0
- package/docs/assets/not-a-meat-proxy.png +0 -0
- package/docs/usage.md +466 -0
- package/package.json +59 -6
- package/src/adapters/claude-attended.mjs +51 -0
- package/src/adapters/claude.mjs +158 -0
- package/src/adapters/codex-app.mjs +58 -0
- package/src/adapters/codex-cli.mjs +38 -0
- package/src/adapters/codex-daemon.mjs +47 -0
- package/src/adapters/codex-transport.mjs +90 -0
- package/src/adapters/codex.mjs +268 -0
- package/src/adapters/opener.mjs +29 -0
- package/src/call.mjs +193 -0
- package/src/check.mjs +46 -0
- package/src/doctor-live.mjs +123 -0
- package/src/doctor.mjs +213 -0
- package/src/history/claude.mjs +263 -0
- package/src/history/codex.mjs +164 -0
- package/src/history/compact.mjs +149 -0
- package/src/history/folder.mjs +10 -0
- package/src/history/index.mjs +35 -0
- package/src/history/match.mjs +20 -0
- package/src/history/render.mjs +23 -0
- package/src/ledger.mjs +118 -0
- package/src/listen.mjs +36 -0
- package/src/listener-lease.mjs +88 -0
- package/src/mailbox.mjs +145 -0
- package/src/orient.mjs +72 -0
- package/src/peers.mjs +18 -0
- package/src/remote-control-store.mjs +116 -0
- package/src/remote-control.mjs +64 -0
- package/src/standing.mjs +54 -0
- package/src/stats.mjs +65 -0
- package/src/usage.mjs +44 -0
- package/src/writable.mjs +49 -0
- package/templates/peer-calls.md +136 -0
- package/tools/doctor.mjs +109 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Claude history: list and read Claude Code sessions from their local session files. The
|
|
2
|
+
// file format is not a documented interface, so every field is read defensively.
|
|
3
|
+
// Sections: records (pure) / files / find and read / compact conversation / message search.
|
|
4
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import { sameFolder } from './folder.mjs';
|
|
9
|
+
import { matchedOn, matchItem, textWords } from './match.mjs';
|
|
10
|
+
import { clipItems, firstLine, lastTurns } from './render.mjs';
|
|
11
|
+
|
|
12
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
// A search inside messages reads at most this many of the most recent session files. On the
|
|
14
|
+
// development machine, reading all 46 files there (420 MB) took about 2 seconds.
|
|
15
|
+
export const TEXT_SCAN = 200;
|
|
16
|
+
|
|
17
|
+
// --- records ---
|
|
18
|
+
|
|
19
|
+
// The text the user typed, or null for tool results, injected records and side conversations.
|
|
20
|
+
function promptText(record) {
|
|
21
|
+
if (record.type !== 'user' || record.isMeta || record.isSidechain) return null;
|
|
22
|
+
const content = record.message?.content;
|
|
23
|
+
if (typeof content === 'string') return content;
|
|
24
|
+
if (!Array.isArray(content) || content.some((block) => block.type === 'tool_result')) return null;
|
|
25
|
+
return content.filter((block) => block.type === 'text').map((block) => block.text).join('\n') || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns null for blocks that are not surfaced, such as the model's private reasoning.
|
|
29
|
+
export function describeClaudeBlock(block) {
|
|
30
|
+
if (block.type === 'text') return { kind: 'assistant', text: block.text ?? '' };
|
|
31
|
+
if (block.type !== 'tool_use') return null;
|
|
32
|
+
const detail = block.input?.command ?? block.input?.file_path ?? block.input?.description ?? '';
|
|
33
|
+
return { kind: 'tool', text: detail ? `${block.name}: ${detail}` : String(block.name) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Adds one record to the session and returns the items it added.
|
|
37
|
+
function foldClaudeRecord(session, record) {
|
|
38
|
+
if (record.timestamp && record.timestamp > (session.updatedAt ?? '')) session.updatedAt = record.timestamp;
|
|
39
|
+
if (record.cwd) session.cwd = record.cwd;
|
|
40
|
+
if (record.type === 'custom-title' && record.customTitle) session.title = record.customTitle;
|
|
41
|
+
const prompt = promptText(record);
|
|
42
|
+
if (prompt !== null) {
|
|
43
|
+
const item = { kind: 'user', text: prompt };
|
|
44
|
+
session.turns.push({ startedAt: record.timestamp ?? null, items: [item] });
|
|
45
|
+
return [item];
|
|
46
|
+
}
|
|
47
|
+
if (record.type !== 'assistant' || record.isSidechain || session.turns.length === 0) return [];
|
|
48
|
+
const blocks = Array.isArray(record.message?.content) ? record.message.content : [];
|
|
49
|
+
const items = blocks.map(describeClaudeBlock).filter(Boolean);
|
|
50
|
+
session.turns.at(-1).items.push(...items);
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const newSession = () => ({ title: '', cwd: null, updatedAt: null, turns: [] });
|
|
55
|
+
|
|
56
|
+
function finishSession(session) {
|
|
57
|
+
if (!session.title) session.title = firstLine(session.turns[0]?.items[0]?.text);
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function foldClaudeRecords(records) {
|
|
62
|
+
const session = newSession();
|
|
63
|
+
for (const record of records) foldClaudeRecord(session, record);
|
|
64
|
+
return finishSession(session);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A record, or null for a line that is torn, damaged or not a record at all.
|
|
68
|
+
function parseRecord(line) {
|
|
69
|
+
try {
|
|
70
|
+
const record = JSON.parse(line);
|
|
71
|
+
return record && typeof record === 'object' && !Array.isArray(record) ? record : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- files ---
|
|
78
|
+
|
|
79
|
+
export function claudeProjectsDir(env = process.env) {
|
|
80
|
+
return path.join(env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'), 'projects');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sessionFiles(projectsDir) {
|
|
84
|
+
if (!existsSync(projectsDir)) return [];
|
|
85
|
+
return readdirSync(projectsDir, { withFileTypes: true })
|
|
86
|
+
.filter((entry) => entry.isDirectory())
|
|
87
|
+
.flatMap((project) =>
|
|
88
|
+
readdirSync(path.join(projectsDir, project.name))
|
|
89
|
+
.filter((name) => UUID.test(name.replace(/\.jsonl$/, '')) && name.endsWith('.jsonl'))
|
|
90
|
+
.map((name) => ({ threadId: name.replace(/\.jsonl$/, ''), file: path.join(projectsDir, project.name, name) })),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadSession(file) {
|
|
95
|
+
// A session being written can end in a torn line.
|
|
96
|
+
return foldClaudeRecords(sessionRecords(file));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sessionRecords = (file) => readFileSync(file, 'utf8').split('\n').map(parseRecord).filter(Boolean);
|
|
100
|
+
|
|
101
|
+
// --- find and read ---
|
|
102
|
+
|
|
103
|
+
export function findClaudeSessions({ search, limit, project, projectsDir = claudeProjectsDir() }) {
|
|
104
|
+
const files = sessionFiles(projectsDir);
|
|
105
|
+
const needle = String(search ?? '').toLowerCase();
|
|
106
|
+
const matches = files
|
|
107
|
+
.map(({ threadId, file }) => ({ threadId, ...loadSession(file) }))
|
|
108
|
+
.filter((session) => session.title.toLowerCase().includes(needle) && (!project || sameFolder(session.cwd, project)))
|
|
109
|
+
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
|
|
110
|
+
return {
|
|
111
|
+
side: 'claude',
|
|
112
|
+
matchedOn: project ? 'title, within the project folder' : 'title',
|
|
113
|
+
scope: `${files.length} Claude Code session files on this machine`,
|
|
114
|
+
more: matches.length > limit,
|
|
115
|
+
threads: matches.slice(0, limit).map(({ threadId, title, updatedAt, cwd }) => ({ threadId, title, updatedAt, cwd })),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sessionEntry(threadId, projectsDir) {
|
|
120
|
+
// The Claude desktop app shows the same id with a `local_` prefix.
|
|
121
|
+
const id = String(threadId ?? '').replace(/^local_/, '');
|
|
122
|
+
if (!UUID.test(id)) throw new Error(`invalid Claude session id: ${JSON.stringify(threadId)}`);
|
|
123
|
+
const entry = sessionFiles(projectsDir).find((candidate) => candidate.threadId === id);
|
|
124
|
+
if (!entry) throw new Error(`no Claude session ${id} on this machine`);
|
|
125
|
+
return { id, file: entry.file };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readClaudeSession({ threadId, turns, maxChars, projectsDir = claudeProjectsDir() }) {
|
|
129
|
+
const { id, file } = sessionEntry(threadId, projectsDir);
|
|
130
|
+
const session = loadSession(file);
|
|
131
|
+
return {
|
|
132
|
+
side: 'claude',
|
|
133
|
+
threadId: id,
|
|
134
|
+
title: session.title,
|
|
135
|
+
updatedAt: session.updatedAt,
|
|
136
|
+
cwd: session.cwd,
|
|
137
|
+
turnsTotal: session.turns.length,
|
|
138
|
+
turns: lastTurns(session.turns, turns).map((turn) => ({ startedAt: turn.startedAt, items: clipItems(turn.items, maxChars) })),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- compact conversation ---
|
|
143
|
+
|
|
144
|
+
export function claudeConversation(records) {
|
|
145
|
+
const turns = [];
|
|
146
|
+
for (const record of records) {
|
|
147
|
+
if (record.type !== 'user' && record.type !== 'assistant') continue;
|
|
148
|
+
const content = record.message?.content;
|
|
149
|
+
const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
|
|
150
|
+
const text = blocks.filter(block => block?.type === 'text').map(block => block.text ?? '').join('\n');
|
|
151
|
+
// Structured provenance wins. Old local command records lack it: recognize only complete
|
|
152
|
+
// machine envelopes, not mentions of /compact or task notifications in ordinary prose.
|
|
153
|
+
const context = record.isCompactSummary === true;
|
|
154
|
+
const synthetic = record.isMeta || record.isSidechain || ['task-notification', 'peer'].includes(record.origin?.kind) ||
|
|
155
|
+
record.turnOrigin === 'task_notification' ||
|
|
156
|
+
/^<command-name>\/compact<\/command-name>(?:\s*<command-message>[\s\S]*<\/command-message>)?(?:\s*<command-args>[\s\S]*<\/command-args>)?\s*$/.test(text) ||
|
|
157
|
+
/^<local-command-stdout>Compacted [\s\S]*<\/local-command-stdout>\s*$/.test(text);
|
|
158
|
+
const omission = context ? 'context' : synthetic ? 'synthetic' : null;
|
|
159
|
+
const items = blocks.flatMap(block => {
|
|
160
|
+
if (!block || typeof block !== 'object') return [{ kind: 'unsupported', text: '', omission: 'privateOrUnsupported' }];
|
|
161
|
+
if (block.type === 'text') return [{ kind: record.type === 'user' ? 'user' : 'assistant', text: String(block.text ?? ''), ...(omission ? { omission } : {}) }];
|
|
162
|
+
const described = describeClaudeBlock(block);
|
|
163
|
+
if (described) return [{ ...described, ...(omission ? { omission } : {}) }];
|
|
164
|
+
if (block.type === 'tool_result') return [{ kind: 'tool-result', text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? ''), ...(omission ? { omission } : {}) }];
|
|
165
|
+
// Never surface private reasoning, even with --tools; count its exclusion without text.
|
|
166
|
+
return [{ kind: 'private', text: '', omission: 'privateOrUnsupported' }];
|
|
167
|
+
});
|
|
168
|
+
const startsTurn = record.type === 'user' && !blocks.some(block => block?.type === 'tool_result');
|
|
169
|
+
if (startsTurn || turns.length === 0) turns.push({ startedAt: record.timestamp ?? null, items: [] });
|
|
170
|
+
turns.at(-1).items.push(...items);
|
|
171
|
+
}
|
|
172
|
+
return turns;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function readClaudeConversation({ threadId, projectsDir = claudeProjectsDir() }) {
|
|
176
|
+
const { id, file } = sessionEntry(threadId, projectsDir);
|
|
177
|
+
return { side: 'claude', threadId: id, turns: claudeConversation(sessionRecords(file)) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- search inside messages ---
|
|
181
|
+
|
|
182
|
+
// Reads a session file line by line up to its first matching message, or to the end when the
|
|
183
|
+
// final title is needed. Limit: a title set after the match is not seen when reading stops.
|
|
184
|
+
// One assistant message is written as several records that share its message id, so their
|
|
185
|
+
// text is gathered before matching; tool calls and tool output are left out.
|
|
186
|
+
async function firstMatch(file, words, readToEnd) {
|
|
187
|
+
const session = newSession();
|
|
188
|
+
let match = null;
|
|
189
|
+
let open = null; // the message being gathered: { key, kind, text, at }
|
|
190
|
+
const close = () => {
|
|
191
|
+
const found = !match && open && matchItem(open, words);
|
|
192
|
+
if (found) match = { ...found, at: open.at };
|
|
193
|
+
open = null;
|
|
194
|
+
};
|
|
195
|
+
const input = createReadStream(file, 'utf8');
|
|
196
|
+
try {
|
|
197
|
+
for await (const line of readline.createInterface({ input, crlfDelay: Infinity })) {
|
|
198
|
+
const record = parseRecord(line);
|
|
199
|
+
if (!record) continue;
|
|
200
|
+
let said;
|
|
201
|
+
try {
|
|
202
|
+
said = foldClaudeRecord(session, record).filter((item) => item.kind === 'user' || item.kind === 'assistant');
|
|
203
|
+
} catch {
|
|
204
|
+
continue; // A record with damaged contents is skipped like a damaged line.
|
|
205
|
+
}
|
|
206
|
+
if (said.length === 0) continue;
|
|
207
|
+
const key = record.message?.id ?? Symbol('one record');
|
|
208
|
+
if (open?.key !== key) close();
|
|
209
|
+
if (match && !readToEnd) break;
|
|
210
|
+
const text = said.map((item) => item.text).join('\n');
|
|
211
|
+
if (open) open.text += `\n${text}`;
|
|
212
|
+
else open = { key, kind: said[0].kind, text, at: record.timestamp ?? null };
|
|
213
|
+
}
|
|
214
|
+
close();
|
|
215
|
+
} finally {
|
|
216
|
+
input.destroy();
|
|
217
|
+
}
|
|
218
|
+
return { session: finishSession(session), match };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function searchClaudeSessions({ search, text, limit, project, projectsDir = claudeProjectsDir(), bound = TEXT_SCAN }) {
|
|
222
|
+
// A file that cannot be read, or is gone since the listing, is skipped and counted.
|
|
223
|
+
let unreadable = 0;
|
|
224
|
+
const files = sessionFiles(projectsDir)
|
|
225
|
+
.flatMap((entry) => {
|
|
226
|
+
try {
|
|
227
|
+
return [{ ...entry, modified: statSync(entry.file).mtime }];
|
|
228
|
+
} catch {
|
|
229
|
+
unreadable += 1;
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
.sort((a, b) => b.modified - a.modified);
|
|
234
|
+
const words = textWords(text);
|
|
235
|
+
const needle = String(search ?? '').toLowerCase();
|
|
236
|
+
const threads = [];
|
|
237
|
+
let looked = 0;
|
|
238
|
+
for (const { threadId, file, modified } of files.slice(0, bound)) {
|
|
239
|
+
if (threads.length > limit) break;
|
|
240
|
+
looked += 1;
|
|
241
|
+
let session;
|
|
242
|
+
let match;
|
|
243
|
+
try {
|
|
244
|
+
({ session, match } = await firstMatch(file, words, Boolean(needle)));
|
|
245
|
+
} catch {
|
|
246
|
+
unreadable += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!match || !session.title.toLowerCase().includes(needle) || (project && !sameFolder(session.cwd, project))) continue;
|
|
250
|
+
// The file's last change stands for when the chat was updated, since reading may stop early.
|
|
251
|
+
threads.push({ threadId, title: session.title, updatedAt: modified.toISOString(), cwd: session.cwd, match });
|
|
252
|
+
}
|
|
253
|
+
const cut = looked === bound && files.length > bound ? `; the search stops at ${bound}, so ${files.length - bound} older ones were not searched` : '';
|
|
254
|
+
return {
|
|
255
|
+
side: 'claude',
|
|
256
|
+
matchedOn: matchedOn({ search, text, project }),
|
|
257
|
+
scope:
|
|
258
|
+
`the ${looked} most recent of ${files.length} Claude Code session files on this machine` +
|
|
259
|
+
`${unreadable ? `; ${unreadable} could not be read` : ''}${cut}`,
|
|
260
|
+
more: threads.length > limit,
|
|
261
|
+
threads: threads.slice(0, limit),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Codex history: list and read the threads the Codex desktop app shows, through app-server.
|
|
2
|
+
// Sections: descriptions / legacy list/read / compact conversation / message search.
|
|
3
|
+
import { withCodexServer } from '../adapters/codex-transport.mjs';
|
|
4
|
+
import { sameFolder } from './folder.mjs';
|
|
5
|
+
import { matchedOn, matchItem, textWords } from './match.mjs';
|
|
6
|
+
import { clipItems, firstLine, lastTurns } from './render.mjs';
|
|
7
|
+
|
|
8
|
+
const toIso = (seconds) => (typeof seconds === 'number' ? new Date(seconds * 1000).toISOString() : null);
|
|
9
|
+
|
|
10
|
+
// Returns null for items that are not surfaced, such as the model's private reasoning.
|
|
11
|
+
export function describeCodexItem(item) {
|
|
12
|
+
switch (item.type) {
|
|
13
|
+
case 'userMessage':
|
|
14
|
+
return { kind: 'user', text: (item.content ?? []).filter((part) => part.type === 'text').map((part) => part.text).join('\n') };
|
|
15
|
+
case 'agentMessage':
|
|
16
|
+
return { kind: 'assistant', text: item.text ?? '' };
|
|
17
|
+
case 'plan':
|
|
18
|
+
return { kind: 'plan', text: item.text ?? '' };
|
|
19
|
+
case 'commandExecution':
|
|
20
|
+
return { kind: 'command', text: typeof item.exitCode === 'number' ? `${item.command} (exit ${item.exitCode})` : String(item.command) };
|
|
21
|
+
case 'fileChange':
|
|
22
|
+
return { kind: 'file-change', text: (item.changes ?? []).map((change) => change.path).join('\n') };
|
|
23
|
+
case 'mcpToolCall':
|
|
24
|
+
return { kind: 'tool', text: [item.server, item.tool].filter(Boolean).join('.') };
|
|
25
|
+
case 'dynamicToolCall':
|
|
26
|
+
return { kind: 'tool', text: [item.namespace, item.tool].filter(Boolean).join('.') };
|
|
27
|
+
case 'webSearch':
|
|
28
|
+
return { kind: 'web-search', text: item.query ?? '' };
|
|
29
|
+
case 'reasoning':
|
|
30
|
+
return null;
|
|
31
|
+
default:
|
|
32
|
+
return { kind: item.type, text: '' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function summarizeCodexThread(thread) {
|
|
37
|
+
return {
|
|
38
|
+
threadId: thread.id,
|
|
39
|
+
title: thread.name || firstLine(thread.preview),
|
|
40
|
+
updatedAt: toIso(thread.updatedAt),
|
|
41
|
+
cwd: thread.cwd ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read from outside, a turn another process is still running reports `interrupted` (see
|
|
46
|
+
// findRequestTurn). A newer turn means an older one ended, so only the newest is in doubt.
|
|
47
|
+
export const shownStatus = (status, newest) => (newest && status === 'interrupted' ? 'interrupted, or still running' : status ?? null);
|
|
48
|
+
|
|
49
|
+
export function renderCodexThread(thread, { turns, maxChars }) {
|
|
50
|
+
const all = thread.turns ?? [];
|
|
51
|
+
return {
|
|
52
|
+
side: 'codex',
|
|
53
|
+
...summarizeCodexThread(thread),
|
|
54
|
+
turnsTotal: all.length,
|
|
55
|
+
turns: lastTurns(all, turns).map((turn) => ({
|
|
56
|
+
startedAt: toIso(turn.startedAt),
|
|
57
|
+
status: shownStatus(turn.status, turn === all.at(-1)),
|
|
58
|
+
items: clipItems((turn.items ?? []).map(describeCodexItem).filter(Boolean), maxChars),
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Codex compares a thread's folder in its own way, so the folder is matched here instead, over
|
|
64
|
+
// the newest threads only. The result says how far back that reached. thread/list returns at
|
|
65
|
+
// most 100 threads in one request, whatever larger limit is asked for.
|
|
66
|
+
const LIST_MAX = 100;
|
|
67
|
+
|
|
68
|
+
export function threadsInFolder(threads, project) {
|
|
69
|
+
return project ? threads.filter((thread) => sameFolder(thread.cwd, project)) : threads;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function findCodexThreads({ search, limit, project }) {
|
|
73
|
+
const result = await withCodexServer((client) =>
|
|
74
|
+
client.request('thread/list', { searchTerm: search || null, limit: project ? LIST_MAX : limit, sortKey: 'updated_at' }),
|
|
75
|
+
);
|
|
76
|
+
const listed = 'threads the Codex app lists (interactive sources, not archived)';
|
|
77
|
+
const matches = threadsInFolder(result.data.map(summarizeCodexThread), project);
|
|
78
|
+
return {
|
|
79
|
+
side: 'codex',
|
|
80
|
+
matchedOn: project ? 'title, within the project folder' : 'title',
|
|
81
|
+
scope: project ? `the newest ${result.data.length} ${listed}` : listed,
|
|
82
|
+
more: matches.length > limit || Boolean(result.nextCursor),
|
|
83
|
+
threads: matches.slice(0, limit),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readCodexThread({ threadId, turns, maxChars }) {
|
|
88
|
+
const result = await withCodexServer((client) => client.request('thread/read', { threadId, includeTurns: true }));
|
|
89
|
+
return renderCodexThread(result.thread, { turns, maxChars });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Separate CLI view: do not change the legacy renderer or the recovery/check consumers.
|
|
93
|
+
export function codexConversation(thread) {
|
|
94
|
+
return {
|
|
95
|
+
side: 'codex', threadId: thread.id,
|
|
96
|
+
turns: (thread.turns ?? []).map(turn => ({ startedAt: toIso(turn.startedAt), status: turn.status ?? null, items: (turn.items ?? []).map(item => {
|
|
97
|
+
const described = describeCodexItem(item);
|
|
98
|
+
if (!described) return { kind: 'private', text: '', omission: 'privateOrUnsupported' };
|
|
99
|
+
return { ...described, ...(item.type === 'contextCompaction' ? { omission: 'context' } : {}) };
|
|
100
|
+
}) })),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function readCodexConversation({ threadId }) {
|
|
105
|
+
const result = await withCodexServer(client => client.request('thread/read', { threadId, includeTurns: true }));
|
|
106
|
+
return codexConversation(result.thread);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- search inside messages ---
|
|
110
|
+
|
|
111
|
+
// How many threads a search inside messages reads, one after another on one server. Without a
|
|
112
|
+
// project it lists only that many; with one it lists up to LIST_MAX and reads the project's.
|
|
113
|
+
// Listing is most of the cost: on the development machine listing 20 threads took about 4
|
|
114
|
+
// seconds and 100 about 7, while each read took under 0.1 seconds.
|
|
115
|
+
export const TEXT_SCAN = 20;
|
|
116
|
+
|
|
117
|
+
export function firstCodexMatch(thread, words) {
|
|
118
|
+
for (const turn of thread.turns ?? []) {
|
|
119
|
+
for (const item of turn.items ?? []) {
|
|
120
|
+
const described = describeCodexItem(item);
|
|
121
|
+
const match = described && matchItem(described, words);
|
|
122
|
+
if (match) return { ...match, at: toIso(turn.startedAt) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function searchCodexThreads({ search, text, limit, project, bound = TEXT_SCAN, server = withCodexServer }) {
|
|
129
|
+
const words = textWords(text);
|
|
130
|
+
return server(async (client) => {
|
|
131
|
+
const listed = await client.request('thread/list', { searchTerm: search || null, limit: project ? LIST_MAX : bound, sortKey: 'updated_at' });
|
|
132
|
+
const inFolder = threadsInFolder(listed.data, project);
|
|
133
|
+
const threads = [];
|
|
134
|
+
let looked = 0;
|
|
135
|
+
let unreadable = 0;
|
|
136
|
+
for (const thread of inFolder.slice(0, bound)) {
|
|
137
|
+
if (threads.length > limit) break;
|
|
138
|
+
looked += 1;
|
|
139
|
+
let full;
|
|
140
|
+
try {
|
|
141
|
+
({ thread: full } = await client.request('thread/read', { threadId: thread.id, includeTurns: true }));
|
|
142
|
+
} catch {
|
|
143
|
+
unreadable += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const match = firstCodexMatch(full, words);
|
|
147
|
+
if (match) threads.push({ ...summarizeCodexThread(full), match });
|
|
148
|
+
}
|
|
149
|
+
const lists = 'the Codex app lists (interactive sources, not archived)';
|
|
150
|
+
const searched = project
|
|
151
|
+
? `the ${looked} threads in the project folder among the ${listed.data.length} most recent threads ${lists}`
|
|
152
|
+
: `the ${looked} most recent threads ${lists}`;
|
|
153
|
+
let cut = '';
|
|
154
|
+
if (threads.length <= limit && inFolder.length > bound) cut = `; the search reads at most ${bound} threads, so older ones in the project folder were not searched`;
|
|
155
|
+
else if (threads.length <= limit && listed.nextCursor) cut = `; threads older than the ${listed.data.length} listed were not searched`;
|
|
156
|
+
return {
|
|
157
|
+
side: 'codex',
|
|
158
|
+
matchedOn: matchedOn({ search, text, project }),
|
|
159
|
+
scope: `${searched}${unreadable ? `; ${unreadable} of them could not be read` : ''}${cut}`,
|
|
160
|
+
more: threads.length > limit,
|
|
161
|
+
threads: threads.slice(0, limit),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Compact conversation pages. Sections: validation/cursors / selection / byte-budgeted pages.
|
|
2
|
+
// The legacy renderers remain unchanged; only the CLI opts into this view.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { shownStatus } from './codex.mjs';
|
|
5
|
+
|
|
6
|
+
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
7
|
+
const size = (value) => Buffer.byteLength(JSON.stringify(value)) + 1; // includes CLI newline
|
|
8
|
+
const stats = (items) => ({ items: items.length, textUnits: items.reduce((sum, item) => sum + item.text.length, 0) });
|
|
9
|
+
const integer = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
10
|
+
const splitsScalar = (value, at) => at > 0 && at < value.length &&
|
|
11
|
+
value.charCodeAt(at - 1) >= 0xd800 && value.charCodeAt(at - 1) <= 0xdbff &&
|
|
12
|
+
value.charCodeAt(at) >= 0xdc00 && value.charCodeAt(at) <= 0xdfff;
|
|
13
|
+
|
|
14
|
+
export function validateCompactOptions({ turns = 2, maxChars = 4000, budget = 16000, cursor } = {}) {
|
|
15
|
+
for (const [name, value] of Object.entries({ turns, 'max-chars': maxChars, budget })) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`--${name} must be a positive safe integer`);
|
|
17
|
+
}
|
|
18
|
+
if (budget < 2048) throw new Error('--budget must be at least 2048 UTF-8 bytes');
|
|
19
|
+
if (cursor !== undefined && (typeof cursor !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(cursor))) throw new Error('invalid read cursor');
|
|
20
|
+
if (cursor !== undefined) decodeCursor(cursor);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decodeCursor(cursor) {
|
|
24
|
+
try {
|
|
25
|
+
const page = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
26
|
+
if (!page || Object.keys(page).sort().join(',') !== 'end,hash,index,offset,scope,snapshotEnd,start,tools,v' ||
|
|
27
|
+
page.v !== 2 || typeof page.tools !== 'boolean' || !/^[a-f0-9]{64}$/.test(page.scope) ||
|
|
28
|
+
!/^[a-f0-9]{64}$/.test(page.hash) || ![page.start, page.end, page.snapshotEnd, page.index, page.offset].every(integer)) throw new Error();
|
|
29
|
+
return page;
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error('invalid read cursor');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reason(item, tools) {
|
|
36
|
+
if (item.omission) return item.omission;
|
|
37
|
+
if (item.kind === 'user' || item.kind === 'assistant') return null;
|
|
38
|
+
return tools ? null : 'tools';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderConversation(source, { turns = 2, maxChars = 4000, budget = 16000, tools = false, cursor } = {}) {
|
|
42
|
+
validateCompactOptions({ turns, maxChars, budget, cursor });
|
|
43
|
+
const scope = hash([source.side, source.threadId]);
|
|
44
|
+
const all = source.turns ?? [];
|
|
45
|
+
let page = cursor ? decodeCursor(cursor) : null;
|
|
46
|
+
if (page && (page.scope !== scope || page.tools !== tools)) throw new Error('read cursor belongs to another thread, side or --tools view');
|
|
47
|
+
const end = page?.end ?? all.length;
|
|
48
|
+
const snapshotEnd = page?.snapshotEnd ?? all.length;
|
|
49
|
+
if (end > snapshotEnd) throw new Error('invalid read cursor snapshot');
|
|
50
|
+
if (snapshotEnd > all.length) throw new Error('history changed; restart read without --cursor');
|
|
51
|
+
// Hidden-only turns do not consume --turns, so notifications cannot crowd out discussion.
|
|
52
|
+
const visible = all.slice(0, end).flatMap((turn, index) => turn.items.some(item => !reason(item, tools)) ? [index] : []);
|
|
53
|
+
const start = page?.start ?? visible[Math.max(0, visible.length - turns)] ?? 0;
|
|
54
|
+
if (start > end) throw new Error('invalid read cursor window');
|
|
55
|
+
const window = all.slice(start, end);
|
|
56
|
+
const digest = hash(window);
|
|
57
|
+
if (page && page.hash !== digest) throw new Error('history changed; restart read without --cursor');
|
|
58
|
+
const entries = [];
|
|
59
|
+
const filtered = {};
|
|
60
|
+
window.forEach((turn, turnIndex) => turn.items.forEach((item, itemIndex) => {
|
|
61
|
+
const omitted = reason(item, tools);
|
|
62
|
+
if (omitted) {
|
|
63
|
+
const count = filtered[omitted] ??= { items: 0, textUnits: 0 };
|
|
64
|
+
count.items += 1;
|
|
65
|
+
count.textUnits += item.text.length;
|
|
66
|
+
} else entries.push({ turn: start + turnIndex, item: itemIndex, startedAt: turn.startedAt ?? null,
|
|
67
|
+
// Raw statuses stay in the digest; presentation uses the saved whole-thread newest
|
|
68
|
+
// turn, not this window's end or a newer append, so cursors remain stable.
|
|
69
|
+
status: source.side === 'codex' ? shownStatus(turn.status, start + turnIndex === snapshotEnd - 1) : turn.status ?? null,
|
|
70
|
+
kind: item.kind, text: item.text });
|
|
71
|
+
}));
|
|
72
|
+
const index = page?.index ?? 0;
|
|
73
|
+
const offset = page?.offset ?? 0;
|
|
74
|
+
if (index > entries.length || (index === entries.length ? offset !== 0 : offset > entries[index].text.length)) throw new Error('invalid read cursor position');
|
|
75
|
+
if (index < entries.length && splitsScalar(entries[index].text, offset)) throw new Error('invalid read cursor scalar boundary');
|
|
76
|
+
const outside = [...all.slice(0, start), ...all.slice(end)].flatMap(turn => turn.items);
|
|
77
|
+
const before = stats(entries.slice(0, index));
|
|
78
|
+
before.textUnits += offset;
|
|
79
|
+
if (offset > 0) before.items += 1;
|
|
80
|
+
const olderVisible = visible.filter(i => i < start);
|
|
81
|
+
const count = visible.filter(i => i >= start).length;
|
|
82
|
+
const olderStart = olderVisible[Math.max(0, olderVisible.length - count)] ?? 0;
|
|
83
|
+
const olderCursor = olderVisible.length === 0 ? null : Buffer.from(JSON.stringify({
|
|
84
|
+
v: 2, scope, hash: hash(all.slice(olderStart, start)), tools, snapshotEnd,
|
|
85
|
+
start: olderStart, end: start, index: 0, offset: 0,
|
|
86
|
+
})).toString('base64url');
|
|
87
|
+
const messages = [];
|
|
88
|
+
const resultAt = (nextIndex, nextOffset) => {
|
|
89
|
+
const remaining = stats(entries.slice(nextIndex));
|
|
90
|
+
remaining.textUnits -= nextOffset;
|
|
91
|
+
const more = nextIndex < entries.length;
|
|
92
|
+
return {
|
|
93
|
+
side: source.side, threadId: source.threadId, view: tools ? 'conversation+tools' : 'conversation',
|
|
94
|
+
window: { startTurn: start, endTurn: end, conversationTurns: visible.filter(i => i >= start).length },
|
|
95
|
+
budget: { bytes: budget, unit: 'UTF-8 serialized JSON including newline' },
|
|
96
|
+
messages,
|
|
97
|
+
omitted: {
|
|
98
|
+
outsideWindow: { ...stats(outside), olderTurns: start, newerTurns: all.length - end },
|
|
99
|
+
filtered, beforePage: before, afterPage: remaining,
|
|
100
|
+
splitItems: messages.filter(item => item.offset > 0 || item.text.length < item.totalTextUnits).length,
|
|
101
|
+
},
|
|
102
|
+
nextCursor: more ? Buffer.from(JSON.stringify({ v: 2, scope, hash: digest, tools, snapshotEnd, start, end, index: nextIndex, offset: nextOffset })).toString('base64url') : null,
|
|
103
|
+
olderCursor: more ? null : olderCursor,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
let nextIndex = index;
|
|
107
|
+
let nextOffset = offset;
|
|
108
|
+
const result = resultAt(nextIndex, nextOffset);
|
|
109
|
+
if (size(result) > budget) throw new Error(`--budget too small for page metadata; need at least ${size(result)} UTF-8 bytes`);
|
|
110
|
+
while (nextIndex < entries.length) {
|
|
111
|
+
const entry = entries[nextIndex];
|
|
112
|
+
let available = Math.min(maxChars, entry.text.length - nextOffset);
|
|
113
|
+
if (splitsScalar(entry.text, nextOffset + available)) available -= 1;
|
|
114
|
+
// Limit: --max-chars=1 needs two UTF-16 units for one supplementary Unicode scalar.
|
|
115
|
+
if (available === 0 && nextOffset < entry.text.length) available = 2;
|
|
116
|
+
const boundaries = [0];
|
|
117
|
+
for (let i = 0; i < available;) {
|
|
118
|
+
i += entry.text.codePointAt(nextOffset + i) > 0xffff ? 2 : 1;
|
|
119
|
+
boundaries.push(i);
|
|
120
|
+
}
|
|
121
|
+
const fragment = { ...entry, text: '', offset: nextOffset, totalTextUnits: entry.text.length };
|
|
122
|
+
messages.push(fragment);
|
|
123
|
+
const candidateAt = (length) => {
|
|
124
|
+
fragment.text = entry.text.slice(nextOffset, nextOffset + length);
|
|
125
|
+
const complete = nextOffset + length === entry.text.length;
|
|
126
|
+
return resultAt(complete ? nextIndex + 1 : nextIndex, complete ? 0 : nextOffset + length);
|
|
127
|
+
};
|
|
128
|
+
let low = size(candidateAt(available)) <= budget ? boundaries.length - 1 : 0;
|
|
129
|
+
let high = low === boundaries.length - 1 ? low : Math.max(0, boundaries.length - 2);
|
|
130
|
+
// Measure the serialized envelope, not just text: escaping and metadata count too.
|
|
131
|
+
while (low < high) {
|
|
132
|
+
const mid = Math.ceil((low + high) / 2);
|
|
133
|
+
if (size(candidateAt(boundaries[mid])) <= budget) low = mid;
|
|
134
|
+
else high = mid - 1;
|
|
135
|
+
}
|
|
136
|
+
const length = boundaries[low];
|
|
137
|
+
const candidate = candidateAt(length);
|
|
138
|
+
if (size(candidate) > budget || (length === 0 && available > 0)) {
|
|
139
|
+
const minimum = size(candidateAt(boundaries[1] ?? 0));
|
|
140
|
+
messages.pop();
|
|
141
|
+
if (messages.length === 0) throw new Error(`--budget too small for the next fragment; need at least ${minimum} UTF-8 bytes`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
nextOffset += length;
|
|
145
|
+
if (nextOffset === entry.text.length) { nextIndex += 1; nextOffset = 0; }
|
|
146
|
+
if (nextOffset > 0) break;
|
|
147
|
+
}
|
|
148
|
+
return resultAt(nextIndex, nextOffset);
|
|
149
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Whether two folder paths name the same folder, however each tool wrote them: a trailing
|
|
2
|
+
// slash anywhere, and on Windows either kind of slash and any letter case. Elsewhere a
|
|
3
|
+
// backslash is an ordinary character of a name.
|
|
4
|
+
export function folderKey(folder, platform = process.platform) {
|
|
5
|
+
const text = String(folder ?? '');
|
|
6
|
+
const plain = platform === 'win32' ? text.replaceAll('\\', '/').toLowerCase() : text;
|
|
7
|
+
return plain.replace(/\/+$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const sameFolder = (a, b, platform = process.platform) => Boolean(a) && Boolean(b) && folderKey(a, platform) === folderKey(b, platform);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Find and read threads on either side by one interface.
|
|
2
|
+
import { findClaudeSessions, readClaudeConversation, readClaudeSession, searchClaudeSessions } from './claude.mjs';
|
|
3
|
+
import { findCodexThreads, readCodexConversation, readCodexThread, searchCodexThreads } from './codex.mjs';
|
|
4
|
+
import { renderConversation, validateCompactOptions } from './compact.mjs';
|
|
5
|
+
|
|
6
|
+
const SIDES = Object.freeze({
|
|
7
|
+
codex: Object.freeze({ find: findCodexThreads, searchText: searchCodexThreads, read: readCodexThread, conversation: readCodexConversation }),
|
|
8
|
+
claude: Object.freeze({ find: findClaudeSessions, searchText: searchClaudeSessions, read: readClaudeSession, conversation: readClaudeConversation }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function sideNamed(name) {
|
|
12
|
+
const key = String(name ?? '').toLowerCase();
|
|
13
|
+
if (!Object.hasOwn(SIDES, key)) throw new Error(`--side must be one of: ${Object.keys(SIDES).join(', ')}`);
|
|
14
|
+
return SIDES[key];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `project`, when given, keeps only threads whose folder is that project folder. `text`, when
|
|
18
|
+
// given, searches inside messages as well.
|
|
19
|
+
export async function findThreads({ side, search = '', text, limit = 10, project }) {
|
|
20
|
+
const chosen = sideNamed(side);
|
|
21
|
+
if (text === undefined) return chosen.find({ search, limit, project });
|
|
22
|
+
if (!String(text).trim()) throw new Error('--text is empty');
|
|
23
|
+
return chosen.searchText({ search, text, limit, project });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readThread({ side, threadId, turns = 2, maxChars = 4000 }) {
|
|
27
|
+
if (!threadId) throw new Error('--thread is required');
|
|
28
|
+
return sideNamed(side).read({ threadId, turns, maxChars });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readConversation({ side, threadId, ...options }) {
|
|
32
|
+
if (!threadId) throw new Error('--thread is required');
|
|
33
|
+
validateCompactOptions(options);
|
|
34
|
+
return renderConversation(await sideNamed(side).conversation({ threadId }), options);
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Searching inside messages, the same on either side: a message matches when it holds every
|
|
2
|
+
// word, in any order and any case. Only what the person and the assistant wrote is searched.
|
|
3
|
+
import { excerpt } from './render.mjs';
|
|
4
|
+
|
|
5
|
+
export const textWords = (text) => String(text ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
6
|
+
|
|
7
|
+
// Where an item matched, or null. `item` is a described item ({ kind, text }) of either side.
|
|
8
|
+
export function matchItem(item, words) {
|
|
9
|
+
if (item.kind !== 'user' && item.kind !== 'assistant') return null;
|
|
10
|
+
const flat = String(item.text ?? '').replace(/\s+/g, ' ').trim();
|
|
11
|
+
const lower = flat.toLowerCase();
|
|
12
|
+
if (!words.every((word) => lower.includes(word))) return null;
|
|
13
|
+
// Lowercasing can change the length of a few characters, which shifts the excerpt slightly.
|
|
14
|
+
return { by: item.kind, excerpt: excerpt(flat, Math.min(...words.map((word) => lower.indexOf(word)))) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchedOn({ search, text, project }) {
|
|
18
|
+
const what = [search && 'title', text && 'text'].filter(Boolean).join(' and ') || 'title';
|
|
19
|
+
return project ? `${what}, within the project folder` : what;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared shaping for thread history, whichever side it came from.
|
|
2
|
+
|
|
3
|
+
export function clip(text, maxChars) {
|
|
4
|
+
const value = String(text ?? '');
|
|
5
|
+
if (value.length <= maxChars) return value;
|
|
6
|
+
return `${value.slice(0, maxChars)}\n[cut: ${value.length - maxChars} more characters; raise --max-chars to read them]`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function firstLine(text) {
|
|
10
|
+
const line = String(text ?? '').split('\n').find((candidate) => candidate.trim());
|
|
11
|
+
return line ? line.trim() : '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const lastTurns = (turns, count) => turns.slice(Math.max(0, turns.length - count));
|
|
15
|
+
|
|
16
|
+
export const clipItems = (items, maxChars) => items.map((item) => ({ ...item, text: clip(item.text, maxChars) }));
|
|
17
|
+
|
|
18
|
+
// One line of about `size` characters around position `at`, with ... where it was cut.
|
|
19
|
+
export function excerpt(text, at, size = 160) {
|
|
20
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
21
|
+
const start = Math.max(0, Math.min(at - Math.floor(size / 4), flat.length - size));
|
|
22
|
+
return `${start > 0 ? '...' : ''}${flat.slice(start, start + size)}${start + size < flat.length ? '...' : ''}`;
|
|
23
|
+
}
|