@crouton-kit/humanloop 0.3.37 → 0.3.39
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/dist/browser/server.d.ts +15 -5
- package/dist/browser/server.js +110 -14
- package/dist/cli.js +0 -0
- package/dist/conversation/reader.d.ts +15 -1
- package/dist/conversation/reader.js +320 -30
- package/dist/editor/roundtrip.d.ts +5 -0
- package/dist/editor/roundtrip.js +36 -0
- package/dist/inbox/completion.js +1 -27
- package/dist/inbox/controller.d.ts +36 -1
- package/dist/inbox/controller.js +335 -28
- package/dist/inbox/convention.d.ts +7 -0
- package/dist/inbox/convention.js +32 -0
- package/dist/inbox/deck-adapter.d.ts +10 -2
- package/dist/inbox/deck-adapter.js +23 -12
- package/dist/inbox/deck-schema.d.ts +2 -0
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/followup.d.ts +57 -0
- package/dist/inbox/followup.js +117 -0
- package/dist/inbox/registry.d.ts +2 -0
- package/dist/inbox/registry.js +6 -2
- package/dist/inbox/tickets.js +8 -2
- package/dist/inbox/tui.d.ts +1 -1
- package/dist/inbox/tui.js +58 -25
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/render/termrender.js +25 -26
- package/dist/tui/app.d.ts +1 -1
- package/dist/tui/app.js +80 -88
- package/dist/tui/input.d.ts +5 -1
- package/dist/tui/input.js +41 -15
- package/dist/tui/log.d.ts +2 -0
- package/dist/tui/log.js +53 -0
- package/dist/tui/render.js +42 -23
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.js +44 -22
- package/dist/types.d.ts +39 -2
- package/dist/visuals/conversation.d.ts +7 -0
- package/dist/visuals/conversation.js +16 -0
- package/dist/visuals/generate.d.ts +3 -1
- package/dist/visuals/generate.js +8 -6
- package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +7 -2
package/dist/browser/server.d.ts
CHANGED
|
@@ -9,12 +9,18 @@ export interface WebServerOpts {
|
|
|
9
9
|
* exactly once per server instance, only for the first accepted submit
|
|
10
10
|
* (later submits get a 409 and never re-fire this). `responsePath`/
|
|
11
11
|
* `completedAt` are already persisted to disk — the caller converges the
|
|
12
|
-
* host surface, it must NOT write the result again.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* host surface, it must NOT write the result again. For a connected caller,
|
|
13
|
+
* fires only after its HTTP 200 finishes, so this callback can `stop()` the
|
|
14
|
+
* server without racing that ack. If the client disconnects after accepted
|
|
15
|
+
* persistence, it fires without waiting for an impossible finish.
|
|
16
16
|
*/
|
|
17
|
-
onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void
|
|
17
|
+
onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void | Promise<void>;
|
|
18
|
+
/** Controller-owned finalization for a claimed inbox ticket. When supplied,
|
|
19
|
+
* this server never writes response.json itself. */
|
|
20
|
+
finalize?: (responses: InteractionResponse[]) => Promise<{
|
|
21
|
+
completedAt: string;
|
|
22
|
+
responsePath: string;
|
|
23
|
+
}>;
|
|
18
24
|
}
|
|
19
25
|
export interface ReviewWebServerOpts {
|
|
20
26
|
/** Shared review job dir — carries review.vim and progress.json. */
|
|
@@ -47,6 +53,10 @@ export interface WebServerHandle {
|
|
|
47
53
|
* acks (closed, network hiccup) can't hang take-back forever. Safe to call
|
|
48
54
|
* with zero open sockets (e.g. the browser was never opened). */
|
|
49
55
|
requestTakeBack(): Promise<void>;
|
|
56
|
+
/** The accepted submit currently completing its HTTP ack and owner convergence,
|
|
57
|
+
* if one has crossed the server boundary. `true` means the normal submit
|
|
58
|
+
* finish path completed; `false` means finalization was rejected. */
|
|
59
|
+
pendingSubmitLifecycle?(): Promise<boolean> | undefined;
|
|
50
60
|
/** Stop listening, close all sockets (WS and HTTP), and tear down. */
|
|
51
61
|
stop(): Promise<void>;
|
|
52
62
|
}
|
package/dist/browser/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
import { join, extname, dirname, basename } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
-
import { deckPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
7
|
+
import { deckPath, progressPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
8
8
|
import { buildDraftFeedbackResult, buildFinalFeedbackResult, parseFeedbackComments, readReviewDraft, serializeFeedbackResult, writeReviewDraft, writeSubmitFlag, } from '../editor/feedback.js';
|
|
9
9
|
// See $CRTR_CONTEXT_DIR/phase1-server-contract.md for the deck HTTP/WS API this
|
|
10
10
|
// module implements — this file is the reference implementation for the deck
|
|
@@ -118,6 +118,12 @@ async function startDeckServer(opts) {
|
|
|
118
118
|
let deck = opts.deck;
|
|
119
119
|
const dir = opts.dir;
|
|
120
120
|
let submitted = null;
|
|
121
|
+
let submitting = false;
|
|
122
|
+
// Created synchronously when an accepted request enters its finalizer, then
|
|
123
|
+
// kept pending through response flush and async owner convergence. A
|
|
124
|
+
// take-back can therefore serialize with the complete submit lifecycle,
|
|
125
|
+
// rather than racing only its persistence portion.
|
|
126
|
+
let pendingSubmitLifecycle;
|
|
121
127
|
const scaffold = createServerScaffold();
|
|
122
128
|
scaffold.setHandler(async (req, res) => {
|
|
123
129
|
const url = req.url ?? '/';
|
|
@@ -132,7 +138,9 @@ async function startDeckServer(opts) {
|
|
|
132
138
|
const onDisk = readJson(deckPath(dir));
|
|
133
139
|
if (onDisk !== null)
|
|
134
140
|
deck = onDisk;
|
|
135
|
-
|
|
141
|
+
const progress = readJson(progressPath(dir));
|
|
142
|
+
const responses = Array.isArray(progress?.responses) ? progress.responses : [];
|
|
143
|
+
sendJson(res, 200, { dir, deck, responses });
|
|
136
144
|
return;
|
|
137
145
|
}
|
|
138
146
|
if (url === '/api/submit' && req.method === 'POST') {
|
|
@@ -156,22 +164,107 @@ async function startDeckServer(opts) {
|
|
|
156
164
|
sendJson(res, 409, { ok: false, error: 'already_submitted', ...submitted });
|
|
157
165
|
return;
|
|
158
166
|
}
|
|
167
|
+
if (submitting) {
|
|
168
|
+
sendJson(res, 409, { ok: false, error: 'submit_in_progress' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
submitting = true;
|
|
159
172
|
const responses = parsed.responses;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
let resolveSubmitLifecycle;
|
|
174
|
+
let submitLifecycleSettled = false;
|
|
175
|
+
const settleSubmitLifecycle = (didSubmit) => {
|
|
176
|
+
if (submitLifecycleSettled)
|
|
177
|
+
return;
|
|
178
|
+
submitLifecycleSettled = true;
|
|
179
|
+
resolveSubmitLifecycle(didSubmit);
|
|
180
|
+
};
|
|
181
|
+
pendingSubmitLifecycle = new Promise((resolve) => { resolveSubmitLifecycle = resolve; });
|
|
182
|
+
let completedAt;
|
|
183
|
+
let responsePath;
|
|
184
|
+
// Convergence (owner onSubmit) must run exactly once for a persisted
|
|
185
|
+
// success, regardless of whether the client is still connected. The
|
|
186
|
+
// `finish` gate below only exists to preserve ack-ordering for a live
|
|
187
|
+
// caller; once the client is gone there is no ordering to protect.
|
|
188
|
+
let convergenceStarted = false;
|
|
189
|
+
const runConvergence = () => {
|
|
190
|
+
if (convergenceStarted)
|
|
191
|
+
return;
|
|
192
|
+
convergenceStarted = true;
|
|
193
|
+
void Promise.resolve(opts.onSubmit?.(responses, completedAt, responsePath)).then(() => settleSubmitLifecycle(true), () => settleSubmitLifecycle(false));
|
|
194
|
+
};
|
|
195
|
+
// Observe client disconnect BEFORE awaiting the finalizer. If the
|
|
196
|
+
// client leaves while finalization is still pending we must NOT declare
|
|
197
|
+
// the accepted submit lost — the finalizer may still persist, in which
|
|
198
|
+
// case convergence must run. Defer judgment until the finalizer settles.
|
|
199
|
+
let finalizePending = true;
|
|
200
|
+
let successReady = false;
|
|
201
|
+
let responseFinished = false;
|
|
202
|
+
let clientGone = false;
|
|
203
|
+
const onResponseFinished = () => {
|
|
204
|
+
responseFinished = true;
|
|
205
|
+
if (successReady && submitted !== null)
|
|
206
|
+
runConvergence();
|
|
207
|
+
};
|
|
208
|
+
const onClientGone = () => {
|
|
209
|
+
// `writableFinished` only means end() was called; a peer can vanish
|
|
210
|
+
// before the `finish` event. Only finish proves the live caller got
|
|
211
|
+
// through the response boundary.
|
|
212
|
+
if (responseFinished)
|
|
213
|
+
return;
|
|
214
|
+
if (finalizePending || !successReady) {
|
|
215
|
+
clientGone = true; // decide once finalization and convergence broadcast settle
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (submitted !== null) {
|
|
219
|
+
// Accepted submit persisted but the client left before its body
|
|
220
|
+
// flushed — converge once; no live caller remains to protect.
|
|
221
|
+
runConvergence();
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// The finalizer rejected: the submit was genuinely lost.
|
|
225
|
+
settleSubmitLifecycle(false);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
req.once('aborted', onClientGone);
|
|
229
|
+
res.once('finish', onResponseFinished);
|
|
230
|
+
res.once('close', onClientGone);
|
|
231
|
+
res.once('error', onClientGone);
|
|
232
|
+
try {
|
|
233
|
+
if (opts.finalize !== undefined) {
|
|
234
|
+
({ completedAt, responsePath } = await opts.finalize(responses));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// Standalone browser handoff retains its direct local persistence.
|
|
238
|
+
completedAt = new Date().toISOString();
|
|
239
|
+
responsePath = writeResponse(dir, responses, completedAt, deck);
|
|
240
|
+
clearProgress(dir);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
finalizePending = false;
|
|
245
|
+
submitting = false;
|
|
246
|
+
settleSubmitLifecycle(false);
|
|
247
|
+
sendJson(res, 400, { error: 'invalid_submit', message: error instanceof Error ? error.message : String(error) });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
finalizePending = false;
|
|
164
251
|
submitted = { responsePath, completedAt };
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
// trigger caller-side stop() — that ordering is what closes the race
|
|
168
|
-
// between a lost WS frame and the teardown it precedes.
|
|
252
|
+
// Broadcast convergence before permitting owner cleanup, so connected
|
|
253
|
+
// observers receive this state transition before the server can close.
|
|
169
254
|
await scaffold.broadcast({ type: 'converged' });
|
|
255
|
+
successReady = true;
|
|
256
|
+
if (clientGone) {
|
|
257
|
+
// The client disconnected during finalization; converge now rather
|
|
258
|
+
// than waiting for a `finish` that will never fire. The 200 is
|
|
259
|
+
// best-effort — the socket may already be closed.
|
|
260
|
+
runConvergence();
|
|
261
|
+
sendJson(res, 200, { ok: true, responsePath, completedAt });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
170
264
|
// Ack-ordering guarantee: the HTTP caller must always receive its 200
|
|
171
|
-
// body before any lifecycle teardown can close its socket.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
});
|
|
265
|
+
// body before any lifecycle teardown can close its socket. The finish
|
|
266
|
+
// observer registered above converges then; `onClientGone` converges if
|
|
267
|
+
// the peer disappears before finish.
|
|
175
268
|
sendJson(res, 200, { ok: true, responsePath, completedAt });
|
|
176
269
|
return;
|
|
177
270
|
}
|
|
@@ -192,6 +285,9 @@ async function startDeckServer(opts) {
|
|
|
192
285
|
async requestTakeBack() {
|
|
193
286
|
await scaffold.broadcast({ type: 'taken-back' });
|
|
194
287
|
},
|
|
288
|
+
pendingSubmitLifecycle() {
|
|
289
|
+
return pendingSubmitLifecycle;
|
|
290
|
+
},
|
|
195
291
|
stop() {
|
|
196
292
|
return scaffold.stop();
|
|
197
293
|
},
|
package/dist/cli.js
CHANGED
|
File without changes
|
|
@@ -2,5 +2,19 @@ export interface ConversationMessage {
|
|
|
2
2
|
role: 'user' | 'assistant';
|
|
3
3
|
content: string;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export type ConversationReadErrorCode = 'session_not_found' | 'session_ambiguous' | 'session_unreadable' | 'session_id_mismatch' | 'conversation_empty';
|
|
6
|
+
export declare class ConversationReadError extends Error {
|
|
7
|
+
readonly code: ConversationReadErrorCode;
|
|
8
|
+
constructor(code: ConversationReadErrorCode);
|
|
9
|
+
}
|
|
10
|
+
export interface ConversationReaderOptions {
|
|
11
|
+
/** Claude SQLite path override for a real-store fixture; production uses Claude's standard store. */
|
|
12
|
+
claudeDbPath?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Read messages from Claude's local conversation store. */
|
|
15
|
+
export declare function readConversation(sessionId: string, options?: ConversationReaderOptions): ConversationMessage[];
|
|
16
|
+
/** Resolve a pi session by exact header id and return its active useful context. */
|
|
17
|
+
export declare function readPiConversationText(sessionId: string): Promise<string>;
|
|
18
|
+
/** Resolve by exact membership across provider stores, never by session-id shape. */
|
|
19
|
+
export declare function readConversationText(sessionId: string, options?: ConversationReaderOptions): Promise<string>;
|
|
6
20
|
export declare function findRecentSessionId(cwd?: string): string | null;
|
|
@@ -1,56 +1,346 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
export class ConversationReadError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code) {
|
|
8
|
+
super(code);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'ConversationReadError';
|
|
9
11
|
}
|
|
12
|
+
}
|
|
13
|
+
const CLAUDE_DB_PATH = join(homedir(), '.claude', '__store.db');
|
|
14
|
+
const MAX_SESSION_ID_LENGTH = 256;
|
|
15
|
+
const piSessionIndex = new Map();
|
|
16
|
+
/** Read messages from Claude's local conversation store. */
|
|
17
|
+
export function readConversation(sessionId, options = {}) {
|
|
18
|
+
const claudeDbPath = options.claudeDbPath ?? CLAUDE_DB_PATH;
|
|
19
|
+
if (!existsSync(claudeDbPath))
|
|
20
|
+
throw new Error('Claude conversation store unavailable');
|
|
10
21
|
const query = `
|
|
11
22
|
SELECT bm.message_type,
|
|
12
23
|
COALESCE(um.message, am.message) AS content
|
|
13
24
|
FROM base_messages bm
|
|
14
25
|
LEFT JOIN user_messages um ON bm.uuid = um.uuid
|
|
15
26
|
LEFT JOIN assistant_messages am ON bm.uuid = am.uuid
|
|
16
|
-
WHERE bm.session_id = '${sessionId
|
|
27
|
+
WHERE bm.session_id = '${escapeSqlString(sessionId)}'
|
|
17
28
|
ORDER BY bm.timestamp ASC;
|
|
18
29
|
`;
|
|
19
|
-
const raw =
|
|
20
|
-
encoding: 'utf8',
|
|
21
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
22
|
-
});
|
|
30
|
+
const raw = runSqlite(claudeDbPath, query);
|
|
23
31
|
if (!raw.trim())
|
|
24
32
|
return [];
|
|
25
33
|
const rows = JSON.parse(raw);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
return rows.flatMap((row) => row.content && (row.message_type === 'user' || row.message_type === 'assistant')
|
|
35
|
+
? [{ role: row.message_type, content: row.content }]
|
|
36
|
+
: []);
|
|
37
|
+
}
|
|
38
|
+
/** Resolve a pi session by exact header id and return its active useful context. */
|
|
39
|
+
export async function readPiConversationText(sessionId) {
|
|
40
|
+
if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)
|
|
41
|
+
throw new ConversationReadError('session_not_found');
|
|
42
|
+
const pi = await import('@earendil-works/pi-coding-agent').catch(() => {
|
|
43
|
+
throw new ConversationReadError('session_unreadable');
|
|
44
|
+
});
|
|
45
|
+
const paths = await resolvePiSessionPaths(pi.SessionManager, sessionId);
|
|
46
|
+
if (paths.length === 0)
|
|
47
|
+
throw new ConversationReadError('session_not_found');
|
|
48
|
+
if (paths.length !== 1)
|
|
49
|
+
throw new ConversationReadError('session_ambiguous');
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
// parseSessionEntries is the package-root parser. Reading only the SDK-discovered
|
|
53
|
+
// path keeps an untrusted deck id from becoming a filesystem locator.
|
|
54
|
+
const raw = readFileSync(paths[0], 'utf8');
|
|
55
|
+
rejectMalformedCompleteJsonlRecords(raw);
|
|
56
|
+
entries = pi.parseSessionEntries(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new ConversationReadError('session_unreadable');
|
|
60
|
+
}
|
|
61
|
+
const header = entries[0];
|
|
62
|
+
if (!isMatchingHeader(header, sessionId))
|
|
63
|
+
throw new ConversationReadError('session_id_mismatch');
|
|
64
|
+
if (entries.length < 2)
|
|
65
|
+
throw new ConversationReadError('conversation_empty');
|
|
66
|
+
try {
|
|
67
|
+
pi.migrateSessionEntries(entries);
|
|
68
|
+
const sessionEntries = entries.slice(1).filter((entry) => entry.type !== 'session');
|
|
69
|
+
const context = pi.buildSessionContext(sessionEntries);
|
|
70
|
+
const text = context.messages.map(serializePiMessage).filter((part) => part !== '').join('\n\n').trim();
|
|
71
|
+
if (text === '')
|
|
72
|
+
throw new ConversationReadError('conversation_empty');
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof ConversationReadError)
|
|
77
|
+
throw error;
|
|
78
|
+
throw new ConversationReadError('session_unreadable');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function resolvePiSessionPaths(SessionManager, sessionId) {
|
|
82
|
+
const root = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
83
|
+
let index = piSessionIndex.get(root);
|
|
84
|
+
if (index === undefined) {
|
|
85
|
+
index = await buildPiSessionIndex(SessionManager);
|
|
86
|
+
piSessionIndex.set(root, index);
|
|
87
|
+
}
|
|
88
|
+
let paths = index.get(sessionId) ?? [];
|
|
89
|
+
// A popup can outlive session creation. Refresh once on a miss, then keep the
|
|
90
|
+
// process-local cache for subsequent visuals instead of rescanning every transcript.
|
|
91
|
+
if (paths.length === 0) {
|
|
92
|
+
index = await buildPiSessionIndex(SessionManager);
|
|
93
|
+
piSessionIndex.set(root, index);
|
|
94
|
+
paths = index.get(sessionId) ?? [];
|
|
95
|
+
}
|
|
96
|
+
return paths;
|
|
97
|
+
}
|
|
98
|
+
async function buildPiSessionIndex(SessionManager) {
|
|
99
|
+
let sessions;
|
|
100
|
+
try {
|
|
101
|
+
sessions = await SessionManager.listAll();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new ConversationReadError('session_unreadable');
|
|
105
|
+
}
|
|
106
|
+
const index = new Map();
|
|
107
|
+
for (const session of sessions) {
|
|
108
|
+
const paths = index.get(session.id);
|
|
109
|
+
if (paths === undefined)
|
|
110
|
+
index.set(session.id, [session.path]);
|
|
111
|
+
else
|
|
112
|
+
paths.push(session.path);
|
|
113
|
+
}
|
|
114
|
+
return index;
|
|
115
|
+
}
|
|
116
|
+
/** Resolve by exact membership across provider stores, never by session-id shape. */
|
|
117
|
+
export async function readConversationText(sessionId, options = {}) {
|
|
118
|
+
if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)
|
|
119
|
+
throw new ConversationReadError('session_not_found');
|
|
120
|
+
const claudeDbPath = options.claudeDbPath ?? CLAUDE_DB_PATH;
|
|
121
|
+
const pi = await import('@earendil-works/pi-coding-agent').catch(() => {
|
|
122
|
+
throw new ConversationReadError('session_unreadable');
|
|
123
|
+
});
|
|
124
|
+
const piPaths = await resolvePiSessionPaths(pi.SessionManager, sessionId);
|
|
125
|
+
const claudeMatches = findClaudeSessionMembership(sessionId, claudeDbPath);
|
|
126
|
+
const matches = (piPaths.length === 1 ? 1 : 0) + (claudeMatches ? 1 : 0);
|
|
127
|
+
if (piPaths.length > 1 || matches > 1)
|
|
128
|
+
throw new ConversationReadError('session_ambiguous');
|
|
129
|
+
if (matches === 0)
|
|
130
|
+
throw new ConversationReadError('session_not_found');
|
|
131
|
+
if (claudeMatches) {
|
|
132
|
+
try {
|
|
133
|
+
const text = readConversation(sessionId, { claudeDbPath }).map((message) => `${message.role}: ${message.content}`).join('\n\n').trim();
|
|
134
|
+
if (text === '')
|
|
135
|
+
throw new ConversationReadError('conversation_empty');
|
|
136
|
+
return text;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof ConversationReadError)
|
|
140
|
+
throw error;
|
|
141
|
+
throw new ConversationReadError('session_unreadable');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return readPiConversationText(sessionId);
|
|
145
|
+
}
|
|
146
|
+
function findClaudeSessionMembership(sessionId, claudeDbPath) {
|
|
147
|
+
if (!existsSync(claudeDbPath))
|
|
148
|
+
return false;
|
|
149
|
+
try {
|
|
150
|
+
return runSqlite(claudeDbPath, `SELECT 1 AS found FROM base_messages WHERE session_id = '${escapeSqlString(sessionId)}' LIMIT 1;`).trim() !== '';
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new ConversationReadError('session_unreadable');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function runSqlite(dbPath, query) {
|
|
157
|
+
return execFileSync('sqlite3', ['-json', dbPath, query], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
|
158
|
+
}
|
|
159
|
+
function escapeSqlString(value) { return value.replace(/'/g, "''"); }
|
|
160
|
+
/** Reject corrupt records while allowing only an unterminated JSON-object prefix being concurrently written. */
|
|
161
|
+
function rejectMalformedCompleteJsonlRecords(raw) {
|
|
162
|
+
const lines = raw.split('\n');
|
|
163
|
+
for (const line of lines.slice(0, -1)) {
|
|
164
|
+
if (line.trim() === '')
|
|
29
165
|
continue;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
166
|
+
JSON.parse(line);
|
|
167
|
+
}
|
|
168
|
+
const tail = lines.at(-1);
|
|
169
|
+
if (!raw.endsWith('\n') && tail !== '' && !isIncompleteJsonObjectPrefix(tail))
|
|
170
|
+
JSON.parse(tail);
|
|
171
|
+
}
|
|
172
|
+
function isIncompleteJsonObjectPrefix(source) {
|
|
173
|
+
let offset = 0;
|
|
174
|
+
const whitespace = () => { while (/\s/.test(source[offset] ?? ''))
|
|
175
|
+
offset += 1; };
|
|
176
|
+
const string = () => {
|
|
177
|
+
if (source[offset++] !== '"')
|
|
178
|
+
return 'malformed';
|
|
179
|
+
while (offset < source.length) {
|
|
180
|
+
const character = source[offset++];
|
|
181
|
+
if (character === '"')
|
|
182
|
+
return 'complete';
|
|
183
|
+
if (character < ' ')
|
|
184
|
+
return 'malformed';
|
|
185
|
+
if (character === '\\') {
|
|
186
|
+
if (offset === source.length)
|
|
187
|
+
return 'incomplete';
|
|
188
|
+
const escape = source[offset++];
|
|
189
|
+
if (!'"\\/bfnrtu'.includes(escape))
|
|
190
|
+
return 'malformed';
|
|
191
|
+
if (escape === 'u') {
|
|
192
|
+
for (let count = 0; count < 4; count += 1) {
|
|
193
|
+
if (offset === source.length)
|
|
194
|
+
return 'incomplete';
|
|
195
|
+
if (!/[0-9a-f]/i.test(source[offset++]))
|
|
196
|
+
return 'malformed';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return 'incomplete';
|
|
202
|
+
};
|
|
203
|
+
const value = () => {
|
|
204
|
+
whitespace();
|
|
205
|
+
const character = source[offset];
|
|
206
|
+
if (character === undefined)
|
|
207
|
+
return 'incomplete';
|
|
208
|
+
if (character === '"')
|
|
209
|
+
return string();
|
|
210
|
+
if (character === '{')
|
|
211
|
+
return object();
|
|
212
|
+
if (character === '[')
|
|
213
|
+
return array();
|
|
214
|
+
for (const literal of ['true', 'false', 'null']) {
|
|
215
|
+
if (source.slice(offset, offset + literal.length) === literal) {
|
|
216
|
+
offset += literal.length;
|
|
217
|
+
return 'complete';
|
|
218
|
+
}
|
|
219
|
+
if (literal.startsWith(source.slice(offset)))
|
|
220
|
+
return 'incomplete';
|
|
221
|
+
}
|
|
222
|
+
if (character === '-' || /[0-9]/.test(character)) {
|
|
223
|
+
const match = source.slice(offset).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
|
|
224
|
+
if (match === null)
|
|
225
|
+
return character === '-' ? 'incomplete' : 'malformed';
|
|
226
|
+
offset += match[0].length;
|
|
227
|
+
return 'complete';
|
|
228
|
+
}
|
|
229
|
+
return 'malformed';
|
|
230
|
+
};
|
|
231
|
+
const object = () => {
|
|
232
|
+
offset += 1;
|
|
233
|
+
whitespace();
|
|
234
|
+
if (source[offset] === '}') {
|
|
235
|
+
offset += 1;
|
|
236
|
+
return 'complete';
|
|
35
237
|
}
|
|
238
|
+
while (true) {
|
|
239
|
+
if (offset === source.length)
|
|
240
|
+
return 'incomplete';
|
|
241
|
+
const key = string();
|
|
242
|
+
if (key !== 'complete')
|
|
243
|
+
return key;
|
|
244
|
+
whitespace();
|
|
245
|
+
if (offset === source.length)
|
|
246
|
+
return 'incomplete';
|
|
247
|
+
if (source[offset++] !== ':')
|
|
248
|
+
return 'malformed';
|
|
249
|
+
const item = value();
|
|
250
|
+
if (item !== 'complete')
|
|
251
|
+
return item;
|
|
252
|
+
whitespace();
|
|
253
|
+
if (offset === source.length)
|
|
254
|
+
return 'incomplete';
|
|
255
|
+
const separator = source[offset++];
|
|
256
|
+
if (separator === '}')
|
|
257
|
+
return 'complete';
|
|
258
|
+
if (separator !== ',')
|
|
259
|
+
return 'malformed';
|
|
260
|
+
whitespace();
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const array = () => {
|
|
264
|
+
offset += 1;
|
|
265
|
+
whitespace();
|
|
266
|
+
if (source[offset] === ']') {
|
|
267
|
+
offset += 1;
|
|
268
|
+
return 'complete';
|
|
269
|
+
}
|
|
270
|
+
while (true) {
|
|
271
|
+
const item = value();
|
|
272
|
+
if (item !== 'complete')
|
|
273
|
+
return item;
|
|
274
|
+
whitespace();
|
|
275
|
+
if (offset === source.length)
|
|
276
|
+
return 'incomplete';
|
|
277
|
+
const separator = source[offset++];
|
|
278
|
+
if (separator === ']')
|
|
279
|
+
return 'complete';
|
|
280
|
+
if (separator !== ',')
|
|
281
|
+
return 'malformed';
|
|
282
|
+
whitespace();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const result = value();
|
|
286
|
+
whitespace();
|
|
287
|
+
return result === 'incomplete' && offset === source.length && source.trimStart().startsWith('{');
|
|
288
|
+
}
|
|
289
|
+
function isMatchingHeader(entry, sessionId) {
|
|
290
|
+
return entry?.type === 'session' && entry.id === sessionId;
|
|
291
|
+
}
|
|
292
|
+
function serializePiMessage(message) {
|
|
293
|
+
switch (message.role) {
|
|
294
|
+
case 'user': return labeledText('user', message.content);
|
|
295
|
+
case 'assistant': {
|
|
296
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
297
|
+
const text = blocks.flatMap((block) => {
|
|
298
|
+
if (!isRecord(block))
|
|
299
|
+
return [];
|
|
300
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
301
|
+
return [block.text];
|
|
302
|
+
if (block.type === 'toolCall' && typeof block.name === 'string')
|
|
303
|
+
return [`tool call ${block.name}: ${safeJson(block.arguments)}`];
|
|
304
|
+
return [];
|
|
305
|
+
}).filter((part) => part.trim() !== '');
|
|
306
|
+
return text.length === 0 ? '' : `assistant: ${text.join('\n')}`;
|
|
307
|
+
}
|
|
308
|
+
case 'toolResult': return labeledText(`tool result${typeof message.toolName === 'string' ? ` ${message.toolName}` : ''}`, message.content);
|
|
309
|
+
case 'compactionSummary': return typeof message.summary === 'string' && message.summary.trim() !== '' ? `compaction summary: ${message.summary}` : '';
|
|
310
|
+
case 'branchSummary': return typeof message.summary === 'string' && message.summary.trim() !== '' ? `branch summary: ${message.summary}` : '';
|
|
311
|
+
default: return '';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function labeledText(label, content) {
|
|
315
|
+
const text = textFromContent(content);
|
|
316
|
+
return text === '' ? '' : `${label}: ${text}`;
|
|
317
|
+
}
|
|
318
|
+
function textFromContent(content) {
|
|
319
|
+
if (typeof content === 'string')
|
|
320
|
+
return content.trim();
|
|
321
|
+
if (!Array.isArray(content))
|
|
322
|
+
return '';
|
|
323
|
+
return content.flatMap((block) => isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n').trim();
|
|
324
|
+
}
|
|
325
|
+
function safeJson(value) {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.stringify(value);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return '[unserializable arguments]';
|
|
36
331
|
}
|
|
37
|
-
return messages;
|
|
38
332
|
}
|
|
333
|
+
function isRecord(value) { return typeof value === 'object' && value !== null; }
|
|
39
334
|
export function findRecentSessionId(cwd) {
|
|
40
335
|
if (!existsSync(CLAUDE_DB_PATH))
|
|
41
336
|
return null;
|
|
42
|
-
const whereClause = cwd
|
|
43
|
-
? `WHERE cwd = '${cwd.replace(/'/g, "''")}'`
|
|
44
|
-
: '';
|
|
337
|
+
const whereClause = cwd ? `WHERE cwd = '${cwd.replace(/'/g, "''")}'` : '';
|
|
45
338
|
const query = `SELECT DISTINCT session_id FROM base_messages ${whereClause} ORDER BY timestamp DESC LIMIT 1;`;
|
|
46
339
|
try {
|
|
47
|
-
const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, {
|
|
48
|
-
encoding: 'utf8',
|
|
49
|
-
});
|
|
340
|
+
const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, { encoding: 'utf8' });
|
|
50
341
|
if (!raw.trim())
|
|
51
342
|
return null;
|
|
52
|
-
|
|
53
|
-
return rows[0]?.session_id ?? null;
|
|
343
|
+
return JSON.parse(raw)[0]?.session_id ?? null;
|
|
54
344
|
}
|
|
55
345
|
catch {
|
|
56
346
|
return null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
/** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
|
|
7
|
+
export function editBufferInEditor(buffer) {
|
|
8
|
+
const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
|
|
9
|
+
const editor = process.env.EDITOR || 'vi';
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(tmpFile, buffer);
|
|
12
|
+
// $EDITOR may be a shell fragment such as "code --wait".
|
|
13
|
+
const result = spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'sh', tmpFile], { stdio: 'inherit' });
|
|
14
|
+
if (result.error)
|
|
15
|
+
return { text: buffer, error: `$EDITOR ("${editor}") failed to launch: ${result.error.message}` };
|
|
16
|
+
if (result.signal !== null)
|
|
17
|
+
return { text: buffer, error: `$EDITOR ("${editor}") was killed by signal ${result.signal}` };
|
|
18
|
+
if (result.status === 127 || result.status === 126)
|
|
19
|
+
return { text: buffer, error: `$EDITOR ("${editor}") failed to launch (shell exit ${result.status})` };
|
|
20
|
+
if (result.status !== 0)
|
|
21
|
+
return { text: buffer, error: `$EDITOR ("${editor}") exited with status ${result.status}` };
|
|
22
|
+
let text = readFileSync(tmpFile, 'utf8');
|
|
23
|
+
if (text.endsWith('\n') && !buffer.endsWith('\n'))
|
|
24
|
+
text = text.slice(0, -1);
|
|
25
|
+
return { text };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return { text: buffer, error: `$EDITOR round-trip failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
try {
|
|
32
|
+
unlinkSync(tmpFile);
|
|
33
|
+
}
|
|
34
|
+
catch { /* best-effort cleanup */ }
|
|
35
|
+
}
|
|
36
|
+
}
|