@duetso/agent 0.1.56 → 0.1.58

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.
Files changed (52) hide show
  1. package/dist/package.json +2 -2
  2. package/dist/src/cli/memory-db.d.ts +1 -1
  3. package/dist/src/cli/memory-db.js +3 -2
  4. package/dist/src/cli/memory-db.js.map +1 -1
  5. package/dist/src/memory/context-pack.d.ts +3 -3
  6. package/dist/src/memory/context-pack.d.ts.map +1 -1
  7. package/dist/src/memory/context-pack.js +2 -2
  8. package/dist/src/memory/loader.d.ts +12 -45
  9. package/dist/src/memory/loader.d.ts.map +1 -1
  10. package/dist/src/memory/loader.js +61 -77
  11. package/dist/src/memory/loader.js.map +1 -1
  12. package/dist/src/memory/migrations.d.ts.map +1 -1
  13. package/dist/src/memory/migrations.js +28 -0
  14. package/dist/src/memory/migrations.js.map +1 -1
  15. package/dist/src/memory/observational-prompts.d.ts.map +1 -1
  16. package/dist/src/memory/observational-prompts.js +3 -0
  17. package/dist/src/memory/observational-prompts.js.map +1 -1
  18. package/dist/src/memory/observational.d.ts +15 -3
  19. package/dist/src/memory/observational.d.ts.map +1 -1
  20. package/dist/src/memory/observational.js +117 -40
  21. package/dist/src/memory/observational.js.map +1 -1
  22. package/dist/src/memory/recall.js +2 -1
  23. package/dist/src/memory/recall.js.map +1 -1
  24. package/dist/src/memory/storage.d.ts +48 -27
  25. package/dist/src/memory/storage.d.ts.map +1 -1
  26. package/dist/src/memory/storage.js +65 -42
  27. package/dist/src/memory/storage.js.map +1 -1
  28. package/dist/src/memory/store.d.ts +20 -21
  29. package/dist/src/memory/store.d.ts.map +1 -1
  30. package/dist/src/memory/store.js +15 -99
  31. package/dist/src/memory/store.js.map +1 -1
  32. package/dist/src/tui/app.d.ts.map +1 -1
  33. package/dist/src/tui/app.js +146 -51
  34. package/dist/src/tui/app.js.map +1 -1
  35. package/dist/src/tui/clipboard.d.ts +16 -11
  36. package/dist/src/tui/clipboard.d.ts.map +1 -1
  37. package/dist/src/tui/clipboard.js +16 -11
  38. package/dist/src/tui/clipboard.js.map +1 -1
  39. package/dist/src/tui/theme.d.ts +12 -1
  40. package/dist/src/tui/theme.d.ts.map +1 -1
  41. package/dist/src/tui/theme.js +15 -1
  42. package/dist/src/tui/theme.js.map +1 -1
  43. package/dist/src/tui/transcript-log.d.ts +1 -1
  44. package/dist/src/tui/transcript-log.js +1 -1
  45. package/dist/src/turn-runner/tools.d.ts +1 -1
  46. package/dist/src/turn-runner/turn-runner.d.ts +2 -2
  47. package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
  48. package/dist/src/turn-runner/turn-runner.js +9 -5
  49. package/dist/src/turn-runner/turn-runner.js.map +1 -1
  50. package/dist/src/types/memory.d.ts +20 -36
  51. package/dist/src/types/memory.d.ts.map +1 -1
  52. package/package.json +2 -2
