@crouton-kit/humanloop 0.3.39 → 0.4.1
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/api.d.ts +3 -2
- package/dist/api.js +2 -2
- package/dist/cli.js +6 -2
- package/dist/inbox/controller.d.ts +14 -8
- package/dist/inbox/controller.js +133 -40
- package/dist/inbox/convention.d.ts +2 -2
- package/dist/inbox/convention.js +19 -3
- package/dist/inbox/deck-adapter.d.ts +3 -3
- package/dist/inbox/deck-adapter.js +3 -3
- package/dist/inbox/deck-schema.d.ts +2 -2
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/maintenance.d.ts +4 -0
- package/dist/inbox/maintenance.js +145 -0
- package/dist/inbox/registry.d.ts +8 -0
- package/dist/inbox/registry.js +17 -5
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +35 -36
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/surfaces/inbox-popup.d.ts +1 -1
- package/dist/surfaces/inbox-popup.js +2 -2
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +59 -18
- package/dist/tui/render.js +4 -4
- package/dist/tui/tmux.js +2 -1
- package/dist/types.d.ts +48 -11
- package/package.json +2 -3
- package/dist/conversation/reader.d.ts +0 -20
- package/dist/conversation/reader.js +0 -348
- package/dist/visuals/conversation.d.ts +0 -7
- package/dist/visuals/conversation.js +0 -16
- package/dist/visuals/generate.d.ts +0 -11
- package/dist/visuals/generate.js +0 -81
|
@@ -1,348 +0,0 @@
|
|
|
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';
|
|
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');
|
|
21
|
-
const query = `
|
|
22
|
-
SELECT bm.message_type,
|
|
23
|
-
COALESCE(um.message, am.message) AS content
|
|
24
|
-
FROM base_messages bm
|
|
25
|
-
LEFT JOIN user_messages um ON bm.uuid = um.uuid
|
|
26
|
-
LEFT JOIN assistant_messages am ON bm.uuid = am.uuid
|
|
27
|
-
WHERE bm.session_id = '${escapeSqlString(sessionId)}'
|
|
28
|
-
ORDER BY bm.timestamp ASC;
|
|
29
|
-
`;
|
|
30
|
-
const raw = runSqlite(claudeDbPath, query);
|
|
31
|
-
if (!raw.trim())
|
|
32
|
-
return [];
|
|
33
|
-
const rows = JSON.parse(raw);
|
|
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() === '')
|
|
165
|
-
continue;
|
|
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';
|
|
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]';
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
function isRecord(value) { return typeof value === 'object' && value !== null; }
|
|
334
|
-
export function findRecentSessionId(cwd) {
|
|
335
|
-
if (!existsSync(CLAUDE_DB_PATH))
|
|
336
|
-
return null;
|
|
337
|
-
const whereClause = cwd ? `WHERE cwd = '${cwd.replace(/'/g, "''")}'` : '';
|
|
338
|
-
const query = `SELECT DISTINCT session_id FROM base_messages ${whereClause} ORDER BY timestamp DESC LIMIT 1;`;
|
|
339
|
-
try {
|
|
340
|
-
const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, { encoding: 'utf8' });
|
|
341
|
-
if (!raw.trim())
|
|
342
|
-
return null;
|
|
343
|
-
return JSON.parse(raw)[0]?.session_id ?? null;
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { GenerateVisual, Interaction } from '../types.js';
|
|
2
|
-
import { defaultGenerateVisual } from './generate.js';
|
|
3
|
-
type VisualGenerator = (interaction: Interaction, conversationContext: string, cols?: number) => ReturnType<typeof defaultGenerateVisual>;
|
|
4
|
-
type ConversationTextReader = (sessionId: string) => Promise<string>;
|
|
5
|
-
/** Build the standard visual generator from explicit originating-session metadata. */
|
|
6
|
-
export declare function visualGeneratorForConversationSession(sessionId: string, generateVisual?: VisualGenerator, readContext?: ConversationTextReader): GenerateVisual;
|
|
7
|
-
export {};
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { readConversationText } from '../conversation/reader.js';
|
|
2
|
-
import { defaultGenerateVisual } from './generate.js';
|
|
3
|
-
/** Build the standard visual generator from explicit originating-session metadata. */
|
|
4
|
-
export function visualGeneratorForConversationSession(sessionId, generateVisual = defaultGenerateVisual, readContext = readConversationText) {
|
|
5
|
-
// One lookup feeds every interaction and resize regeneration in this mounted panel.
|
|
6
|
-
// A failed lookup remains a failed visual rather than falling through to a generic prompt.
|
|
7
|
-
const context = readContext(sessionId);
|
|
8
|
-
return async (interaction, cols) => {
|
|
9
|
-
try {
|
|
10
|
-
return await generateVisual(interaction, await context, cols);
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return { ok: false, error: 'visual context unavailable' };
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { Interaction } from '../types.js';
|
|
2
|
-
/** Width shared by model-time and local resize-time visual rendering. */
|
|
3
|
-
export declare function visualRenderWidth(cols: number): number;
|
|
4
|
-
export declare function defaultGenerateVisual(interaction: Interaction, conversationContext: string, cols?: number): Promise<{
|
|
5
|
-
ok: true;
|
|
6
|
-
ansi: string;
|
|
7
|
-
markdown: string;
|
|
8
|
-
} | {
|
|
9
|
-
ok: false;
|
|
10
|
-
error: string;
|
|
11
|
-
}>;
|
package/dist/visuals/generate.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { query } from '@r-cli/sdk';
|
|
2
|
-
import { renderMarkdown } from '../render/termrender.js';
|
|
3
|
-
const VISUAL_SYSTEM_PROMPT = `You are re-grounding a decision-maker in the moment before they answer the question below. They were deep in this problem but got pulled away; in the next 20 seconds they need to remember *what is actually in play* — the current state, the files, the constraint this decision lives inside — so the question stops feeling cold and they can answer with confidence.
|
|
4
|
-
|
|
5
|
-
Write from the conversation history you're given. Lead with what *is*: the real files, functions, data, and constraints that ground this decision, named concretely (as \`path/to/file.ts:123\` so they can jump straight there). Reconstruct just enough of how they arrived here to make the question legible — no more.
|
|
6
|
-
|
|
7
|
-
Keep it tight: usually a short paragraph or a few bullets, 30 lines hard cap. Say less when less is true. Choose whatever shape carries the meaning fastest — prose by default, a list when enumerating, a table only to compare several things across the same dimensions, a small diagram when the structure itself is the point. You're trusted to pick; don't force a format.
|
|
8
|
-
|
|
9
|
-
The one rule that matters: **only reference files, identifiers, and facts that actually appear in the conversation.** Never invent a plausible-looking path or name. If the conversation is thin, write a short honest briefing about what little is grounded — that beats confident fabrication every time.
|
|
10
|
-
|
|
11
|
-
And stay in your lane: don't restate the question, don't recommend an option or tell them how to decide, don't lay out tradeoffs or sketch alternatives. They own the decision; you only reconstruct the ground it stands on.
|
|
12
|
-
|
|
13
|
-
Formatting: plain markdown renders (**bold**, *italic*, \`code\`, bullets, and GFM tables). These termrender directives are available if one genuinely helps — anything you draw with box-drawing/ASCII must sit inside a :::panel or it will be reflowed and destroyed:
|
|
14
|
-
:::panel{title="T" color="cyan"} bordered box (red|green|yellow|blue|magenta|cyan|white|gray)
|
|
15
|
-
:::tree{color="c"} indented hierarchy (2-space indent = nesting)
|
|
16
|
-
:::callout{type="info|warning|error|success"} status callout
|
|
17
|
-
::::columns / :::col{width="50%"} side-by-side (outer fence needs strictly more colons)
|
|
18
|
-
Never wrap the whole output in a code fence.`;
|
|
19
|
-
async function callHaiku(prompt, systemPrompt) {
|
|
20
|
-
try {
|
|
21
|
-
const session = await query({
|
|
22
|
-
prompt,
|
|
23
|
-
options: {
|
|
24
|
-
model: 'haiku',
|
|
25
|
-
maxTurns: 1,
|
|
26
|
-
systemPrompt,
|
|
27
|
-
},
|
|
28
|
-
});
|
|
29
|
-
let text = '';
|
|
30
|
-
for await (const msg of session) {
|
|
31
|
-
if (msg.type === 'assistant' && msg.message?.content) {
|
|
32
|
-
for (const block of msg.message.content) {
|
|
33
|
-
if (block.type === 'text')
|
|
34
|
-
text += block.text;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return text.trim() || null;
|
|
39
|
-
}
|
|
40
|
-
catch (err) {
|
|
41
|
-
process.stderr.write(`[hl] Haiku call failed: ${err instanceof Error ? err.message : err}\n`);
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
// The mounting surface supplies its actual render width; generated ANSI must
|
|
46
|
-
// fit an embedded panel just as it fits a standalone terminal.
|
|
47
|
-
// Cap on how much conversation we hand the generator. Recency is what grounds
|
|
48
|
-
// the decision in front of the human, so when history is long we keep the tail
|
|
49
|
-
// (most recent messages) rather than the head.
|
|
50
|
-
const MAX_CONTEXT_CHARS = 24000;
|
|
51
|
-
/** Width shared by model-time and local resize-time visual rendering. */
|
|
52
|
-
export function visualRenderWidth(cols) {
|
|
53
|
-
return Math.max(1, Math.min(cols - 4, 76));
|
|
54
|
-
}
|
|
55
|
-
export async function defaultGenerateVisual(interaction, conversationContext, cols = (process.stdout.columns || 80)) {
|
|
56
|
-
const width = visualRenderWidth(cols);
|
|
57
|
-
const optionsSummary = interaction.options.length > 0
|
|
58
|
-
? `\nOptions: ${interaction.options.map((o) => o.label).join(' | ')}`
|
|
59
|
-
: '';
|
|
60
|
-
const subtitleLine = interaction.subtitle ? `\nContext: ${interaction.subtitle}` : '';
|
|
61
|
-
// The body is the richest statement of what's being asked — hand it to the
|
|
62
|
-
// generator so the briefing grounds in the actual question, not just its title.
|
|
63
|
-
const bodyLine = interaction.body ? `\nDetail:\n${interaction.body}` : '';
|
|
64
|
-
const questionText = `Title: "${interaction.title}"${subtitleLine}${bodyLine}${optionsSummary}`;
|
|
65
|
-
const trimmedContext = conversationContext.length > MAX_CONTEXT_CHARS
|
|
66
|
-
? `…(earlier conversation trimmed)…\n\n${conversationContext.slice(-MAX_CONTEXT_CHARS)}`
|
|
67
|
-
: conversationContext;
|
|
68
|
-
const prompt = trimmedContext
|
|
69
|
-
? `Here is the conversation so far:\n\n${trimmedContext}\n\n---\n\nThe human is about to answer this decision. Re-ground them in what's in play:\n\n${questionText}`
|
|
70
|
-
: `The human is about to answer this decision. Re-ground them in what's in play:\n\n${questionText}`;
|
|
71
|
-
const result = await callHaiku(prompt, VISUAL_SYSTEM_PROMPT);
|
|
72
|
-
if (result) {
|
|
73
|
-
const markdown = result
|
|
74
|
-
.replace(/^```[\w]*\n?/gm, '')
|
|
75
|
-
.replace(/^```\s*$/gm, '')
|
|
76
|
-
.trim();
|
|
77
|
-
const ansi = renderMarkdown(markdown, width).join('\n');
|
|
78
|
-
return { ok: true, ansi, markdown };
|
|
79
|
-
}
|
|
80
|
-
return { ok: false, error: 'haiku returned no output' };
|
|
81
|
-
}
|