@aion0/forge 0.10.51 → 0.10.55

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.
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import MarkdownContent from './MarkdownContent';
5
+
6
+ const TEXT_LIKE = new Set(['md', 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'html']);
7
+ const IMAGE_LIKE = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg']);
8
+ const EMBED_LIKE = new Set(['pdf']);
9
+
10
+ /** Cap inline text rendering. Anything larger falls back to a download
11
+ * prompt — react-markdown on multi-MB strings locks the tab. */
12
+ const MAX_INLINE_BYTES = 2 * 1024 * 1024;
13
+
14
+ function extOf(p: string): string {
15
+ const m = p.match(/\.([^./]+)$/);
16
+ return m ? m[1].toLowerCase() : '';
17
+ }
18
+
19
+ export default function ScratchViewer({
20
+ path,
21
+ apiBase = '/api/scratch',
22
+ topLabel = 'scratch',
23
+ }: {
24
+ path: string;
25
+ /** API route prefix. /api/scratch resolves under <dataDir>/scratch/;
26
+ * /api/files resolves dataDir-wide. Default scratch for back-compat. */
27
+ apiBase?: string;
28
+ /** Tiny header tag shown next to the file path (e.g. "scratch", "file"). */
29
+ topLabel?: string;
30
+ }) {
31
+ const decoded = (() => {
32
+ try {
33
+ return decodeURIComponent(path);
34
+ } catch {
35
+ return path;
36
+ }
37
+ })();
38
+ const ext = extOf(decoded);
39
+ const rawUrl = `${apiBase}/${path}`;
40
+ const downloadUrl = `${rawUrl}?download=1`;
41
+
42
+ const [text, setText] = useState<string | null>(null);
43
+ const [err, setErr] = useState<string>('');
44
+ const [tooLarge, setTooLarge] = useState(false);
45
+
46
+ useEffect(() => {
47
+ if (!TEXT_LIKE.has(ext)) return;
48
+ let cancelled = false;
49
+ (async () => {
50
+ try {
51
+ const r = await fetch(rawUrl);
52
+ if (!r.ok) {
53
+ if (!cancelled) setErr(`${r.status} ${r.statusText}`);
54
+ return;
55
+ }
56
+ const len = Number(r.headers.get('content-length') || '0');
57
+ if (len > MAX_INLINE_BYTES) {
58
+ if (!cancelled) setTooLarge(true);
59
+ return;
60
+ }
61
+ const body = await r.text();
62
+ if (cancelled) return;
63
+ if (body.length > MAX_INLINE_BYTES) {
64
+ setTooLarge(true);
65
+ return;
66
+ }
67
+ setText(body);
68
+ } catch (e) {
69
+ if (!cancelled) setErr((e as Error).message);
70
+ }
71
+ })();
72
+ return () => {
73
+ cancelled = true;
74
+ };
75
+ }, [rawUrl, ext]);
76
+
77
+ return (
78
+ <div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
79
+ <header className="sticky top-0 z-10 flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]">
80
+ <div className="flex items-center gap-2 min-w-0">
81
+ <span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">{topLabel}</span>
82
+ <span className="text-xs font-mono truncate" title={decoded}>
83
+ {decoded}
84
+ </span>
85
+ </div>
86
+ <div className="flex items-center gap-2 flex-shrink-0">
87
+ <a
88
+ href={rawUrl}
89
+ className="text-xs px-2 py-1 rounded border border-[var(--border)] hover:bg-[var(--bg-tertiary)]"
90
+ target="_blank"
91
+ rel="noopener"
92
+ >
93
+ Open raw
94
+ </a>
95
+ <a
96
+ href={downloadUrl}
97
+ className="text-xs px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
98
+ >
99
+ Download
100
+ </a>
101
+ </div>
102
+ </header>
103
+
104
+ <main className="px-4 py-3 max-w-4xl mx-auto">
105
+ {err && (
106
+ <div className="text-xs text-red-400 border border-red-400/40 rounded p-2 bg-red-400/5">
107
+ Failed to load: {err}
108
+ </div>
109
+ )}
110
+
111
+ {tooLarge && (
112
+ <div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
113
+ File is larger than {Math.round(MAX_INLINE_BYTES / 1024 / 1024)} MB — inline preview skipped. Use Download or Open raw.
114
+ </div>
115
+ )}
116
+
117
+ {!err && !tooLarge && IMAGE_LIKE.has(ext) && (
118
+ <img src={rawUrl} alt={decoded} className="max-w-full rounded border border-[var(--border)]" />
119
+ )}
120
+
121
+ {!err && !tooLarge && EMBED_LIKE.has(ext) && (
122
+ <embed src={rawUrl} type="application/pdf" className="w-full h-[calc(100vh-60px)]" />
123
+ )}
124
+
125
+ {!err && !tooLarge && TEXT_LIKE.has(ext) && text != null && (
126
+ ext === 'md' ? (
127
+ <MarkdownContent content={text} />
128
+ ) : ext === 'html' ? (
129
+ // Wrap in iframe srcdoc so any embedded scripts can't reach Forge's
130
+ // origin or steal session cookies. sandbox blocks everything.
131
+ <iframe
132
+ srcDoc={text}
133
+ sandbox=""
134
+ className="w-full h-[calc(100vh-60px)] bg-white rounded border border-[var(--border)]"
135
+ title={decoded}
136
+ />
137
+ ) : (
138
+ <pre className="text-[12px] font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-3 overflow-auto whitespace-pre-wrap break-words" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>
139
+ {text}
140
+ </pre>
141
+ )
142
+ )}
143
+
144
+ {!err && !tooLarge && !TEXT_LIKE.has(ext) && !IMAGE_LIKE.has(ext) && !EMBED_LIKE.has(ext) && (
145
+ <div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
146
+ No inline preview for <code className="font-mono">.{ext || '?'}</code> files. Use Download.
147
+ </div>
148
+ )}
149
+ </main>
150
+ </div>
151
+ );
152
+ }
@@ -28,6 +28,7 @@ import { buildMemoryContext } from './build-memory-context';
28
28
  import { buildReferencePromptSection } from './reference-prompt';