@@ -9,7 +9,7 @@ import { DUET_BANNER_LINES_COMPACT, historyDisplayBlocks, limitHistoryDisplayMes
9
9
  import { listRecentSessions } from "./recent-sessions.js";
10
10
  import { createSidebar, SIDEBAR_WIDTH } from "./sidebar.js";
11
11
  import { orderedSelectableStarters, selectStarters } from "./starters.js";
12
- import { COLORS, HINT_IDLE, HINT_RUNNING } from "./theme.js";
12
+ import { COLORS, HINT_IDLE, HINT_RUNNING, HINT_SELECTION_COPY } from "./theme.js";
13
13
  // Re-exports preserve the historical `tui/app.js` entry point used by tests
14
14
  // and external callers; the implementations live in focused leaf modules.
15
15
  export { activeFileAutocompleteToken, activeSkillAutocompleteToken, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, skillAutocompleteMatches, } from "./autocomplete.js";
@@ -30,11 +30,17 @@ const QUESTION_OPTION_LIMIT = AUTOCOMPLETE_LIMITS.questionOption;
30
30
  export async function runTui(input) {
31
31
  const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
32
32
  // useMouse: true so the scroll wheel reaches the transcript
33
- // ScrollBoxRenderable. Drag-select for native copy still works in every
34
- // mainstream terminal by holding Option (macOS) or Shift (most Linux
35
- // terminals) while dragging, which bypasses the terminal's mouse
36
- // capture. PageUp/PageDown and Shift+Up/Down keyboard bindings below are
37
- // the no-mouse fallback.
33
+ // ScrollBoxRenderable and so OpenTUI receives drag events for in-app
34
+ // text selection. Selected text is captured via the renderer's
35
+ // `selection` event below and copied to the clipboard via OSC 52 (or
36
+ // CLI fallback) on the platform-appropriate copy keystroke (Cmd+C on
37
+ // macOS, Ctrl+Shift+C elsewhere) or `/copy`. PageUp/PageDown and
38
+ // Shift+Up/Down keyboard bindings below cover terminals or sessions
39
+ // where the wheel does not reach us (e.g. tmux without mouse mode).
40
+ //
41
+ // Bare Ctrl+C remains the always-exit keystroke (handled via
42
+ // exitOnCtrlC) so the convention every other interactive Linux/Windows
43
+ // terminal app follows still works here.
38
44
  const renderer = await createCliRenderer({
39
45
  exitOnCtrlC: true,
40
46
  useMouse: true,
@@ -42,6 +48,16 @@ export async function runTui(input) {
42
48
  targetFps: 60,
43
49
  });
44
50
  restoreWindowGlobal(previousWindow);
51
+ // Most recent drag-selected text. OpenTUI emits the `selection` event
52
+ // whenever a drag finishes; we cache the resulting string so /copy and
53
+ // the copy keystroke can prefer the user's actual highlight over the
54
+ // last-message heuristic, and so the bottom hint can advertise the
55
+ // copy keystroke only while it actually does something.
56
+ let lastSelectionText = "";
57
+ renderer.on("selection", (selection) => {
58
+ lastSelectionText = selection.getSelectedText();
59
+ refreshHint();
60
+ });
45
61
  // Outer row wraps the main column and a right-side sidebar that surfaces
46
62
  // the runner's current todo list and state-machine progress.
47
63
  const root = new BoxRenderable(renderer, {
@@ -71,17 +87,22 @@ export async function runTui(input) {
71
87
  borderColor: COLORS.border,
72
88
  padding: 1,
73
89
  });
90
+ // Status and hint chrome are excluded from drag-select so a highlight that
91
+ // sweeps the bottom of the screen does not pull the spinner / hint text
92
+ // into the clipboard alongside the transcript content the user wanted.
74
93
  const status = new TextRenderable(renderer, {
75
94
  content: "",
76
95
  fg: COLORS.status,
77
96
  height: 1,
78
97
  flexShrink: 0,
98
+ selectable: false,
79
99
  });
80
100
  const hint = new TextRenderable(renderer, {
81
101
  content: HINT_IDLE,
82
102
  fg: COLORS.hint,
83
103
  height: 1,
84
104
  flexShrink: 0,
105
+ selectable: false,
85
106
  });
86
107
  const skillAutocompletePanel = new BoxRenderable(renderer, {
87
108
  flexDirection: "column",
@@ -99,11 +120,15 @@ export async function runTui(input) {
99
120
  // length so a one-line description doesn't leave an empty trailing line
100
121
  // beneath the name. The renderer sets `height` whenever it writes
101
122
  // `content`.
123
+ // Autocomplete and panel chrome are not part of the transcript content,
124
+ // so exclude them from drag-select to keep the clipboard focused on
125
+ // assistant/user messages.
102
126
  const makeItemRow = () => {
103
127
  const row = new TextRenderable(renderer, {
104
128
  content: "",
105
129
  fg: COLORS.hint,
106
130
  flexShrink: 0,
131
+ selectable: false,
107
132
  });
108
133
  row.visible = false;
109
134
  return row;
@@ -113,6 +138,7 @@ export async function runTui(input) {
113
138
  fg: COLORS.status,
114
139
  height: 1,
115
140
  flexShrink: 0,
141
+ selectable: false,
116
142
  });
117
143
  const commandHeader = makeHeaderRow("commands");
118
144
  const commandRows = Array.from({ length: BUILT_IN_SLASH_COMMANDS.length }, makeItemRow);
@@ -140,6 +166,7 @@ export async function runTui(input) {
140
166
  fg: COLORS.status,
141
167
  height: 1,
142
168
  flexShrink: 0,
169
+ selectable: false,
143
170
  });
144
171
  const fileAutocompleteRows = Array.from({ length: FILE_AUTOCOMPLETE_LIMIT }, () => {
145
172
  const row = new TextRenderable(renderer, {
@@ -147,6 +174,7 @@ export async function runTui(input) {
147
174
  fg: COLORS.hint,
148
175
  height: 1,
149
176
  flexShrink: 0,
177
+ selectable: false,
150
178
  });
151
179
  row.visible = false;
152
180
  return row;
@@ -169,11 +197,13 @@ export async function runTui(input) {
169
197
  fg: COLORS.agent,
170
198
  wrapMode: "word",
171
199
  flexShrink: 0,
200
+ selectable: false,
172
201
  });
173
202
  const questionSpacer = new TextRenderable(renderer, {
174
203
  content: "",
175
204
  height: 1,
176
205
  flexShrink: 0,
206
+ selectable: false,
177
207
  });
178
208
  const questionRows = Array.from({ length: QUESTION_OPTION_LIMIT }, () => {
179
209
  const row = new TextRenderable(renderer, {
@@ -181,6 +211,7 @@ export async function runTui(input) {
181
211
  fg: COLORS.hint,
182
212
  wrapMode: "word",
183
213
  flexShrink: 0,
214
+ selectable: false,
184
215
  });
185
216
  row.visible = false;
186
217
  return row;
@@ -198,10 +229,14 @@ export async function runTui(input) {
198
229
  paddingRight: 1,
199
230
  flexShrink: 0,
200
231
  });
232
+ // The leading "> " sigil is decoration, not content; excluding it from
233
+ // selection means a drag that starts at the input row does not pull the
234
+ // sigil into the clipboard alongside the highlighted text.
201
235
  const prompt = new TextRenderable(renderer, {
202
236
  content: "> ",
203
237
  fg: COLORS.user,
204
238
  width: 2,
239
+ selectable: false,
205
240
  });
206
241
  // Textarea (rather than Input) so long messages soft-wrap visually. Enter
207
242
  // is intercepted in onKeyDown below to submit instead of inserting a newline.
@@ -252,13 +287,21 @@ export async function runTui(input) {
252
287
  }
253
288
  function setHint(running) {
254
289
  const base = running ? HINT_RUNNING : HINT_IDLE;
255
- hint.content = pendingImages.length > 0 ? `${attachmentHint()} · ${base}` : base;
290
+ const segments = [];
291
+ if (pendingImages.length > 0)
292
+ segments.push(attachmentHint());
293
+ segments.push(base);
294
+ if (lastSelectionText.trim().length > 0)
295
+ segments.push(HINT_SELECTION_COPY);
296
+ hint.content = segments.join(" · ");
256
297
  }
257
298
  function attachmentHint() {
258
299
  const n = pendingImages.length;
259
300
  return n === 1 ? "📎 1 image attached" : `📎 ${n} images attached`;
260
301
  }
261
- function refreshAttachmentHint() {
302
+ // Single-channel hint refresh used by every input that affects what the
303
+ // bottom row should advertise (running state, attachments, selection).
304
+ function refreshHint() {
262
305
  setHint(running);
263
306
  }
264
307
  // ---- runtime state ---------------------------------------------------------
@@ -272,7 +315,7 @@ export async function runTui(input) {
272
315
  let nextImageId = 1;
273
316
  // Parallel record of user/agent message bodies driven by the same code
274
317
  // paths that render them into the transcript. The `/copy` slash command
275
- // and Ctrl+Y keystroke read from this log instead of trying to walk the
318
+ // and copy keystroke read from this log instead of trying to walk the
276
319
  // ScrollBoxRenderable, which only stores presentation lines.
277
320
  const transcriptLog = [];
278
321
  function recordTranscriptEntry(kind, text) {
@@ -850,29 +893,6 @@ export async function runTui(input) {
850
893
  let pendingQuestions = [];
851
894
  let questionOptionSelectedIndex = 0;
852
895
  let suppressNextEscapeExit = false;
853
- let closingAfterInterrupt = false;
854
- const requestExit = async () => {
855
- if (running) {
856
- if (closingAfterInterrupt)
857
- return;
858
- closingAfterInterrupt = true;
859
- stopWorkingTicker();
860
- setStatus("● interrupting…");
861
- try {
862
- await input.session.interrupt();
863
- await input.session.waitForTerminal();
864
- }
865
- catch (error) {
866
- reportError(error);
867
- }
868
- finally {
869
- renderer.destroy();
870
- }
871
- }
872
- else {
873
- renderer.destroy();
874
- }
875
- };
876
896
  function skillAutocompleteIsOpen() {
877
897
  return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
878
898
  }
@@ -1149,7 +1169,7 @@ export async function runTui(input) {
1149
1169
  return;
1150
1170
  }
1151
1171
  key.preventDefault();
1152
- void requestExit();
1172
+ handleEscape();
1153
1173
  });
1154
1174
  // Keyboard scroll bindings for the transcript. Mirrors the mouse wheel
1155
1175
  // for terminals that swallow mouse events (tmux without mouse mode, ssh
@@ -1232,13 +1252,29 @@ export async function runTui(input) {
1232
1252
  void triggerClipboardProbe("keystroke");
1233
1253
  return;
1234
1254
  }
1235
- // Ctrl+Y emacs-style "yank," repurposed here as the keyboard hotkey
1236
- // for the `/copy` slash command. macOS Cmd+C is owned by the terminal
1237
- // emulator and never reaches the TUI, so Ctrl+Y is the closest thing
1238
- // to a real copy keystroke we can deliver across all terminals.
1239
- if (key.name === "y" && key.ctrl && !key.shift && !key.super && !key.meta) {
1255
+ // Copy keystrokes. The set is intentionally generous because each
1256
+ // mainstream terminal forwards a different subset:
1257
+ //
1258
+ // - Cmd+C: macOS muscle memory; many terminals (Terminal.app, Warp)
1259
+ // own this for their own selection UI and never forward it.
1260
+ // - Cmd+Shift+C: forwarded by Warp on macOS and by some configs of
1261
+ // iTerm2 / Ghostty where Cmd+C is reserved.
1262
+ // - Ctrl+Shift+C: Linux/Windows terminal-app convention that
1263
+ // leaves bare Ctrl+C free for "exit."
1264
+ //
1265
+ // The hint label surfaces only the OS-natural one so the bottom row
1266
+ // stays terse. No-op when nothing is selected so the keystroke still
1267
+ // falls through to whatever the terminal would do natively. We accept
1268
+ // both "c" and "C" because some kitty parsers report the shifted
1269
+ // letter while others report the base letter plus shift=true.
1270
+ const isCopyLetter = key.name === "c" || key.name === "C";
1271
+ const cmdHeld = key.super || key.meta;
1272
+ const isCmdC = isCopyLetter && cmdHeld && !key.shift && !key.ctrl;
1273
+ const isCmdShiftC = isCopyLetter && cmdHeld && key.shift && !key.ctrl;
1274
+ const isCtrlShiftC = isCopyLetter && key.ctrl && key.shift && !cmdHeld;
1275
+ if ((isCmdC || isCmdShiftC || isCtrlShiftC) && lastSelectionText.trim().length > 0) {
1240
1276
  key.preventDefault();
1241
- void handleCopySlashCommand("/copy");
1277
+ void copyActiveSelection();
1242
1278
  return;
1243
1279
  }
1244
1280
  if (skillAutocompleteIsOpen()) {
@@ -1335,10 +1371,20 @@ export async function runTui(input) {
1335
1371
  return;
1336
1372
  }
1337
1373
  if (key.name === "escape") {
1338
- void requestExit();
1374
+ handleEscape();
1339
1375
  return;
1340
1376
  }
1341
1377
  };
1378
+ // Esc interrupts the in-flight turn; when nothing is running it is a
1379
+ // no-op so muscle memory does not eject the user out of the session.
1380
+ // Quitting goes through Ctrl+C (renderer's exitOnCtrlC) or closing the
1381
+ // terminal — both paths drain through the `finally` block in
1382
+ // cli/run.ts that disposes the SessionManager and flushes PGlite.
1383
+ function handleEscape() {
1384
+ if (!running)
1385
+ return;
1386
+ void input.session.interrupt().catch(reportError);
1387
+ }
1342
1388
  inputField.onContentChange = () => {
1343
1389
  // First real keystroke into the input collapses the starter section
1344
1390
  // — the user is composing their own prompt, so the suggestions get
@@ -1405,7 +1451,7 @@ export async function runTui(input) {
1405
1451
  pendingImages.push(pending);
1406
1452
  inputField.insertText(pending.label);
1407
1453
  appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
1408
- refreshAttachmentHint();
1454
+ refreshHint();
1409
1455
  }
1410
1456
  catch (error) {
1411
1457
  // The clipboard looked like an image path but we could not load
@@ -1445,7 +1491,7 @@ export async function runTui(input) {
1445
1491
  pendingImages.push(pending);
1446
1492
  inputField.insertText(pending.label);
1447
1493
  appendBlock("[paste]", `attached ${pending.label} (${mimeType}, ${formatBytes(bytes.length)})`, COLORS.system);
1448
- refreshAttachmentHint();
1494
+ refreshHint();
1449
1495
  }
1450
1496
  catch (error) {
1451
1497
  appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
@@ -1456,7 +1502,7 @@ export async function runTui(input) {
1456
1502
  return;
1457
1503
  pendingImages = [];
1458
1504
  nextImageId = 1;
1459
- refreshAttachmentHint();
1505
+ refreshHint();
1460
1506
  }
1461
1507
  // Manual clipboard probe. Read the OS clipboard for an image right now and
1462
1508
  // attach it if found; otherwise emit a useful diagnostic line. Used both by
@@ -1541,27 +1587,63 @@ export async function runTui(input) {
1541
1587
  markRunning();
1542
1588
  }
1543
1589
  }
1590
+ /**
1591
+ * Copy the active drag-selection to the clipboard and clear the highlight
1592
+ * so the user gets visual confirmation the action happened. Used by the
1593
+ * platform copy keystroke (Cmd+C on macOS, Ctrl+Shift+C elsewhere); the
1594
+ * slash command path goes through `handleCopySlashCommand` so it can
1595
+ * also serve `/copy last|all|<N>`.
1596
+ */
1597
+ async function copyActiveSelection() {
1598
+ const text = lastSelectionText;
1599
+ if (text.trim().length === 0)
1600
+ return;
1601
+ const result = await copyTextToClipboard(text);
1602
+ renderer.clearSelection();
1603
+ lastSelectionText = "";
1604
+ refreshHint();
1605
+ if (result.ok) {
1606
+ appendBlock("[copy]", `copied selection (${text.length} char${text.length === 1 ? "" : "s"}) to clipboard via ${result.via}`, COLORS.system);
1607
+ }
1608
+ else {
1609
+ appendBlock("[copy]", `clipboard write failed: ${result.error ?? "unknown error"}` +
1610
+ (process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
1611
+ }
1612
+ }
1544
1613
  /**
1545
1614
  * Resolve a `/copy ...` invocation to clipboard text and pipe it to the
1546
- * OS clipboard. Surfaces every failure mode in the transcript so users on
1547
- * minimal Linux installs see exactly which writer is missing (e.g.
1548
- * "install xclip or wl-clipboard").
1615
+ * OS clipboard. When the user has an active drag-selection and ran a bare
1616
+ * `/copy`, copy that highlight verbatim it matches what they actually
1617
+ * have on screen. Otherwise fall back to the transcript-log heuristic
1618
+ * (`last` / `all` / `<N>`).
1619
+ *
1620
+ * Failures are surfaced in the transcript so users on minimal Linux
1621
+ * installs see exactly which writer is missing.
1549
1622
  */
1550
1623
  async function handleCopySlashCommand(raw) {
1551
1624
  const argumentRaw = raw === "/copy" ? "" : raw.slice("/copy ".length);
1552
1625
  const argument = parseCopyArgument(argumentRaw);
1553
1626
  if (argument === undefined) {
1554
- appendBlock("[copy]", "Usage: /copy [last|all|<N>] — last (default) copies the most recent agent reply", COLORS.system);
1627
+ appendBlock("[copy]", "Usage: /copy [last|all|<N>] — last (default) copies the most recent agent reply, " +
1628
+ "or copies the active drag-selection when one is present", COLORS.system);
1555
1629
  return;
1556
1630
  }
1557
- const text = selectCopyText(transcriptLog, argument);
1631
+ // A bare `/copy` (or the copy keystroke while a selection is active)
1632
+ // prefers the drag-selection so the clipboard matches what the user
1633
+ // has highlighted on screen; an explicit `/copy last|all|<N>` always
1634
+ // uses the transcript log instead.
1635
+ const explicitArgument = argumentRaw.trim().length > 0;
1636
+ const useSelection = !explicitArgument && lastSelectionText.trim().length > 0;
1637
+ const text = useSelection ? lastSelectionText : selectCopyText(transcriptLog, argument);
1558
1638
  if (!text) {
1559
1639
  appendBlock("[copy]", "nothing to copy yet", COLORS.system);
1560
1640
  return;
1561
1641
  }
1562
- const result = await writeClipboardText(text);
1642
+ const result = await copyTextToClipboard(text);
1563
1643
  if (result.ok) {
1564
- const summary = describeCopySelection(argument, text.length);
1644
+ const summary = useSelection
1645
+ ? `selection (${text.length} char${text.length === 1 ? "" : "s"})`
1646
+ : describeCopySelection(argument, text.length);
1565
1647
  appendBlock("[copy]", `copied ${summary} to clipboard via ${result.via}`, COLORS.system);
1566
1648
  }
1567
1649
  else {
@@ -1569,6 +1651,19 @@ export async function runTui(input) {
1569
1651
  (process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
1570
1652
  }
1571
1653
  }
1654
+ /**
1655
+ * Two-stage clipboard write. OpenTUI's OSC 52 path is preferred because it
1656
+ * lands in the user's clipboard even when the TUI is running over SSH or
1657
+ * inside tmux — no host CLI required. When the terminal does not
1658
+ * advertise OSC 52 support (or stdout is gone), fall back to the
1659
+ * platform-native CLI writers in `clipboard.ts`.
1660
+ */
1661
+ async function copyTextToClipboard(text) {
1662
+ if (renderer.isOsc52Supported() && renderer.copyToClipboardOSC52(text)) {
1663
+ return { ok: true, via: "OSC 52" };
1664
+ }
1665
+ return writeClipboardText(text);
1666
+ }
1572
1667
  function describeCopySelection(argument, length) {
1573
1668
  const chars = `${length} char${length === 1 ? "" : "s"}`;
1574
1669
  if (argument === "last")
@@ -1595,7 +1690,7 @@ export async function runTui(input) {
1595
1690
  // can keep typing their prompt with the image already attached.
1596
1691
  inputField.insertText(pending.label);
1597
1692
  appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
1598
- refreshAttachmentHint();
1693
+ refreshHint();
1599
1694
  }
1600
1695
  catch (error) {
1601
1696
  appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);