@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/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
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
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
- // ── launchTui shim ────────────────────────────────────────────────────────────
273
- export async function launchTui(decisionsPath, sessionId) {
274
- if (!existsSync(decisionsPath)) {
275
- throw new Error(`Decisions file not found: ${decisionsPath}`);
276
- }
277
- const raw = readFileSync(decisionsPath, 'utf8');
278
- const deck = validateInput(JSON.parse(raw));
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 { cols, rows } = getTerminalSize();
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 onComplete = (responses) => {
224
+ const finalize = (responses) => {
306
225
  restoreTerminal();
307
226
  process.stdin.removeListener('data', onData);
308
227
  panel?.unmount();
309
- resolve({ responses, completedAt: new Date().toISOString() });
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: `${decisionsPath}.progress.json`,
236
+ progressPath: progressPathFor(dir),
314
237
  cols,
315
238
  rows,
316
- generateVisual: sessionId !== undefined
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
- onComplete(lastResponses);
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
+ }
@@ -1,45 +1,5 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import stringWidth from 'string-width';
3
- // ── Termrender body rendering ────────────────────────────────────────────────
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 renderBody(interaction.body, maxW)) {
245
+ for (const line of renderMarkdown(interaction.body, maxW)) {
286
246
  bodyLines.push(` ${line}`);
287
247
  }
288
248
  }
@@ -1,10 +1,8 @@
1
- import type { InteractionResponse } from '../types.js';
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<TuiOutput>;
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
- 'create',
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 dir = mkdtempSync(join(tmpdir(), 'hl-'));
33
- const resultPath = join(dir, 'result.json');
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(dir);
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;
@@ -1,5 +1,5 @@
1
1
  import { query } from '@r-cli/sdk';
2
- import { execSync } from 'child_process';
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 = renderWithTermrender(markdown, width);
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.4",
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",