shraga 0.1.69 → 0.1.71
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/client/assets/{index-6SI-XJ2-.js → index-BHcdo8Ga.js} +195 -195
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/components/MachineStats.tsx +20 -4
- package/src/server/claude-usage.ts +55 -2
- package/src/server/para/feature.ts +17 -6
- package/src/server/para/streamer.ts +151 -11
package/dist/client/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-BHcdo8Ga.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-uvYJNXVj.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -16,7 +16,10 @@ export interface UsageLimit {
|
|
|
16
16
|
export interface Usage { subscriptionType: string | null; limits: UsageLimit[] }
|
|
17
17
|
|
|
18
18
|
const WINDOW = 120;
|
|
19
|
-
|
|
19
|
+
// The upstream usage endpoint rate-limits hard and the numbers move slowly, so poll lazily: every
|
|
20
|
+
// 5 min, and ONLY while this tab is visible. A wall of forgotten tabs was what kept the server in a
|
|
21
|
+
// permanent 429 penalty box.
|
|
22
|
+
const USAGE_POLL_MS = 300_000;
|
|
20
23
|
|
|
21
24
|
interface Props {
|
|
22
25
|
socket: AgentSocket | null;
|
|
@@ -69,10 +72,12 @@ function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null
|
|
|
69
72
|
|
|
70
73
|
useEffect(() => {
|
|
71
74
|
let alive = true;
|
|
75
|
+
let last = 0;
|
|
72
76
|
const poll = async () => {
|
|
73
77
|
try {
|
|
74
78
|
const t = await getToken();
|
|
75
79
|
if (!t) return;
|
|
80
|
+
last = Date.now();
|
|
76
81
|
const r = await fetch('/api/claude-usage', { headers: { Authorization: `Bearer ${t}` } });
|
|
77
82
|
if (!alive) return;
|
|
78
83
|
// 204 = not a subscription deployment. Anything non-OK = fail closed, same outcome.
|
|
@@ -85,9 +90,20 @@ function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null
|
|
|
85
90
|
if (alive) setUsage(null);
|
|
86
91
|
}
|
|
87
92
|
};
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
93
|
+
// A hidden tab is nobody watching: skip the tick entirely rather than paying an upstream call.
|
|
94
|
+
const tick = () => { if (document.visibilityState === 'visible') void poll(); };
|
|
95
|
+
// Coming back to the tab shows a stale gauge otherwise — refresh only if the interval was missed.
|
|
96
|
+
const onVisible = () => {
|
|
97
|
+
if (document.visibilityState === 'visible' && Date.now() - last >= USAGE_POLL_MS) void poll();
|
|
98
|
+
};
|
|
99
|
+
tick();
|
|
100
|
+
const id = setInterval(tick, USAGE_POLL_MS);
|
|
101
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
102
|
+
return () => {
|
|
103
|
+
alive = false;
|
|
104
|
+
clearInterval(id);
|
|
105
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
106
|
+
};
|
|
91
107
|
}, [getToken]);
|
|
92
108
|
|
|
93
109
|
return <UsageMetric usage={usage} />;
|
|
@@ -41,8 +41,12 @@ export class ClaudeUsageOptions {
|
|
|
41
41
|
* deduped separately onto one in-flight request, so a reload storm or a wall of open tabs costs at
|
|
42
42
|
* most one upstream call per window — this endpoint answers 429 aggressively. Failures are cached
|
|
43
43
|
* on the same terms, so a 403/API-key box does not retry on every client poll. */
|
|
44
|
-
ttlMs =
|
|
44
|
+
ttlMs = 300_000;
|
|
45
45
|
timeoutMs = 8_000;
|
|
46
|
+
/** A 429 is not a transient blip here: retrying it on the ordinary TTL is what keeps a box wedged in
|
|
47
|
+
* the penalty box all day (25 straight 429s on prod). Each consecutive 429 climbs this ladder and a
|
|
48
|
+
* success drops back to the first rung; a `Retry-After` header wins over the rung when it is longer. */
|
|
49
|
+
rateLimitBackoffMs = [60_000, 300_000, 900_000, 1_800_000];
|
|
46
50
|
/** macOS stores Claude Code's OAuth credentials in the login Keychain and writes NO credentials
|
|
47
51
|
* file, so on darwin an absent file is not proof of an API-key deployment — we look there second.
|
|
48
52
|
* Linux keeps the file as the only source; we never shell out there. */
|
|
@@ -79,6 +83,9 @@ export class ClaudeUsageReader {
|
|
|
79
83
|
* Without this the TTL is useless against a stampede: the cache is only written once the request
|
|
80
84
|
* RESOLVES, so N simultaneous callers all miss and all hit upstream. */
|
|
81
85
|
private inflight: Promise<ClaudeUsage | null> | null = null;
|
|
86
|
+
/** While set in the future, `get()` answers null WITHOUT touching upstream — see rateLimitBackoffMs. */
|
|
87
|
+
private cooldownUntil = 0;
|
|
88
|
+
private rateLimitStreak = 0;
|
|
82
89
|
|
|
83
90
|
public constructor(options?: Partial<ClaudeUsageOptions>) {
|
|
84
91
|
this.options = { ...new ClaudeUsageOptions(), ...options };
|
|
@@ -87,6 +94,7 @@ export class ClaudeUsageReader {
|
|
|
87
94
|
/** null => this box is not on a Claude subscription, or we could not prove that it is. */
|
|
88
95
|
async get(): Promise<ClaudeUsage | null> {
|
|
89
96
|
const now = Date.now();
|
|
97
|
+
if (now < this.cooldownUntil) return null;
|
|
90
98
|
if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value;
|
|
91
99
|
if (this.inflight) return this.inflight;
|
|
92
100
|
// Clear inflight before the value is handed out, so a rejection can never wedge the reader:
|
|
@@ -113,8 +121,15 @@ export class ClaudeUsageReader {
|
|
|
113
121
|
},
|
|
114
122
|
signal: AbortSignal.timeout(this.options.timeoutMs),
|
|
115
123
|
});
|
|
124
|
+
if (res.status === 429) {
|
|
125
|
+
// The reason lives in the BODY, not the status: "too many requests" (our own poll rate) and
|
|
126
|
+
// an account/org-level throttle read identically from the outside, and only the first one is
|
|
127
|
+
// ours to fix. Backing off blind once cost a day of a hidden gauge, so surface it.
|
|
128
|
+
this.enterCooldown(res.headers.get('retry-after'), await describeError(res));
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
116
131
|
if (!res.ok) {
|
|
117
|
-
console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget`);
|
|
132
|
+
console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget — ${await describeError(res)}`);
|
|
118
133
|
return null;
|
|
119
134
|
}
|
|
120
135
|
const body: any = await res.json();
|
|
@@ -123,6 +138,7 @@ export class ClaudeUsageReader {
|
|
|
123
138
|
console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
|
|
124
139
|
return null;
|
|
125
140
|
}
|
|
141
|
+
this.rateLimitStreak = 0;
|
|
126
142
|
return { subscriptionType: creds.subscriptionType, limits };
|
|
127
143
|
} catch (err) {
|
|
128
144
|
console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
|
|
@@ -130,6 +146,21 @@ export class ClaudeUsageReader {
|
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
148
|
|
|
149
|
+
/** Climb the backoff ladder one rung per consecutive 429, capped at the last rung. `Retry-After`
|
|
150
|
+
* (seconds, or an HTTP-date) only ever EXTENDS the wait — never shortens the rung we earned. */
|
|
151
|
+
private enterCooldown(retryAfter: string | null, detail: string) {
|
|
152
|
+
const ladder = this.options.rateLimitBackoffMs;
|
|
153
|
+
const rung = ladder[Math.min(this.rateLimitStreak, ladder.length - 1)] ?? 60_000;
|
|
154
|
+
this.rateLimitStreak++;
|
|
155
|
+
const hinted = parseRetryAfter(retryAfter);
|
|
156
|
+
const waitMs = Math.max(rung, hinted ?? 0);
|
|
157
|
+
this.cooldownUntil = Date.now() + waitMs;
|
|
158
|
+
console.warn(
|
|
159
|
+
`${TAG} usage endpoint returned 429 (ua=claude-code/${claudeCodeVersion()}, retry-after=${retryAfter ?? 'none'}); ` +
|
|
160
|
+
`hiding widget and backing off ${Math.round(waitMs / 1000)}s — ${detail}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
/** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
|
|
134
165
|
* refreshes it in place, so nothing here may be cached in a field. File first, Keychain second. */
|
|
135
166
|
private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
|
|
@@ -183,6 +214,28 @@ function parseCredentials(raw: string, source: string): { accessToken: string; s
|
|
|
183
214
|
return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
|
|
184
215
|
}
|
|
185
216
|
|
|
217
|
+
/** Anthropic answers errors as `{ error: { type, message } }`. Never throws and never returns more
|
|
218
|
+
* than a line: this only ever lands in a log, next to a status we already decided to fail on. */
|
|
219
|
+
async function describeError(res: Response): Promise<string> {
|
|
220
|
+
try {
|
|
221
|
+
const raw = (await res.text()).slice(0, 300);
|
|
222
|
+
const parsed = JSON.parse(raw)?.error;
|
|
223
|
+
return parsed?.message ? `${parsed.type ?? 'error'}: ${parsed.message}` : raw || '(empty body)';
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return `(unreadable body: ${(err as Error).message})`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `Retry-After` is either delta-seconds or an HTTP-date. Anything unparseable => no hint. */
|
|
230
|
+
function parseRetryAfter(raw: string | null): number | null {
|
|
231
|
+
if (!raw) return null;
|
|
232
|
+
const secs = Number(raw.trim());
|
|
233
|
+
if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
|
|
234
|
+
const at = Date.parse(raw);
|
|
235
|
+
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
186
239
|
function toLimit(l: any): ClaudeUsageLimit | null {
|
|
187
240
|
if (!l || typeof l.percent !== 'number' || !Number.isFinite(l.percent)) return null;
|
|
188
241
|
return {
|
|
@@ -86,10 +86,10 @@ function rememberLink(link: Link): void {
|
|
|
86
86
|
* be left watching a "typing…" placeholder that will never resolve. */
|
|
87
87
|
async function runParaTurn(args: {
|
|
88
88
|
callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
|
|
89
|
-
uid: string; userEmail: string;
|
|
89
|
+
uid: string; userEmail: string; sendSegments: boolean;
|
|
90
90
|
}): Promise<void> {
|
|
91
|
-
const { callback, convId, msgId, sessionId, prompt, uid, userEmail } = args;
|
|
92
|
-
const streamer = new ParaStreamer({ callback, convId, msgId });
|
|
91
|
+
const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments } = args;
|
|
92
|
+
const streamer = new ParaStreamer({ callback, convId, msgId, sendSegments });
|
|
93
93
|
const abortController = new AbortController();
|
|
94
94
|
|
|
95
95
|
// Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
|
|
@@ -119,9 +119,14 @@ async function runParaTurn(args: {
|
|
|
119
119
|
else if (ev.type === 'tool_use') {
|
|
120
120
|
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
121
121
|
blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
122
|
-
streamer.feed({ type: 'tool_use', tool: ev.tool });
|
|
122
|
+
streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
123
|
+
}
|
|
124
|
+
else if (ev.type === 'tool_result') {
|
|
125
|
+
blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
126
|
+
// The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
|
|
127
|
+
// it unless the receiver negotiated segments.
|
|
128
|
+
streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
|
|
123
129
|
}
|
|
124
|
-
else if (ev.type === 'tool_result') blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
125
130
|
else if (ev.type === 'done') break;
|
|
126
131
|
else if (ev.type === 'error') {
|
|
127
132
|
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
@@ -188,9 +193,14 @@ export const paraFeature: ServerFeature = {
|
|
|
188
193
|
const caller = bearer ? validateApiKey(bearer) : null;
|
|
189
194
|
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
190
195
|
|
|
191
|
-
const { connId, convId, sessionId, msgId, prompt, callback } = req.body as {
|
|
196
|
+
const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as {
|
|
192
197
|
connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
|
|
193
198
|
callback?: { url?: string; secret?: string };
|
|
199
|
+
/** Receiver capability negotiation. `'segments'` means "I can store and render structured
|
|
200
|
+
* tool segments" — see `ParaStreamerOptions.sendSegments`. ABSENT means no: a para-li that
|
|
201
|
+
* predates this field, and a lane (groups) that deliberately declines, both fall back to
|
|
202
|
+
* the flattened `_🔧 Tool_` markers in the text. */
|
|
203
|
+
accepts?: unknown;
|
|
194
204
|
};
|
|
195
205
|
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
196
206
|
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
@@ -210,6 +220,7 @@ export const paraFeature: ServerFeature = {
|
|
|
210
220
|
void runParaTurn({
|
|
211
221
|
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
212
222
|
uid: caller.uid, userEmail: caller.email,
|
|
223
|
+
sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
|
|
213
224
|
});
|
|
214
225
|
});
|
|
215
226
|
|
|
@@ -34,8 +34,22 @@ export interface ParaStreamerOptions {
|
|
|
34
34
|
msgId?: string;
|
|
35
35
|
flushInterval?: number;
|
|
36
36
|
flushThreshold?: number;
|
|
37
|
-
/** Show a transient inline marker per tool call, as the Slack streamer does. Default on.
|
|
37
|
+
/** Show a transient inline marker per tool call, as the Slack streamer does. Default on.
|
|
38
|
+
* Ignored when `sendSegments` is on — see `ParaStreamerOptions.sendSegments`. */
|
|
38
39
|
toolMarkers?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Emit STRUCTURED segments (`text` + `tool`) alongside the accumulated text.
|
|
42
|
+
*
|
|
43
|
+
* OFF BY DEFAULT, AND THAT IS THE POINT. This is a negotiated capability, not a preference: the
|
|
44
|
+
* receiver tells us per turn (`accepts: ['segments']` on the turn request) whether it can store
|
|
45
|
+
* and render them. A receiver that cannot gets the flattened `_🔧 Tool_` markers in the text, as
|
|
46
|
+
* it always did. So the fallback is EXPLICIT — one flag, decided by the receiver — instead of
|
|
47
|
+
* "the field is there, hopefully they ignore it".
|
|
48
|
+
*
|
|
49
|
+
* The two representations are mutually exclusive on purpose. Sending both would double-render on
|
|
50
|
+
* a receiver that shows segments AND falls back to text for a preview.
|
|
51
|
+
*/
|
|
52
|
+
sendSegments?: boolean;
|
|
39
53
|
/** Per-POST wall clock. See `POST_TIMEOUT_MS`. Overridable for tests. */
|
|
40
54
|
postTimeout?: number;
|
|
41
55
|
}
|
|
@@ -60,6 +74,71 @@ export function signPara(secret: string, connId: string, deliveryId: string, ts:
|
|
|
60
74
|
return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
// ── Structured segments ──────────────────────────────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// SHAPE-COMPATIBLE WITH para-li's OWN `MessageSegment`/`ToolCallInfo` (`lib/para-relay.ts`), which
|
|
80
|
+
// its `ConversationChannel` already renders as collapsible tool pills. This is a deliberate reuse:
|
|
81
|
+
// the external-agent lane emits the SAME shape the Para's own turns do, so no second renderer, no
|
|
82
|
+
// second row field, and no second thing to keep in sync. Duplicated here rather than imported for
|
|
83
|
+
// the same reason `signPara` is — the two repos publish separately.
|
|
84
|
+
|
|
85
|
+
export interface ToolCallInfo {
|
|
86
|
+
id: string;
|
|
87
|
+
tool: string;
|
|
88
|
+
status: 'running' | 'completed' | 'error';
|
|
89
|
+
args?: Record<string, unknown>;
|
|
90
|
+
result?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type MessageSegment =
|
|
94
|
+
| { type: 'text'; content: string }
|
|
95
|
+
| { type: 'tool'; tool: ToolCallInfo };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* SIZE DISCIPLINE. A `Read` of a big file or a chatty `Bash` produces tool input/output measured in
|
|
99
|
+
* hundreds of KB, and every flush re-sends the WHOLE accumulated state (see ACCUMULATE, DON'T
|
|
100
|
+
* APPEND at the top). Unclamped, one such call would be re-uploaded on every subsequent delta for
|
|
101
|
+
* the rest of the turn and then parked in a database row forever.
|
|
102
|
+
*
|
|
103
|
+
* The caps are chosen against what the pill actually shows: para-li's `ToolPill` renders args as
|
|
104
|
+
* one JSON line and slices the result at 500 chars, so anything past a few KB is invisible detail
|
|
105
|
+
* that still costs bandwidth and storage. Truncation is MARKED with the true length — a silently
|
|
106
|
+
* shortened `Bash` output is a lie the reader cannot detect.
|
|
107
|
+
*
|
|
108
|
+
* These are the SENDER's caps. para-li re-clamps on ingest (`parseSegments`) because a cap that
|
|
109
|
+
* only exists on the sender is not a cap.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_TOOL_ARGS_CHARS = 2_000;
|
|
112
|
+
export const MAX_TOOL_RESULT_CHARS = 4_000;
|
|
113
|
+
/** Beyond this many tool calls in one turn, later calls stop being recorded as segments (the text
|
|
114
|
+
* reply is unaffected). A 100-call turn is already unreadable as pills; the cap is what stops a
|
|
115
|
+
* runaway loop from growing the row without bound. */
|
|
116
|
+
export const MAX_TOOL_SEGMENTS = 100;
|
|
117
|
+
|
|
118
|
+
/** Truncate with an honest marker naming the TRUE length. */
|
|
119
|
+
export function clampText(s: string, cap: number): string {
|
|
120
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}… [truncated, ${s.length} chars total]`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Clamp a tool's input to `MAX_TOOL_ARGS_CHARS` across all its values, preserving the key
|
|
124
|
+
* structure (that is what makes the pill readable) and marking every truncation. */
|
|
125
|
+
export function clampArgs(input: unknown): Record<string, unknown> | undefined {
|
|
126
|
+
if (input === undefined || input === null) return undefined;
|
|
127
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
128
|
+
return { value: clampText(String(input), MAX_TOOL_ARGS_CHARS) };
|
|
129
|
+
}
|
|
130
|
+
const out: Record<string, unknown> = {};
|
|
131
|
+
let budget = MAX_TOOL_ARGS_CHARS;
|
|
132
|
+
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
|
133
|
+
if (budget <= 0) { out['…'] = 'more arguments omitted'; break; }
|
|
134
|
+
const s = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v));
|
|
135
|
+
if (s.length > budget) out[k] = clampText(s, budget);
|
|
136
|
+
else out[k] = v;
|
|
137
|
+
budget -= Math.min(s.length, budget);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
63
142
|
/**
|
|
64
143
|
* Wall clock on ONE delivery. Mirrors the 20s AbortController on para-li's own `dispatchAgentTurn`,
|
|
65
144
|
* which is the other half of this lane.
|
|
@@ -121,37 +200,90 @@ export class ParaStreamer {
|
|
|
121
200
|
private flushChain: Promise<void> = Promise.resolve();
|
|
122
201
|
private aborted = false;
|
|
123
202
|
private afterTool = false;
|
|
203
|
+
/** Structured mirror of the turn. Empty unless `sendSegments`. */
|
|
204
|
+
private segments: MessageSegment[] = [];
|
|
205
|
+
/** toolUseId → the live `ToolCallInfo` inside `segments`, so a `tool_result` can flip the status
|
|
206
|
+
* of the call it belongs to rather than of whichever ran last. */
|
|
207
|
+
private readonly toolById = new Map<string, ToolCallInfo>();
|
|
208
|
+
private toolCount = 0;
|
|
124
209
|
|
|
125
210
|
private readonly flushInterval: number;
|
|
126
211
|
private readonly flushThreshold: number;
|
|
127
212
|
private readonly toolMarkers: boolean;
|
|
213
|
+
private readonly sendSegments: boolean;
|
|
128
214
|
private readonly postTimeout: number;
|
|
129
215
|
|
|
130
216
|
constructor(private readonly opts: ParaStreamerOptions) {
|
|
131
217
|
this.flushInterval = opts.flushInterval ?? 300;
|
|
132
218
|
this.flushThreshold = opts.flushThreshold ?? 30;
|
|
133
|
-
this.
|
|
219
|
+
this.sendSegments = opts.sendSegments ?? false;
|
|
220
|
+
// The marker is the FALLBACK, so it is off exactly when segments are on. Not "both, harmless" —
|
|
221
|
+
// a receiver that renders segments and also derives a preview from the text would show the
|
|
222
|
+
// markers in the preview of a reply whose body has real pills.
|
|
223
|
+
this.toolMarkers = this.sendSegments ? false : (opts.toolMarkers ?? true);
|
|
134
224
|
this.postTimeout = opts.postTimeout ?? POST_TIMEOUT_MS;
|
|
135
225
|
}
|
|
136
226
|
|
|
137
|
-
feed(ev: { type: string; text?: string; tool?: string }): void {
|
|
227
|
+
feed(ev: { type: string; text?: string; tool?: string; toolUseId?: string; input?: unknown; output?: string; isError?: boolean }): void {
|
|
138
228
|
if (this.aborted || !this.opts.msgId) return;
|
|
139
229
|
|
|
140
230
|
if (ev.type === 'text_delta' && ev.text) {
|
|
141
231
|
if (this.afterTool) { this.fullText += '\n'; this.afterTool = false; }
|
|
142
232
|
this.buffer += ev.text;
|
|
143
233
|
this.fullText += ev.text;
|
|
234
|
+
this.appendSegmentText(ev.text);
|
|
144
235
|
if (this.buffer.length >= this.flushThreshold) this.enqueueFlush();
|
|
145
236
|
else this.scheduleTimer();
|
|
146
|
-
} else if (ev.type === 'tool_use' && ev.tool
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
237
|
+
} else if (ev.type === 'tool_use' && ev.tool) {
|
|
238
|
+
if (this.sendSegments) {
|
|
239
|
+
// A tool call is progress the reader wants IMMEDIATELY, at `running` — that is the whole
|
|
240
|
+
// point of the pill. So it flushes rather than waiting for the text threshold.
|
|
241
|
+
if (this.toolCount < MAX_TOOL_SEGMENTS) {
|
|
242
|
+
const id = ev.toolUseId || `tool_${this.toolCount}`;
|
|
243
|
+
const info: ToolCallInfo = { id, tool: ev.tool.slice(0, 200), status: 'running', ...(clampArgs(ev.input) ? { args: clampArgs(ev.input) } : {}) };
|
|
244
|
+
this.toolById.set(id, info);
|
|
245
|
+
this.segments.push({ type: 'tool', tool: info });
|
|
246
|
+
}
|
|
247
|
+
this.toolCount++;
|
|
248
|
+
this.enqueueFlush();
|
|
249
|
+
} else if (this.toolMarkers) {
|
|
250
|
+
// In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
|
|
251
|
+
// (para-li writes the whole row), so the marker disappears on its own.
|
|
252
|
+
this.afterTool = true;
|
|
253
|
+
this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
|
|
254
|
+
this.enqueueFlush();
|
|
255
|
+
}
|
|
256
|
+
} else if (ev.type === 'tool_result' && this.sendSegments && ev.toolUseId) {
|
|
257
|
+
const info = this.toolById.get(ev.toolUseId);
|
|
258
|
+
// A result for a call past MAX_TOOL_SEGMENTS (or for one we never saw) has nothing to settle;
|
|
259
|
+
// dropping it is correct — inventing a segment for it would put the pill out of order.
|
|
260
|
+
if (!info) return;
|
|
261
|
+
info.status = ev.isError ? 'error' : 'completed';
|
|
262
|
+
if (ev.output) info.result = clampText(ev.output, MAX_TOOL_RESULT_CHARS);
|
|
151
263
|
this.enqueueFlush();
|
|
152
264
|
}
|
|
153
265
|
}
|
|
154
266
|
|
|
267
|
+
/** Grow the trailing text segment, or start one. Mirrors `buildSegmentHost` in para-li's own
|
|
268
|
+
* `lib/para-agent.ts` — consecutive text stays ONE segment, so the pills sit between paragraphs
|
|
269
|
+
* instead of shredding the answer into a segment per delta. */
|
|
270
|
+
private appendSegmentText(text: string): void {
|
|
271
|
+
if (!this.sendSegments) return;
|
|
272
|
+
const last = this.segments[this.segments.length - 1];
|
|
273
|
+
if (last && last.type === 'text') last.content += text;
|
|
274
|
+
else this.segments.push({ type: 'text', content: text });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A deep copy with empty text segments dropped. Deep because `toolById` holds live references
|
|
278
|
+
* into `segments`: a flush snapshot that shared them would be MUTATED by a later `tool_result`
|
|
279
|
+
* while its POST was still in flight, so an in-order pair of deltas could show the same call as
|
|
280
|
+
* `completed` and then `running`. */
|
|
281
|
+
private segmentSnapshot(): MessageSegment[] {
|
|
282
|
+
return this.segments
|
|
283
|
+
.filter((s) => s.type === 'tool' || s.content.trim().length > 0)
|
|
284
|
+
.map((s) => (s.type === 'tool' ? { type: 'tool' as const, tool: { ...s.tool } } : { type: 'text' as const, content: s.content }));
|
|
285
|
+
}
|
|
286
|
+
|
|
155
287
|
/** Settle the row with the final text. Returns the text actually sent. */
|
|
156
288
|
async finish(): Promise<string> {
|
|
157
289
|
this.clearTimer();
|
|
@@ -159,7 +291,10 @@ export class ParaStreamer {
|
|
|
159
291
|
await this.flushChain;
|
|
160
292
|
if (this.aborted || !this.opts.msgId) return this.fullText;
|
|
161
293
|
const text = this.fullText.trim() || '(no output)';
|
|
162
|
-
await postPara(this.opts.callback, {
|
|
294
|
+
await postPara(this.opts.callback, {
|
|
295
|
+
type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text,
|
|
296
|
+
...(this.sendSegments ? { segments: this.segmentSnapshot() } : {}),
|
|
297
|
+
}, this.postTimeout);
|
|
163
298
|
return text;
|
|
164
299
|
}
|
|
165
300
|
|
|
@@ -174,13 +309,18 @@ export class ParaStreamer {
|
|
|
174
309
|
|
|
175
310
|
private enqueueFlush(): void {
|
|
176
311
|
this.clearTimer();
|
|
177
|
-
|
|
312
|
+
// `segments` matters here too: a turn that opens with a tool call has produced NO text yet, and
|
|
313
|
+
// the old guard would have swallowed that flush — so the first pill would not appear until the
|
|
314
|
+
// model started talking, and, worse, the delta that bumps para-li's stall watchdog
|
|
315
|
+
// (`lastActivityAt`) would never be sent for a long tool-only stretch.
|
|
316
|
+
if (!this.fullText && !this.segments.length) return;
|
|
178
317
|
this.buffer = '';
|
|
179
318
|
const snapshot = this.fullText;
|
|
319
|
+
const segs = this.sendSegments ? this.segmentSnapshot() : null;
|
|
180
320
|
// Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
|
|
181
321
|
// visibly rewinds mid-stream.
|
|
182
322
|
this.flushChain = this.flushChain
|
|
183
|
-
.then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot }, this.postTimeout); })
|
|
323
|
+
.then(async () => { await postPara(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot, ...(segs ? { segments: segs } : {}) }, this.postTimeout); })
|
|
184
324
|
.catch((err) => console.warn('[para-streamer] flush error:', (err as Error).message));
|
|
185
325
|
}
|
|
186
326
|
|