29
29
  import { buildMemoryTools } from './memory-tools';
30
30
  import { buildStartWatchTool } from '../watch/start-watch-tool';
31
+ import { beginTurn, endTurn, isAborted, consumeNotes } from './turn-control';
31
32
  import { estimateTokens } from '../memory/token-estimate';
32
33
  import {
33
34
  listInstalledConnectors,
@@ -419,9 +420,16 @@ function buildSystemPrompt(
419
420
  }
420
421
  }
421
422
  if (builtinDefs.length > 0) {
423
+ // Builtins are always-active (no connector_open gate) — list their
424
+ // FULL description so per-tool rules like "save_tmp_file: output
425
+ // user_message verbatim" / "dispatch_task: ASK before classifying
426
+ // a save as a task" reach the LLM here, not just buried in the
427
+ // tools schema. The connector catalog above can be terse because
428
+ // those tools only become live after connector_open; builtins
429
+ // never go through that gate, so terseness here loses real guidance.
422
430
  lines.push('', 'Builtin tools (always available):');
423
431
  for (const t of builtinDefs) {
424
- lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
432
+ lines.push(`- ${t.name}: ${t.description}`);
425
433
  }
426
434
  }
427
435
 
@@ -754,10 +762,52 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
754
762
  let lastStop = '';
755
763
  let assistantBlocksAccum: ContentBlock[] = [];
756
764
 
