wotann 0.5.86 → 0.5.87

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.
@@ -36,8 +36,48 @@ export interface ToolResultEvent {
36
36
  readonly toolName: string;
37
37
  readonly result: string;
38
38
  }
39
- export type RunAgentControlEvent = IterationStartEvent | ToolResultEvent;
39
+ /**
40
+ * Emitted at an iteration boundary after the loop drained one or more
41
+ * user-side redirects (Hermes Gap 2 — interrupt-and-redirect). UI
42
+ * consumers can render a system bubble showing the redirected text so
43
+ * the user has visual confirmation the directive landed. The redirect
44
+ * is ALREADY appended to context by the loop before this event fires;
45
+ * receivers MUST NOT re-append it themselves.
46
+ */
47
+ export interface RedirectReceivedEvent {
48
+ readonly kind: "redirect_received";
49
+ readonly count: number;
50
+ readonly messages: readonly string[];
51
+ }
52
+ export type RunAgentControlEvent = IterationStartEvent | ToolResultEvent | RedirectReceivedEvent;
40
53
  export type AgentEvent = StreamChunk | RunAgentControlEvent;
54
+ /**
55
+ * Channel for user-side redirects (Hermes Gap 2). The TUI / RPC client
56
+ * pushes messages when the user sends a new directive WHILE the agent
57
+ * is streaming. The runAgent loop drains at each iteration boundary
58
+ * and appends them to context as new user turns, so the model absorbs
59
+ * the directive on the next turn — no context loss, no abort needed.
60
+ *
61
+ * Implementations MUST be safe to call concurrently (push from the
62
+ * UI thread, drain from the loop's async generator).
63
+ */
64
+ export interface RedirectChannel {
65
+ /** Drain all pending redirects. Returns empty array when none queued. */
66
+ drain(): readonly string[];
67
+ }
68
+ /**
69
+ * Default in-memory channel. Pushes are appended to a queue; drain
70
+ * empties the queue atomically. The queue ignores empty-string pushes
71
+ * so an accidental Ctrl+R on an empty composer doesn't inject a blank
72
+ * user turn into the model context.
73
+ */
74
+ export declare class InMemoryRedirectChannel implements RedirectChannel {
75
+ private queue;
76
+ push(message: string): void;
77
+ drain(): readonly string[];
78
+ /** Snapshot of pending count — for status-bar indicators. Read-only. */
79
+ get pending(): number;
80
+ }
41
81
  export interface RunAgentOptions {
42
82
  readonly prompt: string;
43
83
  readonly images?: readonly string[];
@@ -49,6 +89,14 @@ export interface RunAgentOptions {
49
89
  readonly signal?: AbortSignal;
50
90
  readonly guardrails?: readonly GuardrailCheck[];
51
91
  readonly guardrailContext?: GuardrailContext;
92
+ /**
93
+ * Optional user-redirect channel. When provided, the loop drains
94
+ * pending messages BEFORE each iteration's query and appends them
95
+ * to context as new user turns. Lets the user redirect a streaming
96
+ * agent without losing context (Hermes Gap 2). See
97
+ * {@link InMemoryRedirectChannel} for the default implementation.
98
+ */
99
+ readonly redirectChannel?: RedirectChannel;
52
100
  readonly query: (o: WotannQueryOptions) => AsyncGenerator<StreamChunk>;
53
101
  readonly executeTool: (name: string, input: Record<string, unknown>) => Promise<string>;
54
102
  }
@@ -1,5 +1,31 @@
1
1
  import { evaluateGuardrails, } from "../guardrails/tripwire.js";
2
- import { IntelligenceAmplifier, updateDoomLoopState } from "../intelligence/amplifier.js";
2
+ import { IntelligenceAmplifier, updateDoomLoopState, } from "../intelligence/amplifier.js";
3
+ /**
4
+ * Default in-memory channel. Pushes are appended to a queue; drain
5
+ * empties the queue atomically. The queue ignores empty-string pushes
6
+ * so an accidental Ctrl+R on an empty composer doesn't inject a blank
7
+ * user turn into the model context.
8
+ */
9
+ export class InMemoryRedirectChannel {
10
+ queue = [];
11
+ push(message) {
12
+ const trimmed = message.trim();
13
+ if (trimmed.length === 0)
14
+ return;
15
+ this.queue.push(trimmed);
16
+ }
17
+ drain() {
18
+ if (this.queue.length === 0)
19
+ return [];
20
+ const out = this.queue;
21
+ this.queue = [];
22
+ return out;
23
+ }
24
+ /** Snapshot of pending count — for status-bar indicators. Read-only. */
25
+ get pending() {
26
+ return this.queue.length;
27
+ }
28
+ }
3
29
  export async function* runAgent(opts) {
4
30
  const maxIterations = opts.maxIterations ?? 8;
5
31
  const guardrails = opts.guardrails ?? [];
@@ -20,6 +46,22 @@ export async function* runAgent(opts) {
20
46
  while (iteration < maxIterations) {
21
47
  iteration++;
22
48
  yield { kind: "iteration_start", iteration };
49
+ // Hermes Gap 2 — drain user-side redirects BEFORE this iteration's
50
+ // query. Each pending message becomes a new user turn appended to
51
+ // context, so the model sees the directive on its next turn. No
52
+ // abort, no context loss. We emit a `redirect_received` control
53
+ // event so UI consumers can render a system breadcrumb.
54
+ if (opts.redirectChannel !== undefined) {
55
+ const pending = opts.redirectChannel.drain();
56
+ if (pending.length > 0) {
57
+ const next = [...context];
58
+ for (const msg of pending) {
59
+ next.push({ role: "user", content: msg });
60
+ }
61
+ context = next;
62
+ yield { kind: "redirect_received", count: pending.length, messages: pending };
63
+ }
64
+ }
23
65
  let fullContent = "";
24
66
  let model;
25
67
  let provider;
@@ -41,7 +41,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "#wotann-jsx/j
41
41
  */
42
42
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
43
43
  import { Box, Text, useInput } from "ink";
44
- import { runAgent } from "../../../core/runtime-agent-loop.js";
44
+ import { InMemoryRedirectChannel, runAgent } from "../../../core/runtime-agent-loop.js";
45
45
  import { buildAgentToolContext } from "../../../core/agent-tool-context.js";
46
46
  import { AGENT_TOOL_DEFINITIONS, executeAgentTool } from "../../../tools/agent-tools.js";
47
47
  import { ThemeProvider, useThemeTone } from "../../theme/context.js";
@@ -217,6 +217,13 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
217
217
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
218
218
  const activeRunRef = useRef(null);
219
219
  const sideActiveRunRef = useRef(null);
220
+ // Hermes Gap 2 — interrupt-and-redirect: persistent channel for the
221
+ // host composer's mid-stream redirects. Ctrl+R captures the current
222
+ // draft and pushes it here; runAgent's iteration loop drains pending
223
+ // redirects at the next iteration boundary and appends them to
224
+ // context as new user turns. One channel per AppV3Inner instance so
225
+ // pending redirects survive within a session.
226
+ const redirectChannelRef = useRef(new InMemoryRedirectChannel());
220
227
  const [sidePaneDraft, setSidePaneDraft] = useState("");
221
228
  const [nowMs, setNowMs] = useState(() => Date.now());
222
229
  useEffect(() => {
@@ -241,6 +248,16 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
241
248
  // composer is empty so we don't intercept the dot from a typed
242
249
  // slash-command argument or filename.
243
250
  useInput((input, key) => {
251
+ // Hermes Gap 2 — Ctrl+R: queue the composer draft as a mid-stream
252
+ // redirect. ONLY fires when the agent is currently streaming AND
253
+ // the draft has non-whitespace content. The channel's drain runs
254
+ // at the next iteration boundary, so the user sees a system
255
+ // "redirect queued" bubble within ~1 turn.
256
+ if (key.ctrl && input === "r" && isStreaming && draftValue.trim().length > 0) {
257
+ redirectChannelRef.current.push(draftValue);
258
+ setDraftValue("");
259
+ return;
260
+ }
244
261
  if (key.ctrl && (input === "k" || input === "p")) {
245
262
  overlay.open("palette");
246
263
  return;
@@ -342,6 +359,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
342
359
  provider: initialProvider || undefined,
343
360
  tools: AGENT_TOOL_DEFINITIONS,
344
361
  signal: abortController.signal,
362
+ redirectChannel: redirectChannelRef.current,
345
363
  query: (o) => runtime.query(o),
346
364
  executeTool: (name, input) => executeAgentTool(name, input, buildAgentToolContext(runtime, {
347
365
  workingDir: runtime.getWorkingDir(),
@@ -362,6 +380,20 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
362
380
  },
363
381
  ]);
364
382
  }
383
+ else if (ev.kind === "redirect_received") {
384
+ // Hermes Gap 2 — UI breadcrumb so user sees the directive
385
+ // landed. The loop already appended it to context; we
386
+ // ONLY render here (do NOT push to context again).
387
+ for (const message of ev.messages) {
388
+ setMessages((prev) => [
389
+ ...prev,
390
+ {
391
+ role: "system",
392
+ content: `↳ redirect queued: ${message}`,
393
+ },
394
+ ]);
395
+ }
396
+ }
365
397
  continue;
366
398
  }
367
399
  if (ev.type === "text" && ev.content.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wotann",
3
- "version": "0.5.86",
3
+ "version": "0.5.87",
4
4
  "description": "WOTANN — The All-Father of AI Agent Harnesses",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",