@crouton-kit/humanloop 0.3.21 → 0.3.23

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.js CHANGED
@@ -7,52 +7,11 @@ import { pickFromInbox } from './inbox/tui.js';
7
7
  import { deckPath, atomicWriteJson, readJson, stampCanvasNode } from './inbox/convention.js';
8
8
  import { getTerminalSize } from './tui/terminal.js';
9
9
  import { notifyDeck } from './inbox/deck-factories.js';
10
+ import { buildSummary } from './summary.js';
10
11
  const RESPONSE_SCHEMA_ID = 'humanloop.response/v2';
11
12
  function managedDir() {
12
13
  return mkdtempSync(join(tmpdir(), 'hl-ix-'));
13
14
  }
14
- /**
15
- * Deterministic, no-LLM resolution summary — one line per answered
16
- * interaction: `"<title>: <option label>[ — <freetext>]"`.
17
- */
18
- function buildSummary(deck, responses) {
19
- const byId = new Map(responses.map((r) => [r.id, r]));
20
- const lines = [];
21
- for (const it of deck.interactions) {
22
- const r = byId.get(it.id);
23
- if (r === undefined)
24
- continue;
25
- const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
26
- let picked;
27
- if (r.selectedOptionIds !== undefined) {
28
- const oc = r.optionComments;
29
- const parts = r.selectedOptionIds
30
- .map((id) => it.options.find((o) => o.id === id))
31
- .filter((o) => o !== undefined)
32
- .map((o) => {
33
- const note = oc !== undefined ? oc[o.id] : undefined;
34
- return typeof note === 'string' && note.length > 0
35
- ? `${o.label} ("${note}")`
36
- : o.label;
37
- });
38
- picked = parts.length > 0 ? parts.join(', ') : undefined;
39
- }
40
- else if (r.selectedOptionId !== undefined) {
41
- picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
42
- }
43
- let val;
44
- if (picked !== undefined && ft !== undefined)
45
- val = `${picked} — ${ft}`;
46
- else if (picked !== undefined)
47
- val = picked;
48
- else if (ft !== undefined)
49
- val = ft;
50
- else
51
- val = '(skipped)';
52
- lines.push(`${it.title}: ${val}`);
53
- }
54
- return lines.join('\n');
55
- }
56
15
  /**
57
16
  * Resolve a deck against an interaction directory and return the resolution
58
17
  * envelope. Writes `<dir>/deck.json` (the request, per the convention) and,
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { display } from './surfaces/display.js';
12
12
  import { renderMarkdown, checkMarkdown } from './render/termrender.js';
13
13
  import { scanInbox } from './inbox/scan.js';
14
14
  import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
15
+ import { buildSummary } from './summary.js';
15
16
  // ── Version ───────────────────────────────────────────────────────────────────
16
17
  const HL_VERSION = '0.2.1';
17
18
  function emitError(err, exitCode = 1) {
@@ -898,8 +899,15 @@ function tryReadJobResult(dir, kind) {
898
899
  if (!raw)
899
900
  return null;
900
901
  const dk = readJson(deckPath(dir));
902
+ // Prefer the summary persisted at write time. Legacy response.json files
903
+ // (written before the summary field existed) lack it — rebuild it from the
904
+ // deck if it's still on disk, else fall back to ''.
905
+ let summary = typeof raw.summary === 'string' ? raw.summary : '';
906
+ if (summary === '' && dk) {
907
+ summary = buildSummary(dk, raw.responses);
908
+ }
901
909
  return {
902
- summary: '',
910
+ summary,
903
911
  responsePath: rp,
904
912
  schema: 'humanloop.response/v2',
905
913
  responses: raw.responses,
@@ -21,6 +21,6 @@ export declare function isClaimed(dir: string): boolean;
21
21
  export declare function stampCanvasNode(deck: Deck): void;
22
22
  export declare function atomicWriteJson(path: string, value: unknown): void;
23
23
  export declare function readJson<T>(path: string): T | null;
24
- export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string): string;
24
+ export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
25
25
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
26
26
  export declare function clearProgress(dir: string): void;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
2
2
  import { dirname } from 'path';
3
+ import { buildSummary } from '../summary.js';
3
4
  // ── Path helpers ──────────────────────────────────────────────────────────────
4
5
  export function deckPath(dir) {
5
6
  return `${dir}/deck.json`;
@@ -81,9 +82,15 @@ export function readJson(path) {
81
82
  }
82
83
  }
83
84
  // ── High-level write helpers ──────────────────────────────────────────────────
84
- export function writeResponse(dir, responses, completedAt) {
85
+ export function writeResponse(dir, responses, completedAt, deck) {
85
86
  const p = responsePath(dir);
86
- atomicWriteJson(p, { responses, completedAt });
87
+ // Persist the deterministic summary alongside the raw responses so
88
+ // `hl job result` can return a populated summary without re-deriving it (or
89
+ // silently emitting ''). When the deck is known at write time we compute it
90
+ // here; the deck is deterministic input so this never diverges from ask()'s
91
+ // envelope summary.
92
+ const summary = deck !== undefined ? buildSummary(deck, responses) : '';
93
+ atomicWriteJson(p, { responses, completedAt, summary });
87
94
  return p;
88
95
  }
89
96
  export function writeProgress(dir, responses) {
package/dist/inbox/tui.js CHANGED
@@ -1,16 +1,7 @@
1
- import stringWidth from 'string-width';
2
1
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize, } from '../tui/terminal.js';
3
2
  import { diffFrame } from '../tui/render.js';
3
+ import { RESET, BOLD, DIM, ITALIC, CYAN, RED, GRAY, YELLOW, truncateRow } from '../tui/ansi.js';
4
4
  // ── ANSI helpers (local to this module) ──────────────────────────────────────
5
- const ESC = '\x1b[';
6
- const RESET = `${ESC}0m`;
7
- const BOLD = `${ESC}1m`;
8
- const DIM = `${ESC}2m`;
9
- const ITALIC = `${ESC}3m`;
10
- const CYAN = `${ESC}36m`;
11
- const RED = `${ESC}31m`;
12
- const GRAY = `${ESC}90m`;
13
- const YELLOW = `${ESC}33m`;
14
5
  function ansiColor(text, color) {
15
6
  switch (color) {
16
7
  case 'gray': return `${GRAY}${text}${RESET}`;
@@ -44,26 +35,6 @@ export function formatTimeAgo(iso) {
44
35
  return `${minutes}m ago`;
45
36
  return 'just now';
46
37
  }
47
- // ── truncate (ported from sisyphus src/tui/lib/format.ts:19-37) ──────────────
48
- function truncate(text, max) {
49
- const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
50
- if (max < 4)
51
- return clean.slice(0, max);
52
- const w = stringWidth(clean);
53
- if (w <= max)
54
- return clean;
55
- let result = clean;
56
- while (stringWidth(result) > max - 1 && result.length > 0) {
57
- const cut = result.lastIndexOf(' ', result.length - 2);
58
- if (cut > max * 0.4) {
59
- result = result.slice(0, cut);
60
- }
61
- else {
62
- result = result.slice(0, result.length - 1);
63
- }
64
- }
65
- return result + '…';
66
- }
67
38
  // ── buildInboxLines ───────────────────────────────────────────────────────────
68
39
  export function buildInboxLines(items, width, selectedIndex) {
69
40
  const lines = [];
@@ -93,11 +64,11 @@ export function buildInboxLines(items, width, selectedIndex) {
93
64
  else {
94
65
  row += ' ';
95
66
  }
96
- row += `${BOLD}${truncate(titleText, maxTitle)}${RESET}`;
67
+ row += `${BOLD}${truncateRow(titleText, maxTitle)}${RESET}`;
97
68
  row += ` ${DIM}${blocked}${RESET}`;
98
69
  lines.push(row);
99
70
  if (item.subtitle) {
100
- lines.push(` ${DIM}${truncate(item.subtitle, contentWidth - 6)}${RESET}`);
71
+ lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
101
72
  }
102
73
  }
103
74
  return lines;
@@ -113,17 +84,24 @@ export function pickFromInbox(items, opts) {
113
84
  const flush = () => {
114
85
  const { cols: currentCols, rows: currentRows } = getTerminalSize();
115
86
  const lines = buildInboxLines(items, currentCols, selectedIndex);
116
- const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, currentRows);
87
+ const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, currentRows, currentCols);
117
88
  process.stdout.write('\x1b[?2026h');
118
89
  for (const w of writes)
119
90
  process.stdout.write(w);
120
91
  process.stdout.write('\x1b[?2026l');
121
92
  prevFrame = nextPrevFrame;
122
93
  };
94
+ // Resize reflows/scrolls what's already on screen, invalidating the diff
95
+ // model — clear and redraw from scratch at the new size.
96
+ const onResize = () => {
97
+ prevFrame = [];
98
+ process.stdout.write('\x1b[2J\x1b[H');
99
+ flush();
100
+ };
123
101
  const done = (result) => {
124
102
  restoreTerminal();
125
103
  process.stdin.removeListener('data', onData);
126
- process.stdout.removeListener('resize', flush);
104
+ process.stdout.removeListener('resize', onResize);
127
105
  resolve(result);
128
106
  };
129
107
  setupTerminal();
@@ -151,6 +129,6 @@ export function pickFromInbox(items, opts) {
151
129
  }
152
130
  };
153
131
  process.stdin.on('data', onData);
154
- process.stdout.on('resize', flush);
132
+ process.stdout.on('resize', onResize);
155
133
  });
156
134
  }
@@ -0,0 +1,9 @@
1
+ import type { Deck, InteractionResponse } from './types.js';
2
+ /**
3
+ * Deterministic, no-LLM resolution summary — one line per answered
4
+ * interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
5
+ * (envelope summary), `writeResponse` (persisted into response.json at write
6
+ * time), and `hl job result` (fallback rebuild for legacy response.json files
7
+ * written before the summary field existed).
8
+ */
9
+ export declare function buildSummary(deck: Deck, responses: InteractionResponse[]): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deterministic, no-LLM resolution summary — one line per answered
3
+ * interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
4
+ * (envelope summary), `writeResponse` (persisted into response.json at write
5
+ * time), and `hl job result` (fallback rebuild for legacy response.json files
6
+ * written before the summary field existed).
7
+ */
8
+ export function buildSummary(deck, responses) {
9
+ const byId = new Map(responses.map((r) => [r.id, r]));
10
+ const lines = [];
11
+ for (const it of deck.interactions) {
12
+ const r = byId.get(it.id);
13
+ if (r === undefined)
14
+ continue;
15
+ const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
16
+ let picked;
17
+ if (r.selectedOptionIds !== undefined) {
18
+ const oc = r.optionComments;
19
+ const parts = r.selectedOptionIds
20
+ .map((id) => it.options.find((o) => o.id === id))
21
+ .filter((o) => o !== undefined)
22
+ .map((o) => {
23
+ const note = oc !== undefined ? oc[o.id] : undefined;
24
+ return typeof note === 'string' && note.length > 0
25
+ ? `${o.label} ("${note}")`
26
+ : o.label;
27
+ });
28
+ picked = parts.length > 0 ? parts.join(', ') : undefined;
29
+ }
30
+ else if (r.selectedOptionId !== undefined) {
31
+ picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
32
+ }
33
+ let val;
34
+ if (picked !== undefined && ft !== undefined)
35
+ val = `${picked} — ${ft}`;
36
+ else if (picked !== undefined)
37
+ val = picked;
38
+ else if (ft !== undefined)
39
+ val = ft;
40
+ else
41
+ val = '(skipped)';
42
+ lines.push(`${it.title}: ${val}`);
43
+ }
44
+ return lines.join('\n');
45
+ }
@@ -0,0 +1,43 @@
1
+ export declare const ESC = "\u001B[";
2
+ export declare const RESET = "\u001B[0m";
3
+ export declare const BOLD = "\u001B[1m";
4
+ export declare const DIM = "\u001B[2m";
5
+ export declare const ITALIC = "\u001B[3m";
6
+ export declare const RED = "\u001B[31m";
7
+ export declare const GREEN = "\u001B[32m";
8
+ export declare const YELLOW = "\u001B[33m";
9
+ export declare const CYAN = "\u001B[36m";
10
+ export declare const GRAY = "\u001B[90m";
11
+ export declare const REVERSE = "\u001B[7m";
12
+ export declare function sanitize(text: string): string;
13
+ export declare function singleLine(text: string): string;
14
+ export declare function hline(width: number, char?: string): string;
15
+ export declare function padRight(text: string, width: number): string;
16
+ /** Char-based width truncation with a trailing ellipsis. The canonical deck
17
+ * renderer variant. */
18
+ export declare function truncate(text: string, maxWidth: number): string;
19
+ /** Word-aware truncation that also flattens newlines and strips decorative
20
+ * emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
21
+ * distinct from `truncate` because those two surfaces want different behavior. */
22
+ export declare function truncateRow(text: string, max: number): string;
23
+ /** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
24
+ export declare function wrap(text: string, maxWidth: number): string[];
25
+ /** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
26
+ * free-form input buffers where every character (including runs without
27
+ * spaces) must stay on screen. */
28
+ export declare function hardWrap(text: string, maxWidth: number): string[];
29
+ /**
30
+ * Pad each non-empty line with leading spaces to horizontally center the
31
+ * `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
32
+ * narrow panes skip centering. Empty lines stay empty so frame diffing keeps
33
+ * them as cheap no-ops.
34
+ */
35
+ export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
36
+ /**
37
+ * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
38
+ * escape sequences through untouched. Every line written to the terminal must
39
+ * fit within the columns, or it physically wraps onto the next row — which
40
+ * breaks diffFrame's one-logical-line-per-row model and strands spillover text
41
+ * on rows the differ believes are empty (so it never erases them).
42
+ */
43
+ export declare function clipLine(line: string, maxWidth: number): string;
@@ -0,0 +1,210 @@
1
+ import stringWidth from 'string-width';
2
+ // ── ANSI escape constants ─────────────────────────────────────────────────────
3
+ // The single home for these — both the deck renderer (render.ts) and the inbox
4
+ // picker (inbox/tui.ts) import from here rather than keeping their own copies.
5
+ export const ESC = '\x1b[';
6
+ export const RESET = `${ESC}0m`;
7
+ export const BOLD = `${ESC}1m`;
8
+ export const DIM = `${ESC}2m`;
9
+ export const ITALIC = `${ESC}3m`;
10
+ export const RED = `${ESC}31m`;
11
+ export const GREEN = `${ESC}32m`;
12
+ export const YELLOW = `${ESC}33m`;
13
+ export const CYAN = `${ESC}36m`;
14
+ export const GRAY = `${ESC}90m`;
15
+ export const REVERSE = `${ESC}7m`;
16
+ // ── Text helpers ──────────────────────────────────────────────────────────────
17
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
18
+ export function sanitize(text) {
19
+ if (typeof text !== 'string')
20
+ return '';
21
+ return text.replace(CONTROL_CHARS_RE, '');
22
+ }
23
+ export function singleLine(text) {
24
+ return sanitize(text).replace(/\s+/g, ' ').trim();
25
+ }
26
+ export function hline(width, char = '─') {
27
+ if (width < 1)
28
+ return '';
29
+ return char.repeat(width);
30
+ }
31
+ export function padRight(text, width) {
32
+ const w = stringWidth(text);
33
+ if (w >= width)
34
+ return text;
35
+ return text + ' '.repeat(width - w);
36
+ }
37
+ /** Char-based width truncation with a trailing ellipsis. The canonical deck
38
+ * renderer variant. */
39
+ export function truncate(text, maxWidth) {
40
+ if (maxWidth < 1)
41
+ return '';
42
+ if (stringWidth(text) <= maxWidth)
43
+ return text;
44
+ const chars = [...text];
45
+ let w = 0;
46
+ let out = '';
47
+ for (const ch of chars) {
48
+ const cw = stringWidth(ch);
49
+ if (w + cw + 1 > maxWidth)
50
+ break;
51
+ out += ch;
52
+ w += cw;
53
+ }
54
+ return out + '…';
55
+ }
56
+ /** Word-aware truncation that also flattens newlines and strips decorative
57
+ * emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
58
+ * distinct from `truncate` because those two surfaces want different behavior. */
59
+ export function truncateRow(text, max) {
60
+ const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
61
+ if (max < 4)
62
+ return clean.slice(0, max);
63
+ const w = stringWidth(clean);
64
+ if (w <= max)
65
+ return clean;
66
+ let result = clean;
67
+ while (stringWidth(result) > max - 1 && result.length > 0) {
68
+ const cut = result.lastIndexOf(' ', result.length - 2);
69
+ if (cut > max * 0.4) {
70
+ result = result.slice(0, cut);
71
+ }
72
+ else {
73
+ result = result.slice(0, result.length - 1);
74
+ }
75
+ }
76
+ return result + '…';
77
+ }
78
+ function sliceByWidth(s, maxWidth) {
79
+ let w = 0;
80
+ let out = '';
81
+ for (const ch of s) {
82
+ const cw = stringWidth(ch);
83
+ if (w + cw > maxWidth)
84
+ break;
85
+ out += ch;
86
+ w += cw;
87
+ }
88
+ if (out === '' && s.length > 0)
89
+ out = [...s][0];
90
+ return out;
91
+ }
92
+ /** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
93
+ export function wrap(text, maxWidth) {
94
+ if (maxWidth < 1)
95
+ return [text];
96
+ const out = [];
97
+ const paragraphs = text.split('\n');
98
+ for (let p = 0; p < paragraphs.length; p++) {
99
+ const para = paragraphs[p];
100
+ if (para === '') {
101
+ out.push('');
102
+ continue;
103
+ }
104
+ const words = para.split(/[ \t]+/).filter(Boolean);
105
+ let current = '';
106
+ for (let word of words) {
107
+ while (stringWidth(word) > maxWidth) {
108
+ if (current) {
109
+ out.push(current);
110
+ current = '';
111
+ }
112
+ const piece = sliceByWidth(word, maxWidth);
113
+ out.push(piece);
114
+ word = word.slice(piece.length);
115
+ }
116
+ const candidate = current ? `${current} ${word}` : word;
117
+ if (stringWidth(candidate) <= maxWidth) {
118
+ current = candidate;
119
+ }
120
+ else {
121
+ if (current)
122
+ out.push(current);
123
+ current = word;
124
+ }
125
+ }
126
+ if (current)
127
+ out.push(current);
128
+ }
129
+ return out.length > 0 ? out : [''];
130
+ }
131
+ /** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
132
+ * free-form input buffers where every character (including runs without
133
+ * spaces) must stay on screen. */
134
+ export function hardWrap(text, maxWidth) {
135
+ if (maxWidth < 1)
136
+ return [text];
137
+ const segments = text.split('\n');
138
+ const out = [];
139
+ for (const seg of segments) {
140
+ if (seg.length === 0) {
141
+ out.push('');
142
+ continue;
143
+ }
144
+ let current = '';
145
+ let currentW = 0;
146
+ for (const ch of [...seg]) {
147
+ const cw = stringWidth(ch);
148
+ if (currentW + cw > maxWidth) {
149
+ out.push(current);
150
+ current = ch;
151
+ currentW = cw;
152
+ }
153
+ else {
154
+ current += ch;
155
+ currentW += cw;
156
+ }
157
+ }
158
+ out.push(current);
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * Pad each non-empty line with leading spaces to horizontally center the
164
+ * `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
165
+ * narrow panes skip centering. Empty lines stay empty so frame diffing keeps
166
+ * them as cheap no-ops.
167
+ */
168
+ export function centerHorizontal(lines, cols, contentWidth) {
169
+ const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
170
+ if (extraPad === 0)
171
+ return lines;
172
+ const pad = ' '.repeat(extraPad);
173
+ return lines.map((line) => (line === '' ? '' : pad + line));
174
+ }
175
+ /**
176
+ * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
177
+ * escape sequences through untouched. Every line written to the terminal must
178
+ * fit within the columns, or it physically wraps onto the next row — which
179
+ * breaks diffFrame's one-logical-line-per-row model and strands spillover text
180
+ * on rows the differ believes are empty (so it never erases them).
181
+ */
182
+ export function clipLine(line, maxWidth) {
183
+ if (maxWidth < 1)
184
+ return '';
185
+ if (stringWidth(line) <= maxWidth)
186
+ return line; // string-width ignores ANSI
187
+ let out = '';
188
+ let w = 0;
189
+ let i = 0;
190
+ let sawAnsi = false;
191
+ while (i < line.length) {
192
+ if (line[i] === '\x1b') {
193
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]|^\x1b[@-_]/.exec(line.slice(i));
194
+ if (m !== null) {
195
+ out += m[0];
196
+ i += m[0].length;
197
+ sawAnsi = true;
198
+ continue;
199
+ }
200
+ }
201
+ const ch = String.fromCodePoint(line.codePointAt(i));
202
+ const cw = stringWidth(ch);
203
+ if (w + cw > maxWidth)
204
+ break;
205
+ out += ch;
206
+ w += cw;
207
+ i += ch.length;
208
+ }
209
+ return sawAnsi ? out + RESET : out;
210
+ }