765
+ // Mark this turn live so the user can abort it / inject notes mid-flight
766
+ // (see turn-control.ts). Cleared in the finally below.
767
+ beginTurn(args.sessionId);
768
+
757
769
  try {
758
770
  while (iter < MAX_ITERATIONS) {
759
771
  iter += 1;
760
772
 
773
+ // ── User intervention (turn-control) ────────────────────────
774
+ // Abort: stop the loop cleanly at this boundary with a sentinel so
775
+ // the turn doesn't just vanish. (The current in-flight LLM call /
776
+ // tool batch from the previous iteration has already settled here.)
777
+ if (isAborted(args.sessionId)) {
778
+ const stopMsg = appendMessage({
779
+ session_id: args.sessionId,
780
+ role: 'assistant',
781
+ blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
782
+ });
783
+ cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
784
+ lastStop = 'aborted';
785
+ break;
786
+ }
787
+ // Supplementary notes: splice any queued user notes in as a user
788
+ // message so THIS iteration's LLM call sees them. Safe after a
789
+ // tool_result message — the adapter emits that as a `tool` message,
790
+ // so a following `user` message doesn't collide on role.
791
+ // Drain notes — each becomes its own user message so the thread
792
+ // shows them in arrival order (matches the optimistic messages the
793
+ // client already rendered when the user hit Send). The first note
794
+ // carries a flag for the model so it knows this is a mid-task
795
+ // redirect, not ambient chat; subsequent notes go raw to avoid
796
+ // cluttering the visible thread.
797
+ const notes = consumeNotes(args.sessionId);
798
+ if (notes.length > 0) {
799
+ const FLAG = '[mid-task interjection — sent WHILE you were running tools. Treat as an authoritative redirect that overrides any plan you announced earlier (count, target, scope). Adjust on the very next step.]';
800
+ for (let i = 0; i < notes.length; i++) {
801
+ const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
802
+ const noteMsg = appendMessage({
803
+ session_id: args.sessionId,
804
+ role: 'user',
805
+ blocks: [{ type: 'text', text } as TextBlock],
806
+ });
807
+ cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
808
+ }
809
+ }
810
+
761
811
  // ── Recompute open set every iteration ──────────────────────
762
812
  // Scan history (since last user text msg) + this turn's accumulated
763
813
  // blocks → which connectors are open right now. Then filter tools.
@@ -885,10 +935,28 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
885
935
 
886
936
  if (result.stopReason !== 'tool_use') break;
887
937
 
888
- // Execute tool calls
938
+ // Execute tool calls. The LLM can emit several tool_use blocks per
939
+ // iteration (parallel batch). Without an in-batch abort check, a
940
+ // user who clicks Stop after the batch starts has to wait for ALL
941
+ // tools to finish before the loop top-check fires next iter — feels
942
+ // like Stop did nothing. So: between tools, if abort was requested,
943
+ // skip the remaining ones with synthetic "aborted" tool_results
944
+ // (the tool_use/tool_result pairing invariant must hold for the
945
+ // Anthropic API; an orphan tool_use rejects the next call).
889
946
  const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
890
947
  const toolResults: ToolResultBlock[] = [];
891
948
  for (const t of toolUses) {
949
+ if (isAborted(args.sessionId)) {
950
+ const block: ToolResultBlock = {
951
+ type: 'tool_result',
952
+ tool_use_id: t.id,
953
+ content: '⏹ Skipped — user requested stop.',
954
+ is_error: true,
955
+ };
956
+ toolResults.push(block);
957
+ cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
958
+ continue;
959
+ }
892
960
  const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
893
961
  const block: ToolResultBlock = {
894
962
  type: 'tool_result',
@@ -960,5 +1028,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
960
1028
  });
961
1029
  cb({ type: 'error', data: { error: msg } });
962
1030
  return { ok: false, error: msg };
1031
+ } finally {
1032
+ endTurn(args.sessionId);
963
1033
  }
964
1034
  }
@@ -81,17 +81,40 @@ export const LINK_PATTERNS: LinkPattern[] = [
81
81
  url: 'https://nvd.nist.gov/vuln/detail/{1}',
82
82
  label: '{1}',
83
83
  },
84
- // Forge scratch-dir files. LLMs frequently emit paths like
85
- // `scratch/foo.md` when they write reports during chat-launched tasks.
86
- // Turn them into clickable links served by /api/scratch/<path>.
87
- // Match path segments + filename with extension; bound to a known set
88
- // of extensions to avoid linkifying noise like `scratch/notes`.
84
+ // Forge-managed files inside <dataDir>/. LLMs emit:
85
+ // `scratch/foo.md` legacy task-workspace writes (kept under
86
+ // <dataDir>/scratch/, served by /scratch viewer)
87
+ // `tmp/foo.md` — chat's save_tmp_file output (<dataDir>/tmp/)
88
+ // `flows/x.yaml` — pipeline workflow definitions
89
+ // • `prompts/y.yaml` — schedule prompt bodies
90
+ // All four resolve through the /files/<dataDir-path> viewer (rendered
91
+ // markdown + download button), which fetches /api/files for raw bytes.
92
+ // Sensitive top-level items (encrypt key, sqlite DBs, log, token
93
+ // caches) are blocked at the API layer, so safe to be liberal here.
89
94
  {
90
95
  id: 'scratch-file',
91
96
  regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
92
- url: '/api/scratch/{1}',
97
+ url: '/scratch/{1}',
93
98
  label: 'scratch/{1}',
94
99
  },
100
+ {
101
+ id: 'tmp-file',
102
+ regex: /\btmp\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
103
+ url: '/files/tmp/{1}',
104
+ label: 'tmp/{1}',
105
+ },
106
+ {
107
+ id: 'flows-file',
108
+ regex: /\bflows\/([\w\-./]+?\.(?:yaml|yml|json))\b/gi,
109
+ url: '/files/flows/{1}',
110
+ label: 'flows/{1}',
111
+ },
112
+ {
113
+ id: 'prompts-file',
114
+ regex: /\bprompts\/([\w\-./]+?\.(?:yaml|yml|md|txt))\b/gi,
115
+ url: '/files/prompts/{1}',
116
+ label: 'prompts/{1}',
117
+ },
95
118
  ];
96
119
 
97
120
  export interface CompiledPattern {