@crouton-kit/humanloop 0.1.4 → 0.2.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 +35 -0
- package/dist/api.js +119 -0
- package/dist/cli.js +348 -97
- package/dist/editor/review.d.ts +24 -0
- package/dist/editor/review.js +425 -0
- package/dist/inbox/convention.d.ts +17 -0
- package/dist/inbox/convention.js +87 -0
- package/dist/inbox/deck-schema.d.ts +41 -0
- package/dist/inbox/deck-schema.js +109 -0
- package/dist/inbox/scan.d.ts +2 -0
- package/dist/inbox/scan.js +62 -0
- package/dist/inbox/tui.d.ts +9 -0
- package/dist/inbox/tui.js +158 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +13 -0
- package/dist/render/termrender.d.ts +36 -0
- package/dist/render/termrender.js +236 -0
- package/dist/render/version.d.ts +1 -0
- package/dist/render/version.js +1 -0
- package/dist/scripts/install-renderer.d.ts +2 -0
- package/dist/scripts/install-renderer.js +16 -0
- package/dist/surfaces/display.d.ts +5 -0
- package/dist/surfaces/display.js +19 -0
- package/dist/tui/app.d.ts +24 -1
- package/dist/tui/app.js +48 -113
- package/dist/tui/render.js +2 -42
- package/dist/tui/tmux.d.ts +4 -6
- package/dist/tui/tmux.js +6 -4
- package/dist/types.d.ts +65 -0
- package/dist/visuals/generate.js +2 -27
- package/package.json +4 -2
package/dist/tui/app.js
CHANGED
|
@@ -1,105 +1,17 @@
|
|
|
1
1
|
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
3
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
3
4
|
import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
|
|
4
5
|
import { handleKeypress, assignShortcuts } from './input.js';
|
|
5
6
|
import { readConversation } from '../conversation/reader.js';
|
|
6
7
|
import { defaultGenerateVisual } from '../visuals/generate.js';
|
|
8
|
+
import { validateDeck } from '../inbox/deck-schema.js';
|
|
9
|
+
import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
10
|
+
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
11
|
+
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
12
|
+
* with sisyphus). Kept exported for back-compat. */
|
|
7
13
|
export function validateInput(parsed) {
|
|
8
|
-
|
|
9
|
-
throw new Error('Deck file must be a JSON object with an `interactions` array');
|
|
10
|
-
}
|
|
11
|
-
const obj = parsed;
|
|
12
|
-
if (!Array.isArray(obj.interactions)) {
|
|
13
|
-
throw new Error('`interactions` must be an array');
|
|
14
|
-
}
|
|
15
|
-
if (obj.interactions.length === 0) {
|
|
16
|
-
throw new Error('No interactions in deck file');
|
|
17
|
-
}
|
|
18
|
-
if (obj.title !== undefined && typeof obj.title !== 'string') {
|
|
19
|
-
throw new Error('`title` must be a string when present');
|
|
20
|
-
}
|
|
21
|
-
const seen = new Set();
|
|
22
|
-
const validated = [];
|
|
23
|
-
for (let i = 0; i < obj.interactions.length; i++) {
|
|
24
|
-
const raw = obj.interactions[i];
|
|
25
|
-
const where = `interactions[${i}]`;
|
|
26
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
27
|
-
throw new Error(`${where} must be an object`);
|
|
28
|
-
}
|
|
29
|
-
if (typeof raw.id !== 'string' || raw.id === '') {
|
|
30
|
-
throw new Error(`${where}.id must be a non-empty string`);
|
|
31
|
-
}
|
|
32
|
-
if (seen.has(raw.id)) {
|
|
33
|
-
throw new Error(`Duplicate interaction id: ${JSON.stringify(raw.id)}`);
|
|
34
|
-
}
|
|
35
|
-
seen.add(raw.id);
|
|
36
|
-
if (typeof raw.title !== 'string' || raw.title === '') {
|
|
37
|
-
throw new Error(`${where}.title must be a non-empty string`);
|
|
38
|
-
}
|
|
39
|
-
if (!Array.isArray(raw.options)) {
|
|
40
|
-
throw new Error(`${where}.options must be an array`);
|
|
41
|
-
}
|
|
42
|
-
const opts = [];
|
|
43
|
-
for (let j = 0; j < raw.options.length; j++) {
|
|
44
|
-
const o = raw.options[j];
|
|
45
|
-
const owhere = `${where}.options[${j}]`;
|
|
46
|
-
if (typeof o !== 'object' || o === null || Array.isArray(o)) {
|
|
47
|
-
throw new Error(`${owhere} must be an object`);
|
|
48
|
-
}
|
|
49
|
-
if (typeof o.id !== 'string' || o.id === '') {
|
|
50
|
-
throw new Error(`${owhere}.id must be a non-empty string`);
|
|
51
|
-
}
|
|
52
|
-
if (typeof o.label !== 'string') {
|
|
53
|
-
throw new Error(`${owhere}.label must be a string`);
|
|
54
|
-
}
|
|
55
|
-
const opt = { id: o.id, label: o.label };
|
|
56
|
-
if (o.description !== undefined) {
|
|
57
|
-
if (typeof o.description !== 'string')
|
|
58
|
-
throw new Error(`${owhere}.description must be a string`);
|
|
59
|
-
opt.description = o.description;
|
|
60
|
-
}
|
|
61
|
-
if (o.shortcut !== undefined) {
|
|
62
|
-
if (typeof o.shortcut !== 'string')
|
|
63
|
-
throw new Error(`${owhere}.shortcut must be a string`);
|
|
64
|
-
opt.shortcut = o.shortcut;
|
|
65
|
-
}
|
|
66
|
-
opts.push(opt);
|
|
67
|
-
}
|
|
68
|
-
const interaction = { id: raw.id, title: raw.title, options: opts };
|
|
69
|
-
if (raw.subtitle !== undefined) {
|
|
70
|
-
if (typeof raw.subtitle !== 'string')
|
|
71
|
-
throw new Error(`${where}.subtitle must be a string`);
|
|
72
|
-
interaction.subtitle = raw.subtitle;
|
|
73
|
-
}
|
|
74
|
-
if (raw.body !== undefined) {
|
|
75
|
-
if (typeof raw.body !== 'string')
|
|
76
|
-
throw new Error(`${where}.body must be a string`);
|
|
77
|
-
interaction.body = raw.body;
|
|
78
|
-
}
|
|
79
|
-
if (raw.bodyPath !== undefined) {
|
|
80
|
-
if (typeof raw.bodyPath !== 'string')
|
|
81
|
-
throw new Error(`${where}.bodyPath must be a string`);
|
|
82
|
-
interaction.bodyPath = raw.bodyPath;
|
|
83
|
-
}
|
|
84
|
-
if (raw.freetextLabel !== undefined) {
|
|
85
|
-
if (typeof raw.freetextLabel !== 'string')
|
|
86
|
-
throw new Error(`${where}.freetextLabel must be a string`);
|
|
87
|
-
interaction.freetextLabel = raw.freetextLabel;
|
|
88
|
-
}
|
|
89
|
-
if (raw.allowFreetext !== undefined) {
|
|
90
|
-
if (typeof raw.allowFreetext !== 'boolean')
|
|
91
|
-
throw new Error(`${where}.allowFreetext must be a boolean`);
|
|
92
|
-
interaction.allowFreetext = raw.allowFreetext;
|
|
93
|
-
}
|
|
94
|
-
if (raw.kind !== undefined) {
|
|
95
|
-
interaction.kind = raw.kind;
|
|
96
|
-
}
|
|
97
|
-
validated.push(interaction);
|
|
98
|
-
}
|
|
99
|
-
const deck = { interactions: validated };
|
|
100
|
-
if (obj.title !== undefined)
|
|
101
|
-
deck.title = obj.title;
|
|
102
|
-
return deck;
|
|
14
|
+
return validateDeck(parsed);
|
|
103
15
|
}
|
|
104
16
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
105
17
|
function buildInitialState(deck) {
|
|
@@ -269,17 +181,18 @@ export function mountPanel(opts) {
|
|
|
269
181
|
},
|
|
270
182
|
};
|
|
271
183
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Resolve an interaction directory in place: mount the panel TUI keyed off
|
|
186
|
+
* `<dir>/progress.json`, and on finish (full completion OR human-finished
|
|
187
|
+
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
188
|
+
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
189
|
+
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
190
|
+
*/
|
|
191
|
+
export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
279
192
|
let conversationContext = '';
|
|
280
|
-
if (sessionId !== undefined) {
|
|
193
|
+
if (opts.sessionId !== undefined) {
|
|
281
194
|
try {
|
|
282
|
-
const conv = readConversation(sessionId);
|
|
195
|
+
const conv = readConversation(opts.sessionId);
|
|
283
196
|
conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
284
197
|
}
|
|
285
198
|
catch {
|
|
@@ -287,7 +200,13 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
287
200
|
}
|
|
288
201
|
}
|
|
289
202
|
setupTerminal();
|
|
290
|
-
const
|
|
203
|
+
const term = getTerminalSize();
|
|
204
|
+
const cols = opts.cols ?? term.cols;
|
|
205
|
+
const rows = opts.rows ?? term.rows;
|
|
206
|
+
const generateVisual = opts.generateVisual ??
|
|
207
|
+
(opts.sessionId !== undefined
|
|
208
|
+
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
209
|
+
: undefined);
|
|
291
210
|
return new Promise((resolve) => {
|
|
292
211
|
let panel = null;
|
|
293
212
|
let prevFrameLocal = [];
|
|
@@ -302,28 +221,30 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
302
221
|
process.stdout.write('\x1b[?2026l');
|
|
303
222
|
prevFrameLocal = nextPrevFrame;
|
|
304
223
|
};
|
|
305
|
-
const
|
|
224
|
+
const finalize = (responses) => {
|
|
306
225
|
restoreTerminal();
|
|
307
226
|
process.stdin.removeListener('data', onData);
|
|
308
227
|
panel?.unmount();
|
|
309
|
-
|
|
228
|
+
const completedAt = new Date().toISOString();
|
|
229
|
+
// Resolved supersedes in-progress: write response.json, drop progress.json.
|
|
230
|
+
const rp = writeResponse(dir, responses, completedAt);
|
|
231
|
+
clearProgress(dir);
|
|
232
|
+
resolve({ responses, completedAt, responsePath: rp });
|
|
310
233
|
};
|
|
311
234
|
panel = mountPanel({
|
|
312
235
|
deck,
|
|
313
|
-
progressPath:
|
|
236
|
+
progressPath: progressPathFor(dir),
|
|
314
237
|
cols,
|
|
315
238
|
rows,
|
|
316
|
-
generateVisual
|
|
317
|
-
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
318
|
-
: undefined,
|
|
239
|
+
generateVisual,
|
|
319
240
|
onProgress: (responses) => {
|
|
320
241
|
lastResponses = responses;
|
|
321
242
|
if (panel !== null)
|
|
322
243
|
flushHost(panel.render());
|
|
323
244
|
},
|
|
324
|
-
onComplete,
|
|
245
|
+
onComplete: finalize,
|
|
325
246
|
onExit: () => {
|
|
326
|
-
|
|
247
|
+
finalize(lastResponses);
|
|
327
248
|
},
|
|
328
249
|
});
|
|
329
250
|
flushHost(panel.render());
|
|
@@ -335,3 +256,17 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
335
256
|
process.stdin.on('data', onData);
|
|
336
257
|
});
|
|
337
258
|
}
|
|
259
|
+
// ── launchTui — file-path entry over the dir resolver (a kept public export
|
|
260
|
+
// per the interaction-layer plan; consumed until consumers move to ask()) ──
|
|
261
|
+
export async function launchTui(decisionsPath, sessionId) {
|
|
262
|
+
if (!existsSync(decisionsPath)) {
|
|
263
|
+
throw new Error(`Decisions file not found: ${decisionsPath}`);
|
|
264
|
+
}
|
|
265
|
+
const raw = readFileSync(decisionsPath, 'utf8');
|
|
266
|
+
const deck = validateInput(JSON.parse(raw));
|
|
267
|
+
// The interaction dir is the deck file's directory; progress/response live
|
|
268
|
+
// there per the convention.
|
|
269
|
+
const dir = dirname(resolvePath(decisionsPath));
|
|
270
|
+
const { responses, completedAt } = await resolveInteractionDir(dir, deck, { sessionId });
|
|
271
|
+
return { responses, completedAt };
|
|
272
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -1,45 +1,5 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import stringWidth from 'string-width';
|
|
3
|
-
|
|
4
|
-
let _termrenderAvail = null;
|
|
5
|
-
function isTermrenderAvailable() {
|
|
6
|
-
if (_termrenderAvail !== null)
|
|
7
|
-
return _termrenderAvail;
|
|
8
|
-
try {
|
|
9
|
-
execFileSync('termrender', ['--version'], { stdio: 'pipe', timeout: 3000 });
|
|
10
|
-
_termrenderAvail = true;
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
_termrenderAvail = false;
|
|
14
|
-
}
|
|
15
|
-
return _termrenderAvail;
|
|
16
|
-
}
|
|
17
|
-
const _bodyCache = new Map();
|
|
18
|
-
function renderBody(text, width) {
|
|
19
|
-
const key = `${text}\0${width}`;
|
|
20
|
-
const cached = _bodyCache.get(key);
|
|
21
|
-
if (cached)
|
|
22
|
-
return cached;
|
|
23
|
-
if (isTermrenderAvailable()) {
|
|
24
|
-
try {
|
|
25
|
-
const out = execFileSync('termrender', ['--width', String(width)], {
|
|
26
|
-
input: text,
|
|
27
|
-
encoding: 'utf-8',
|
|
28
|
-
timeout: 5000,
|
|
29
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
-
});
|
|
31
|
-
const lines = out.split('\n');
|
|
32
|
-
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
33
|
-
lines.pop();
|
|
34
|
-
_bodyCache.set(key, lines);
|
|
35
|
-
return lines;
|
|
36
|
-
}
|
|
37
|
-
catch { /* fall through */ }
|
|
38
|
-
}
|
|
39
|
-
const fallback = wrap(sanitize(text), width);
|
|
40
|
-
_bodyCache.set(key, fallback);
|
|
41
|
-
return fallback;
|
|
42
|
-
}
|
|
2
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
43
3
|
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
44
4
|
const ESC = '\x1b[';
|
|
45
5
|
const RESET = `${ESC}0m`;
|
|
@@ -282,7 +242,7 @@ export function renderItemReview(state, cols, rows) {
|
|
|
282
242
|
const bodyLines = [];
|
|
283
243
|
if (interaction.body) {
|
|
284
244
|
bodyLines.push('');
|
|
285
|
-
for (const line of
|
|
245
|
+
for (const line of renderMarkdown(interaction.body, maxW)) {
|
|
286
246
|
bodyLines.push(` ${line}`);
|
|
287
247
|
}
|
|
288
248
|
}
|
package/dist/tui/tmux.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export interface TuiOutput {
|
|
3
|
-
responses: InteractionResponse[];
|
|
4
|
-
completedAt: string;
|
|
5
|
-
}
|
|
1
|
+
import type { ResolutionEnvelope } from '../types.js';
|
|
6
2
|
export interface TmuxDispatchOpts {
|
|
7
3
|
sessionId?: string;
|
|
8
4
|
visuals: boolean;
|
|
5
|
+
/** Interaction dir forwarded to the child so response.json lands there. */
|
|
6
|
+
dir: string;
|
|
9
7
|
}
|
|
10
|
-
export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<
|
|
8
|
+
export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<ResolutionEnvelope>;
|
package/dist/tui/tmux.js
CHANGED
|
@@ -15,8 +15,10 @@ function buildChildCmd(file, resultPath, opts) {
|
|
|
15
15
|
const parts = [
|
|
16
16
|
shellQuote(process.execPath),
|
|
17
17
|
shellQuote(scriptPath),
|
|
18
|
-
'
|
|
18
|
+
'ask',
|
|
19
19
|
shellQuote(file),
|
|
20
|
+
'--dir',
|
|
21
|
+
shellQuote(opts.dir),
|
|
20
22
|
'--write-to',
|
|
21
23
|
shellQuote(resultPath),
|
|
22
24
|
];
|
|
@@ -29,8 +31,8 @@ function buildChildCmd(file, resultPath, opts) {
|
|
|
29
31
|
return parts.join(' ');
|
|
30
32
|
}
|
|
31
33
|
export async function dispatchToTmuxPane(file, opts) {
|
|
32
|
-
const
|
|
33
|
-
const resultPath = join(
|
|
34
|
+
const parentTmp = mkdtempSync(join(tmpdir(), 'hl-'));
|
|
35
|
+
const resultPath = join(parentTmp, 'result.json');
|
|
34
36
|
const cmd = buildChildCmd(file, resultPath, opts);
|
|
35
37
|
// Capture the spawned pane id so we can detect if the user closes it
|
|
36
38
|
// without finishing — otherwise the parent would poll forever.
|
|
@@ -65,7 +67,7 @@ export async function dispatchToTmuxPane(file, opts) {
|
|
|
65
67
|
}
|
|
66
68
|
catch { /* ignore */ }
|
|
67
69
|
try {
|
|
68
|
-
rmdirSync(
|
|
70
|
+
rmdirSync(parentTmp);
|
|
69
71
|
}
|
|
70
72
|
catch { /* ignore */ }
|
|
71
73
|
return JSON.parse(json);
|
package/dist/types.d.ts
CHANGED
|
@@ -32,6 +32,32 @@ export interface Deck {
|
|
|
32
32
|
source?: DeckSource;
|
|
33
33
|
interactions: Interaction[];
|
|
34
34
|
}
|
|
35
|
+
export interface FeedbackComment {
|
|
36
|
+
id: string;
|
|
37
|
+
/** 1-based source line where the comment is anchored (start). */
|
|
38
|
+
line: number;
|
|
39
|
+
/** 1-based source line where the anchored range ends (== line for one line). */
|
|
40
|
+
endLine: number;
|
|
41
|
+
/** Exact selected substring when the human made a visual selection. */
|
|
42
|
+
quote?: string;
|
|
43
|
+
/** 0-based byte column where a partial (charwise) selection starts on `line`. */
|
|
44
|
+
colStart?: number;
|
|
45
|
+
/** 0-based exclusive byte column where the selection ends on `endLine`. */
|
|
46
|
+
colEnd?: number;
|
|
47
|
+
/** Full source text of the anchored line(s) — context for the agent. */
|
|
48
|
+
lineText: string;
|
|
49
|
+
comment: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
}
|
|
52
|
+
export interface FeedbackResult {
|
|
53
|
+
file: string;
|
|
54
|
+
submitted: boolean;
|
|
55
|
+
/** True when submitted with zero comments — human signalled "looks good". */
|
|
56
|
+
approved: boolean;
|
|
57
|
+
comments: FeedbackComment[];
|
|
58
|
+
submittedAt?: string;
|
|
59
|
+
savedAt: string;
|
|
60
|
+
}
|
|
35
61
|
export interface VisualBlock {
|
|
36
62
|
questionId: string;
|
|
37
63
|
content: string;
|
|
@@ -58,6 +84,45 @@ export interface TuiState {
|
|
|
58
84
|
scrollOffset: number;
|
|
59
85
|
persist?: () => void;
|
|
60
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolution contract returned by `ask`/`inbox`. On-disk `response.json` stays
|
|
89
|
+
* `{ responses, completedAt }`; `responsePath` points at it. `hl schema
|
|
90
|
+
* response` returns the JSON Schema this `schema` id names.
|
|
91
|
+
*/
|
|
92
|
+
export interface ResolutionEnvelope {
|
|
93
|
+
/** 1 line/interaction "<title>: <option label>[ — <freetext>]"; deterministic, no LLM. */
|
|
94
|
+
summary: string;
|
|
95
|
+
/** Absolute path to response.json. */
|
|
96
|
+
responsePath: string;
|
|
97
|
+
schema: 'humanloop.response/v2';
|
|
98
|
+
/** Inline (small). */
|
|
99
|
+
responses: InteractionResponse[];
|
|
100
|
+
/** ISO timestamp. */
|
|
101
|
+
completedAt: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One pending interaction discovered by `scanInbox`. Read from the
|
|
105
|
+
* `deck.json` header only — never the full deck.
|
|
106
|
+
*/
|
|
107
|
+
export interface InboxItem {
|
|
108
|
+
dir: string;
|
|
109
|
+
id: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
subtitle?: string;
|
|
112
|
+
kind?: InteractionKind;
|
|
113
|
+
/** `deck.source.blockedSince` ?? `statSync(deck.json).mtime` (ISO). */
|
|
114
|
+
blockedSince: string;
|
|
115
|
+
source?: DeckSource;
|
|
116
|
+
}
|
|
117
|
+
/** Options for `display()` — the live-watch tmux pane surface. */
|
|
118
|
+
export interface DisplayOpts {
|
|
119
|
+
/** Pass `--watch` so the pane live-updates on edits. Default true. */
|
|
120
|
+
watch?: boolean;
|
|
121
|
+
/** `'auto'` (default) splits until the pane budget, then opens a new window. */
|
|
122
|
+
window?: 'auto' | 'split' | 'new';
|
|
123
|
+
/** Pane budget per window before `'auto'` opens a new window. Default 3. */
|
|
124
|
+
maxPanes?: number;
|
|
125
|
+
}
|
|
61
126
|
export type GenerateVisual = (interaction: Interaction) => Promise<{
|
|
62
127
|
ok: true;
|
|
63
128
|
ansi: string;
|
package/dist/visuals/generate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from '@r-cli/sdk';
|
|
2
|
-
import {
|
|
2
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
3
3
|
const VISUAL_SYSTEM_PROMPT = `You're briefing a CTO-level engineer in the 30 seconds before they decide. They've been off this problem for days; they need a fast re-ground in what *already exists* — the files, data flow, or constraint they're deciding inside of — not a lecture on tradeoffs.
|
|
4
4
|
|
|
5
5
|
# Length
|
|
@@ -68,31 +68,6 @@ async function callHaiku(prompt, systemPrompt) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
function renderWithTermrender(markdown, width) {
|
|
72
|
-
// First attempt
|
|
73
|
-
const result = tryTermrender(markdown, width);
|
|
74
|
-
if (result !== null)
|
|
75
|
-
return result;
|
|
76
|
-
// Fallback: strip all directives and render as plain markdown
|
|
77
|
-
const stripped = markdown.replace(/^:{3,}\w*.*$/gm, '').trim();
|
|
78
|
-
const fallback = tryTermrender(stripped, width);
|
|
79
|
-
return fallback ?? markdown;
|
|
80
|
-
}
|
|
81
|
-
function tryTermrender(markdown, width) {
|
|
82
|
-
try {
|
|
83
|
-
return execSync(`termrender -w ${width}`, {
|
|
84
|
-
input: markdown,
|
|
85
|
-
encoding: 'utf8',
|
|
86
|
-
timeout: 5000,
|
|
87
|
-
env: { ...process.env, TERMRENDER_COLOR: '1' },
|
|
88
|
-
}).trimEnd();
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
const stderr = err.stderr || '';
|
|
92
|
-
process.stderr.write(`[hl] termrender: ${stderr.split('\n')[0]}\n`);
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
71
|
// defaultGenerateVisual matches the GenerateVisual contract for use with
|
|
97
72
|
// mountPanel. Width is read from process.stdout.columns so callers that
|
|
98
73
|
// embed humanloop in a sub-region should supply their own closure that bakes
|
|
@@ -113,7 +88,7 @@ export async function defaultGenerateVisual(interaction, conversationContext) {
|
|
|
113
88
|
.replace(/^```[\w]*\n?/gm, '')
|
|
114
89
|
.replace(/^```\s*$/gm, '')
|
|
115
90
|
.trim();
|
|
116
|
-
const ansi =
|
|
91
|
+
const ansi = renderMarkdown(markdown, width).join('\n');
|
|
117
92
|
return { ok: true, ansi, markdown };
|
|
118
93
|
}
|
|
119
94
|
return { ok: false, error: 'haiku returned no output' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/humanloop",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,12 +25,14 @@
|
|
|
25
25
|
"build": "tsc",
|
|
26
26
|
"dev": "tsx src/cli.ts",
|
|
27
27
|
"link": "npm link",
|
|
28
|
+
"postinstall": "node dist/scripts/install-renderer.js || true",
|
|
28
29
|
"test": "tsx src/__tests__/mount-panel.test.ts"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@r-cli/sdk": "^1.3.0",
|
|
32
33
|
"commander": "^13.0.0",
|
|
33
|
-
"string-width": "^7.0.0"
|
|
34
|
+
"string-width": "^7.0.0",
|
|
35
|
+
"zod": "^4.3.6"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@types/node": "^22.0.0",
|