cortena-ui 1.5.0 → 1.7.0

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.
@@ -1,6 +1,25 @@
1
1
  "use client";
2
+ import { extractA2UIBlocks, foldA2UIBlocks } from "./a2ui-block.js";
3
+ import { truncate } from "./step-label.js";
2
4
  //#region src/agent-chat/session.ts
3
5
  /**
6
+ * Sessions, history and the filter chain.
7
+ *
8
+ * Ported from `cortena-shared/src/stores/use-chat.ts` and
9
+ * `cortenaweb/components/chat/message-bubble.tsx` (Cortena monorepo).
10
+ * `cortena-shared` is not published, so the rules the two transports share
11
+ * live here; keep them in step.
12
+ *
13
+ * The one deliberate difference is where the current session key is kept.
14
+ * cortenaweb puts it in `localStorage`, which is shared by every tab of an
15
+ * origin, so a second window adopted the first window's session: it inherited
16
+ * a stream that was not its own and both windows then showed the same content.
17
+ * Sessions here are **per window** — the key lives in `sessionStorage`, under a
18
+ * name scoped to the extension — so opening the extension twice starts two
19
+ * sessions. Both stay listed and either window can resume either one; two
20
+ * windows on one session may both send, and cortenacore serialises the runs.
21
+ */
22
+ /**
4
23
  * A new session key, minted client-side.
5
24
  *
6
25
  * `agent:<agentId>:new-<Date.now()>-<random36>`. cortenacore reads the
@@ -27,6 +46,10 @@ function sessionStorageKey(scope) {
27
46
  function viewStorageKey(scope) {
28
47
  return `cortena.agent-chat.${scope}.view`;
29
48
  }
49
+ /** The `sessionStorage` name this window's progress steps are kept under. */
50
+ function stepsStorageKey(scope) {
51
+ return `cortena.agent-chat.${scope}.steps`;
52
+ }
30
53
  /**
31
54
  * `sessionStorage`, or nothing.
32
55
  *
@@ -56,6 +79,126 @@ function writeStoredSessionKey(scope, key) {
56
79
  else storage.setItem(sessionStorageKey(scope), key);
57
80
  } catch {}
58
81
  }
82
+ /** Most steps kept, in memory and in `sessionStorage`. A turn that long is a runaway loop. */
83
+ const STEP_LIMIT = 50;
84
+ /**
85
+ * Most bytes the steps slot may occupy.
86
+ *
87
+ * `sessionStorage` is a few megabytes for the whole origin, shared with
88
+ * everything else the host keeps there, and a step count alone does not bound
89
+ * the size: fifty steps whose labels are all near the limit, with a failure
90
+ * message on each, is a slot nobody budgeted for. Over the cap the OLDEST are
91
+ * dropped, one at a time, because the recent ones are the ones a reload has to
92
+ * bring back.
93
+ */
94
+ const STEP_BYTE_LIMIT = 65536;
95
+ /**
96
+ * The steps of the current turn, and the session they belong to.
97
+ *
98
+ * They go in the per-window store beside the session key, for the same reason
99
+ * the key does: a tab is reloaded mid-run, or a host remounts the surface on a
100
+ * route change, and both threw the whole strip away. (Collapsing the pop-up is
101
+ * NOT one of them — the panel is `hidden`, not unmounted, and the run streams
102
+ * into it either way.) Messages come back from `chat.history`; steps do not
103
+ * exist on the server, so this is the only place they can come back from.
104
+ *
105
+ * Scoped to a session key, and checked on read: resuming a different session
106
+ * must not inherit the last one's steps.
107
+ */
108
+ function readStoredSteps(scope, sessionKey) {
109
+ try {
110
+ const raw = windowSessionStorage()?.getItem(stepsStorageKey(scope));
111
+ if (!raw) return [];
112
+ const parsed = JSON.parse(raw);
113
+ if (parsed?.sessionKey !== sessionKey || !Array.isArray(parsed.steps)) return [];
114
+ return parsed.steps.filter((step) => typeof step?.id === "string" && typeof step?.label === "string").map(restoreStep).slice(-50);
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+ /** Every status a step may legitimately come back with. */
120
+ const STEP_STATUSES = /* @__PURE__ */ new Set([
121
+ "running",
122
+ "done",
123
+ "error",
124
+ "stopped",
125
+ "paused"
126
+ ]);
127
+ /**
128
+ * A step as it comes back from storage.
129
+ *
130
+ * The important cases are `running` and `paused`. Neither is true any more:
131
+ * the run that owned the call ended when the surface went away — a reload, a
132
+ * remount on a route change; a collapsed pop-up only HIDES the strip and never
133
+ * reaches this path — and no result will arrive for it now, so a restored
134
+ * spinner spins until the user gives up and reloads again, which restores it.
135
+ *
136
+ * It comes back `stopped`, with no message. Neutral is the honest reading:
137
+ * nothing failed, the work simply did not continue, and "Interrupted." drawn in
138
+ * the failure colour told the user their call had broken when the page had.
139
+ *
140
+ * An unrecognised status is treated the same way. Nothing else writes this
141
+ * slot today, but it is per-origin `sessionStorage` and this is the only place
142
+ * that decides what a strip is allowed to render.
143
+ */
144
+ function restoreStep(step) {
145
+ if (STEP_STATUSES.has(step.status) && step.status !== "running" && step.status !== "paused") return step;
146
+ const restored = {
147
+ ...step,
148
+ status: "stopped",
149
+ endedAt: step.endedAt ?? Date.now()
150
+ };
151
+ delete restored.errorMessage;
152
+ return restored;
153
+ }
154
+ /**
155
+ * The fields of a step that are safe to keep, and the only ones kept.
156
+ *
157
+ * `args` is deliberately absent. A tool call's arguments are the user's data —
158
+ * what they searched for, whose record they opened, the body of what they
159
+ * wrote — and `sessionStorage` is readable by every script on the origin,
160
+ * survives the run, and is the sort of thing that ends up in a support bundle.
161
+ * Nothing on screen needs them after the run: the LABEL is already derived
162
+ * from them, and the label is what a restored strip shows.
163
+ */
164
+ function storedStep(step) {
165
+ return {
166
+ id: truncate(step.id, 80),
167
+ toolName: truncate(step.toolName, 80),
168
+ label: step.label,
169
+ status: step.status,
170
+ startedAt: step.startedAt,
171
+ ...step.endedAt === void 0 ? {} : { endedAt: step.endedAt },
172
+ ...step.errorMessage === void 0 ? {} : { errorMessage: step.errorMessage }
173
+ };
174
+ }
175
+ /** The slot's contents, projected and trimmed to fit the byte cap. */
176
+ function serialiseSteps(sessionKey, steps) {
177
+ let kept = steps.slice(-50).map(storedStep);
178
+ let json = JSON.stringify({
179
+ sessionKey,
180
+ steps: kept
181
+ });
182
+ while (kept.length > 1 && json.length > STEP_BYTE_LIMIT) {
183
+ kept = kept.slice(1);
184
+ json = JSON.stringify({
185
+ sessionKey,
186
+ steps: kept
187
+ });
188
+ }
189
+ return json;
190
+ }
191
+ function writeStoredSteps(scope, sessionKey, steps) {
192
+ try {
193
+ const storage = windowSessionStorage();
194
+ if (!storage) return;
195
+ if (!sessionKey || steps.length === 0) {
196
+ storage.removeItem(stepsStorageKey(scope));
197
+ return;
198
+ }
199
+ storage.setItem(stepsStorageKey(scope), serialiseSteps(sessionKey, steps));
200
+ } catch {}
201
+ }
59
202
  function generateId() {
60
203
  return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
61
204
  }
@@ -77,11 +220,19 @@ function isToolRelatedMessage(msg) {
77
220
  return false;
78
221
  }
79
222
  /**
80
- * Strip `System: [timestamp] …` lines and `[System Message]` prefixes the
81
- * runtime prepends to a user turn as agent context. They are not user text.
223
+ * A leading `[Dow YYYY-MM-DD HH:MM TZ] ` stamp, as the runtime injects it
224
+ * ahead of every message the agent is given. The date is what makes it safe
225
+ * to remove: a user who opens with "[draft] ship it" keeps their bracket.
226
+ * Same expression as `cortena-shared/src/stores/use-chat.ts`; keep in step.
227
+ */
228
+ const INJECTED_TIMESTAMP_PREFIX = /^\[[^\]]*\d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\]\s*/;
229
+ /**
230
+ * Strip the envelope the runtime prepends to a user turn: `System: [timestamp] …`
231
+ * lines and `[System Message]` prefixes carrying agent context, and the
232
+ * injected timestamp ahead of the words themselves. None of it is user text.
82
233
  */
83
234
  function stripSystemPrefixes(text) {
84
- return text.split("\n").filter((line) => !/^System:\s*\[/.test(line) && !line.startsWith("[System Message]")).join("\n").replace(/^\n+/, "");
235
+ return text.split("\n").filter((line) => !/^System:\s*\[/.test(line) && !line.startsWith("[System Message]")).join("\n").replace(/^\n+/, "").replace(INJECTED_TIMESTAMP_PREFIX, "");
85
236
  }
86
237
  /** Lift `<thinking>…</thinking>` out of plain text. */
87
238
  function separateThinking(text) {
@@ -97,6 +248,7 @@ function separateThinking(text) {
97
248
  function parseOneHistoryMessage(raw) {
98
249
  const msg = raw;
99
250
  const content = msg.content;
251
+ const a2ui = Array.isArray(content) ? extractA2UIBlocks({ content }) : [];
100
252
  let text = Array.isArray(content) ? content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("") : typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : "";
101
253
  const isToolMessage = isToolRelatedMessage(msg);
102
254
  const role = msg.role === "user" ? "user" : "assistant";
@@ -112,7 +264,8 @@ function parseOneHistoryMessage(raw) {
112
264
  text: mainText,
113
265
  timestamp: msg.timestamp ?? Date.now(),
114
266
  thinkingText: tagThinking,
115
- ...isToolMessage ? { isToolMessage } : {}
267
+ ...isToolMessage ? { isToolMessage } : {},
268
+ ...a2ui.length > 0 ? { a2ui } : {}
116
269
  };
117
270
  }
118
271
  return {
@@ -121,7 +274,8 @@ function parseOneHistoryMessage(raw) {
121
274
  text,
122
275
  timestamp: msg.timestamp ?? Date.now(),
123
276
  ...thinkingText ? { thinkingText } : {},
124
- ...isToolMessage ? { isToolMessage } : {}
277
+ ...isToolMessage ? { isToolMessage } : {},
278
+ ...a2ui.length > 0 ? { a2ui } : {}
125
279
  };
126
280
  }
127
281
  /**
@@ -155,9 +309,11 @@ function mergeConsecutiveAssistantMessages(messages) {
155
309
  if (entry.text.trim()) cot.push(entry.text.trim());
156
310
  }
157
311
  const combined = cot.filter(Boolean).join("\n\n");
312
+ const blocks = foldA2UIBlocks(run.flatMap((entry) => entry.a2ui ?? []));
158
313
  result.push({
159
314
  ...visible,
160
- ...combined ? { thinkingText: combined } : {}
315
+ ...combined ? { thinkingText: combined } : {},
316
+ ...blocks.length > 0 ? { a2ui: blocks } : {}
161
317
  });
162
318
  run = [];
163
319
  };
@@ -239,11 +395,11 @@ function isHiddenMessage(message) {
239
395
  if (message.isError) return false;
240
396
  if (message.isToolMessage) return true;
241
397
  if (message.role === "assistant" && !message.text.trim() && !message.thinkingText && !message.a2ui?.length) return true;
242
- if (message.role === "user") return !message.text.trim() && !message.attachments?.length;
398
+ if (message.role === "user") return !message.text.trim() && !message.attachments?.length && !message.attachmentItems?.length;
243
399
  const text = message.text;
244
400
  return isToolResult(text) || isSkillContent(text) || isAgentDiagnostics(text) || isToolOutput(text);
245
401
  }
246
402
  //#endregion
247
- export { isHiddenMessage, isProvisionalSessionKey, isSessionOfAgent, mintSessionKey, parseHistoryMessages, readStoredSessionKey, separateThinking, sessionStorageKey, viewStorageKey, windowSessionStorage, wouldShrinkHistory, writeStoredSessionKey };
403
+ export { STEP_LIMIT, isHiddenMessage, isProvisionalSessionKey, isSessionOfAgent, mintSessionKey, parseHistoryMessages, readStoredSessionKey, readStoredSteps, separateThinking, serialiseSteps, sessionStorageKey, stepsStorageKey, viewStorageKey, windowSessionStorage, wouldShrinkHistory, writeStoredSessionKey, writeStoredSteps };
248
404
 
249
405
  //# sourceMappingURL=session.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","names":[],"sources":["../../src/agent-chat/session.ts"],"sourcesContent":["/**\n * Sessions, history and the filter chain.\n *\n * Ported from `cortena-shared/src/stores/use-chat.ts` and\n * `cortenaweb/components/chat/message-bubble.tsx` (Cortena monorepo).\n * `cortena-shared` is not published, so the rules the two transports share\n * live here; keep them in step.\n *\n * The one deliberate difference is where the current session key is kept.\n * cortenaweb puts it in `localStorage`, which is shared by every tab of an\n * origin, so a second window adopted the first window's session: it inherited\n * a stream that was not its own and both windows then showed the same content.\n * Sessions here are **per window** — the key lives in `sessionStorage`, under a\n * name scoped to the extension — so opening the extension twice starts two\n * sessions. Both stay listed and either window can resume either one; two\n * windows on one session may both send, and cortenacore serialises the runs.\n */\n\nimport type { AgentChatMessage } from \"./types\";\n\n/* ── minting and remembering a key ───────────────────────────────────────── */\n\n/**\n * A new session key, minted client-side.\n *\n * `agent:<agentId>:new-<Date.now()>-<random36>`. cortenacore reads the\n * `agent:<id>:` prefix to bind the session to that agent's template, and the\n * key is the AG-UI `threadId` verbatim, prefix included. Minting it rather than\n * asking for one is what lets the first message go out immediately.\n */\nexport function mintSessionKey(agentId: string): string {\n return `agent:${agentId}:new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n}\n\n/** Was this key minted locally for a chat that had no session yet? */\nexport function isProvisionalSessionKey(key: string): boolean {\n return /(^|:)new-\\d+-[a-z0-9]+$/.test(key);\n}\n\n/** Does this session belong to this agent? `sessions.list` is filtered by it. */\nexport function isSessionOfAgent(key: string, agentId: string): boolean {\n return key.startsWith(`agent:${agentId}:`);\n}\n\n/** The `sessionStorage` name the current key is kept under, per extension. */\nexport function sessionStorageKey(scope: string): string {\n return `cortena.agent-chat.${scope}.session`;\n}\n\n/** The `sessionStorage` name the open/collapsed state is kept under. */\nexport function viewStorageKey(scope: string): string {\n return `cortena.agent-chat.${scope}.view`;\n}\n\n/**\n * `sessionStorage`, or nothing.\n *\n * Absent during SSR and refused outright when a browser blocks storage for the\n * origin, and neither is a reason for the chat not to work: without it a window\n * simply forgets its session on reload.\n */\nexport function windowSessionStorage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nexport function readStoredSessionKey(scope: string): string | null {\n try {\n return windowSessionStorage()?.getItem(sessionStorageKey(scope)) ?? null;\n } catch {\n return null;\n }\n}\n\nexport function writeStoredSessionKey(scope: string, key: string | null): void {\n try {\n const storage = windowSessionStorage();\n if (!storage) {\n return;\n }\n if (key === null) {\n storage.removeItem(sessionStorageKey(scope));\n } else {\n storage.setItem(sessionStorageKey(scope), key);\n }\n } catch {\n // A blocked storage is not a reason to lose the run in flight.\n }\n}\n\n/* ── history ─────────────────────────────────────────────────────────────── */\n\nfunction generateId(): string {\n return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n}\n\nfunction isToolRelatedMessage(msg: Record<string, unknown>): boolean {\n const role = typeof msg.role === \"string\" ? msg.role.toLowerCase() : \"\";\n if (role === \"tool\" || role === \"toolresult\" || role === \"tool_result\") {\n return true;\n }\n const content = msg.content as Array<{ type: string }> | undefined;\n if (Array.isArray(content) && content.length > 0) {\n const toolTypes = new Set([\n \"tool_use\",\n \"tool_call\",\n \"tool_result\",\n \"toolresult\",\n \"server_tool_use\",\n ]);\n const hasText = content.some((c) => c.type === \"text\" || c.type === \"thinking\");\n const hasToolBlocks = content.some((c) => toolTypes.has(c.type));\n if (hasToolBlocks && !hasText) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Strip `System: [timestamp] …` lines and `[System Message]` prefixes the\n * runtime prepends to a user turn as agent context. They are not user text.\n */\nfunction stripSystemPrefixes(text: string): string {\n return text\n .split(\"\\n\")\n .filter((line) => !/^System:\\s*\\[/.test(line) && !line.startsWith(\"[System Message]\"))\n .join(\"\\n\")\n .replace(/^\\n+/, \"\");\n}\n\n/** Lift `<thinking>…</thinking>` out of plain text. */\nexport function separateThinking(text: string): { thinkingText?: string; mainText: string } {\n const match = text.match(/<thinking>([\\s\\S]*?)<\\/thinking>/);\n if (!match?.[1]) {\n return { mainText: text };\n }\n const thinkingText = match[1].trim();\n const mainText = text.replace(/<thinking>[\\s\\S]*?<\\/thinking>\\s*/, \"\").trim();\n return { ...(thinkingText ? { thinkingText } : {}), mainText };\n}\n\nfunction parseOneHistoryMessage(raw: unknown): AgentChatMessage {\n const msg = raw as Record<string, unknown>;\n const content = msg.content as\n | Array<{ type: string; text?: string; thinking?: string; a2ui?: unknown }>\n | undefined;\n let text = Array.isArray(content)\n ? content\n .filter((c) => c.type === \"text\" && c.text)\n .map((c) => c.text!)\n .join(\"\")\n : typeof msg.content === \"string\"\n ? msg.content\n : typeof msg.text === \"string\"\n ? msg.text\n : \"\";\n\n const isToolMessage = isToolRelatedMessage(msg);\n const role = ((msg.role as string) === \"user\" ? \"user\" : \"assistant\") as \"user\" | \"assistant\";\n if (role === \"user\") {\n text = stripSystemPrefixes(text);\n }\n\n let thinkingText = \"\";\n if (typeof msg.thinking === \"string\" && msg.thinking.trim()) {\n thinkingText = msg.thinking.trim();\n } else if (Array.isArray(content)) {\n thinkingText = content\n .filter((c) => c.type === \"thinking\")\n .map((c) => c.thinking ?? c.text ?? \"\")\n .filter(Boolean)\n .join(\"\\n\\n\");\n }\n if (!thinkingText) {\n const { thinkingText: tagThinking, mainText } = separateThinking(text);\n if (tagThinking) {\n return {\n id: typeof msg.id === \"string\" ? msg.id : generateId(),\n role,\n text: mainText,\n timestamp: (msg.timestamp as number) ?? Date.now(),\n thinkingText: tagThinking,\n ...(isToolMessage ? { isToolMessage } : {}),\n };\n }\n }\n\n return {\n id: typeof msg.id === \"string\" ? msg.id : generateId(),\n role,\n text,\n timestamp: (msg.timestamp as number) ?? Date.now(),\n ...(thinkingText ? { thinkingText } : {}),\n ...(isToolMessage ? { isToolMessage } : {}),\n };\n}\n\n/**\n * Merge an agent turn — assistant messages plus tool call and result messages —\n * into one visible message. Everything except the final substantive response is\n * folded into the chain of thought. Only a genuine user message breaks the turn.\n */\nfunction mergeConsecutiveAssistantMessages(messages: AgentChatMessage[]): AgentChatMessage[] {\n const result: AgentChatMessage[] = [];\n let run: AgentChatMessage[] = [];\n\n const flush = () => {\n const first = run[0];\n if (!first) {\n return;\n }\n if (run.length === 1) {\n result.push(first);\n run = [];\n return;\n }\n let visibleIdx = run.length - 1;\n while (visibleIdx > 0 && !(run[visibleIdx]?.text ?? \"\").trim()) {\n visibleIdx--;\n }\n const visible = run[visibleIdx] ?? first;\n const cot: string[] = [];\n for (let i = 0; i < run.length; i++) {\n const entry = run[i];\n if (!entry) {\n continue;\n }\n if (i === visibleIdx) {\n if (visible.thinkingText) {\n cot.push(visible.thinkingText);\n }\n continue;\n }\n if (entry.thinkingText) {\n cot.push(entry.thinkingText);\n }\n if (entry.text.trim()) {\n cot.push(entry.text.trim());\n }\n }\n const combined = cot.filter(Boolean).join(\"\\n\\n\");\n result.push({ ...visible, ...(combined ? { thinkingText: combined } : {}) });\n run = [];\n };\n\n for (const msg of messages) {\n const isRealUser = msg.role === \"user\" && !msg.isToolMessage && msg.text.trim().length > 0;\n if (isRealUser) {\n flush();\n result.push(msg);\n } else {\n run.push(msg);\n }\n }\n flush();\n return result;\n}\n\n/** Raw transcript records to the messages the list renders. */\nexport function parseHistoryMessages(raw: readonly unknown[]): AgentChatMessage[] {\n return mergeConsecutiveAssistantMessages(raw.map(parseOneHistoryMessage));\n}\n\n/**\n * The history shrink guard.\n *\n * A history load must not shrink the session already on screen. The first\n * message of a new chat loads history while the turn is still in flight and the\n * user's message has not been persisted yet; replacing wholesale threw that\n * message away, and the reply survived, so the symptom was a conversation with\n * the answer but not the question.\n *\n * Only the same session, and only against losing messages: a longer or equal\n * history still replaces, so an edit, a deletion made elsewhere and a compaction\n * all land normally.\n */\nexport function wouldShrinkHistory(params: {\n currentSessionKey: string | null;\n loadedSessionKey: string;\n currentCount: number;\n loadedCount: number;\n}): boolean {\n return (\n params.currentSessionKey === params.loadedSessionKey &&\n params.loadedCount < params.currentCount\n );\n}\n\n/* ── the filter chain ────────────────────────────────────────────────────── */\n\n/** A JSON blob a tool or an extension API returned, echoed as a message. */\nfunction isToolResult(text: string): boolean {\n const trimmed = text.trim();\n const wrapped =\n (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) ||\n (trimmed.startsWith(\"{\") && trimmed.endsWith(\"}\"));\n if (!wrapped) {\n return false;\n }\n try {\n const parsed = JSON.parse(trimmed);\n return typeof parsed === \"object\" && parsed !== null;\n } catch {\n return false;\n }\n}\n\n/** Raw skill-file content the model echoed instead of acting on. */\nfunction isSkillContent(text: string): boolean {\n const trimmed = text.trim();\n return (\n /^name:\\s*\\S+.*\\bdescription:.*\\btype:\\s*skill\\b/i.test(trimmed) ||\n /^---\\s*\\n[\\s\\S]*?\\btype:\\s*skill\\b[\\s\\S]*?\\n---/m.test(trimmed)\n );\n}\n\n/** Runtime diagnostics: token counters, a leaked api-key hint. */\nfunction isAgentDiagnostics(text: string): boolean {\n const trimmed = text.trim();\n return /Tokens:\\s*\\d+.*Cache:.*Context:/s.test(trimmed) || /\\bapi-key\\s+sk-/.test(trimmed);\n}\n\n/** Tool execution output that leaked into history as its own message. */\nfunction isToolOutput(text: string): boolean {\n const trimmed = text.trim();\n if (!trimmed) {\n return false;\n }\n if (/^Tool\\s+\\S+\\s+not found$/i.test(trimmed)) {\n return true;\n }\n if (trimmed === \"(no output)\") {\n return true;\n }\n if (/^total\\s+\\d+\\s+[d-][rwx-]{9}/.test(trimmed)) {\n return true;\n }\n if (/^[d-][rwx-]{9}[@+]?\\s+\\d+\\s+\\S+\\s+\\S+/.test(trimmed)) {\n return true;\n }\n if (/(?:^|\\n)[A-Z_]{2,}=\\S/m.test(trimmed)) {\n if (\n /\\b(?:PATH|PWD|HOME|USER|INIT_CWD|PNPM_|NODE_|npm_|CORTENA_|CORTENACORE_|CORTENABOT_)/m.test(\n trimmed,\n )\n ) {\n return true;\n }\n if ((trimmed.match(/(?:^|\\n)[A-Z_]{3,}=\\S/gm) ?? []).length >= 3) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Should this message be hidden?\n *\n * The same chain cortenaweb's bubble applies, in the same order. It exists\n * because tool plumbing leaks into a transcript in several recognisable shapes,\n * and a chat that renders them reads as broken. Tool detail is not lost: it is\n * in the tool strip and in the chain of thought.\n *\n * A reply that is only UI the agent drew has no text at all, so an A2UI block\n * counts as content — without that, the surface the user asked for is filtered\n * away and nothing appears.\n */\nexport function isHiddenMessage(message: AgentChatMessage): boolean {\n if (message.isError) {\n return false;\n }\n if (message.isToolMessage) {\n return true;\n }\n if (\n message.role === \"assistant\" &&\n !message.text.trim() &&\n !message.thinkingText &&\n !message.a2ui?.length\n ) {\n return true;\n }\n if (message.role === \"user\") {\n return !message.text.trim() && !message.attachments?.length;\n }\n const text = message.text;\n return isToolResult(text) || isSkillContent(text) || isAgentDiagnostics(text) || isToolOutput(text);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"session.js","names":[],"sources":["../../src/agent-chat/session.ts"],"sourcesContent":["/**\n * Sessions, history and the filter chain.\n *\n * Ported from `cortena-shared/src/stores/use-chat.ts` and\n * `cortenaweb/components/chat/message-bubble.tsx` (Cortena monorepo).\n * `cortena-shared` is not published, so the rules the two transports share\n * live here; keep them in step.\n *\n * The one deliberate difference is where the current session key is kept.\n * cortenaweb puts it in `localStorage`, which is shared by every tab of an\n * origin, so a second window adopted the first window's session: it inherited\n * a stream that was not its own and both windows then showed the same content.\n * Sessions here are **per window** — the key lives in `sessionStorage`, under a\n * name scoped to the extension — so opening the extension twice starts two\n * sessions. Both stay listed and either window can resume either one; two\n * windows on one session may both send, and cortenacore serialises the runs.\n */\n\nimport { extractA2UIBlocks, foldA2UIBlocks } from \"./a2ui-block\";\nimport { truncate } from \"./step-label\";\nimport type { AgentChatMessage, AgentChatStep } from \"./types\";\n\n/* ── minting and remembering a key ───────────────────────────────────────── */\n\n/**\n * A new session key, minted client-side.\n *\n * `agent:<agentId>:new-<Date.now()>-<random36>`. cortenacore reads the\n * `agent:<id>:` prefix to bind the session to that agent's template, and the\n * key is the AG-UI `threadId` verbatim, prefix included. Minting it rather than\n * asking for one is what lets the first message go out immediately.\n */\nexport function mintSessionKey(agentId: string): string {\n return `agent:${agentId}:new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n}\n\n/** Was this key minted locally for a chat that had no session yet? */\nexport function isProvisionalSessionKey(key: string): boolean {\n return /(^|:)new-\\d+-[a-z0-9]+$/.test(key);\n}\n\n/** Does this session belong to this agent? `sessions.list` is filtered by it. */\nexport function isSessionOfAgent(key: string, agentId: string): boolean {\n return key.startsWith(`agent:${agentId}:`);\n}\n\n/** The `sessionStorage` name the current key is kept under, per extension. */\nexport function sessionStorageKey(scope: string): string {\n return `cortena.agent-chat.${scope}.session`;\n}\n\n/** The `sessionStorage` name the open/collapsed state is kept under. */\nexport function viewStorageKey(scope: string): string {\n return `cortena.agent-chat.${scope}.view`;\n}\n\n/** The `sessionStorage` name this window's progress steps are kept under. */\nexport function stepsStorageKey(scope: string): string {\n return `cortena.agent-chat.${scope}.steps`;\n}\n\n/**\n * `sessionStorage`, or nothing.\n *\n * Absent during SSR and refused outright when a browser blocks storage for the\n * origin, and neither is a reason for the chat not to work: without it a window\n * simply forgets its session on reload.\n */\nexport function windowSessionStorage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nexport function readStoredSessionKey(scope: string): string | null {\n try {\n return windowSessionStorage()?.getItem(sessionStorageKey(scope)) ?? null;\n } catch {\n return null;\n }\n}\n\nexport function writeStoredSessionKey(scope: string, key: string | null): void {\n try {\n const storage = windowSessionStorage();\n if (!storage) {\n return;\n }\n if (key === null) {\n storage.removeItem(sessionStorageKey(scope));\n } else {\n storage.setItem(sessionStorageKey(scope), key);\n }\n } catch {\n // A blocked storage is not a reason to lose the run in flight.\n }\n}\n\n/* ── the progress steps of the turn ──────────────────────────────────────── */\n\n/** Most steps kept, in memory and in `sessionStorage`. A turn that long is a runaway loop. */\nexport const STEP_LIMIT = 50;\n\n/**\n * Most bytes the steps slot may occupy.\n *\n * `sessionStorage` is a few megabytes for the whole origin, shared with\n * everything else the host keeps there, and a step count alone does not bound\n * the size: fifty steps whose labels are all near the limit, with a failure\n * message on each, is a slot nobody budgeted for. Over the cap the OLDEST are\n * dropped, one at a time, because the recent ones are the ones a reload has to\n * bring back.\n */\nconst STEP_BYTE_LIMIT = 64 * 1024;\n\n/** Longest tool call id or tool name kept, in storage and on `data-tool`. */\nexport const STORED_TEXT_LIMIT = 80;\n\n/**\n * The steps of the current turn, and the session they belong to.\n *\n * They go in the per-window store beside the session key, for the same reason\n * the key does: a tab is reloaded mid-run, or a host remounts the surface on a\n * route change, and both threw the whole strip away. (Collapsing the pop-up is\n * NOT one of them — the panel is `hidden`, not unmounted, and the run streams\n * into it either way.) Messages come back from `chat.history`; steps do not\n * exist on the server, so this is the only place they can come back from.\n *\n * Scoped to a session key, and checked on read: resuming a different session\n * must not inherit the last one's steps.\n */\nexport function readStoredSteps(scope: string, sessionKey: string): AgentChatStep[] {\n try {\n const raw = windowSessionStorage()?.getItem(stepsStorageKey(scope));\n if (!raw) {\n return [];\n }\n const parsed = JSON.parse(raw) as { sessionKey?: unknown; steps?: unknown };\n if (parsed?.sessionKey !== sessionKey || !Array.isArray(parsed.steps)) {\n return [];\n }\n return (parsed.steps as AgentChatStep[])\n .filter((step) => typeof step?.id === \"string\" && typeof step?.label === \"string\")\n .map(restoreStep)\n .slice(-STEP_LIMIT);\n } catch {\n // Storage refused, or somebody else wrote the slot. Neither is worth a\n // broken chat; the strip simply starts empty.\n return [];\n }\n}\n\n/** Every status a step may legitimately come back with. */\nconst STEP_STATUSES: ReadonlySet<string> = new Set([\n \"running\",\n \"done\",\n \"error\",\n \"stopped\",\n \"paused\",\n]);\n\n/**\n * A step as it comes back from storage.\n *\n * The important cases are `running` and `paused`. Neither is true any more:\n * the run that owned the call ended when the surface went away — a reload, a\n * remount on a route change; a collapsed pop-up only HIDES the strip and never\n * reaches this path — and no result will arrive for it now, so a restored\n * spinner spins until the user gives up and reloads again, which restores it.\n *\n * It comes back `stopped`, with no message. Neutral is the honest reading:\n * nothing failed, the work simply did not continue, and \"Interrupted.\" drawn in\n * the failure colour told the user their call had broken when the page had.\n *\n * An unrecognised status is treated the same way. Nothing else writes this\n * slot today, but it is per-origin `sessionStorage` and this is the only place\n * that decides what a strip is allowed to render.\n */\nfunction restoreStep(step: AgentChatStep): AgentChatStep {\n const known = STEP_STATUSES.has(step.status);\n if (known && step.status !== \"running\" && step.status !== \"paused\") {\n return step;\n }\n const restored: AgentChatStep = {\n ...step,\n status: \"stopped\",\n endedAt: step.endedAt ?? Date.now(),\n };\n delete restored.errorMessage;\n return restored;\n}\n\n/**\n * The fields of a step that are safe to keep, and the only ones kept.\n *\n * `args` is deliberately absent. A tool call's arguments are the user's data —\n * what they searched for, whose record they opened, the body of what they\n * wrote — and `sessionStorage` is readable by every script on the origin,\n * survives the run, and is the sort of thing that ends up in a support bundle.\n * Nothing on screen needs them after the run: the LABEL is already derived\n * from them, and the label is what a restored strip shows.\n */\nfunction storedStep(step: AgentChatStep): AgentChatStep {\n return {\n // Bounded, both of them. Neither is written by this package: the id is the\n // producer's `toolCallId` and the name is whatever the model called, so a\n // single call carrying 9 kB of either filled the byte cap on its own and\n // pushed every real step out of the slot.\n id: truncate(step.id, STORED_TEXT_LIMIT),\n toolName: truncate(step.toolName, STORED_TEXT_LIMIT),\n label: step.label,\n status: step.status,\n startedAt: step.startedAt,\n ...(step.endedAt === undefined ? {} : { endedAt: step.endedAt }),\n ...(step.errorMessage === undefined ? {} : { errorMessage: step.errorMessage }),\n };\n}\n\n/** The slot's contents, projected and trimmed to fit the byte cap. */\nexport function serialiseSteps(sessionKey: string, steps: readonly AgentChatStep[]): string {\n let kept = steps.slice(-STEP_LIMIT).map(storedStep);\n let json = JSON.stringify({ sessionKey, steps: kept });\n while (kept.length > 1 && json.length > STEP_BYTE_LIMIT) {\n kept = kept.slice(1);\n json = JSON.stringify({ sessionKey, steps: kept });\n }\n return json;\n}\n\nexport function writeStoredSteps(\n scope: string,\n sessionKey: string | null,\n steps: readonly AgentChatStep[],\n): void {\n try {\n const storage = windowSessionStorage();\n if (!storage) {\n return;\n }\n if (!sessionKey || steps.length === 0) {\n storage.removeItem(stepsStorageKey(scope));\n return;\n }\n storage.setItem(stepsStorageKey(scope), serialiseSteps(sessionKey, steps));\n } catch {\n // Blocked, or over quota. The run in flight is not worth losing over it.\n }\n}\n\n/* ── history ─────────────────────────────────────────────────────────────── */\n\nfunction generateId(): string {\n return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n}\n\nfunction isToolRelatedMessage(msg: Record<string, unknown>): boolean {\n const role = typeof msg.role === \"string\" ? msg.role.toLowerCase() : \"\";\n if (role === \"tool\" || role === \"toolresult\" || role === \"tool_result\") {\n return true;\n }\n const content = msg.content as Array<{ type: string }> | undefined;\n if (Array.isArray(content) && content.length > 0) {\n const toolTypes = new Set([\n \"tool_use\",\n \"tool_call\",\n \"tool_result\",\n \"toolresult\",\n \"server_tool_use\",\n ]);\n const hasText = content.some((c) => c.type === \"text\" || c.type === \"thinking\");\n const hasToolBlocks = content.some((c) => toolTypes.has(c.type));\n if (hasToolBlocks && !hasText) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * A leading `[Dow YYYY-MM-DD HH:MM TZ] ` stamp, as the runtime injects it\n * ahead of every message the agent is given. The date is what makes it safe\n * to remove: a user who opens with \"[draft] ship it\" keeps their bracket.\n * Same expression as `cortena-shared/src/stores/use-chat.ts`; keep in step.\n */\nconst INJECTED_TIMESTAMP_PREFIX = /^\\[[^\\]]*\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}[^\\]]*\\]\\s*/;\n\n/**\n * Strip the envelope the runtime prepends to a user turn: `System: [timestamp] …`\n * lines and `[System Message]` prefixes carrying agent context, and the\n * injected timestamp ahead of the words themselves. None of it is user text.\n */\nfunction stripSystemPrefixes(text: string): string {\n return text\n .split(\"\\n\")\n .filter((line) => !/^System:\\s*\\[/.test(line) && !line.startsWith(\"[System Message]\"))\n .join(\"\\n\")\n .replace(/^\\n+/, \"\")\n .replace(INJECTED_TIMESTAMP_PREFIX, \"\");\n}\n\n/** Lift `<thinking>…</thinking>` out of plain text. */\nexport function separateThinking(text: string): { thinkingText?: string; mainText: string } {\n const match = text.match(/<thinking>([\\s\\S]*?)<\\/thinking>/);\n if (!match?.[1]) {\n return { mainText: text };\n }\n const thinkingText = match[1].trim();\n const mainText = text.replace(/<thinking>[\\s\\S]*?<\\/thinking>\\s*/, \"\").trim();\n return { ...(thinkingText ? { thinkingText } : {}), mainText };\n}\n\nfunction parseOneHistoryMessage(raw: unknown): AgentChatMessage {\n const msg = raw as Record<string, unknown>;\n const content = msg.content as\n | Array<{ type: string; text?: string; thinking?: string; a2ui?: unknown }>\n | undefined;\n // The UI the agent drew, back from the transcript. `chat.history` lifts the\n // ```a2ui fences out of the prose and leaves them as `a2ui` content parts —\n // the same parts a live delta carries — so a reload has a block to render\n // rather than a fence to parse. Without this every drawn surface vanished\n // on reopen. Ported from cortena-shared (CORTENA-44).\n const a2ui = Array.isArray(content)\n ? extractA2UIBlocks({ content: content as Array<Record<string, unknown>> })\n : [];\n let text = Array.isArray(content)\n ? content\n .filter((c) => c.type === \"text\" && c.text)\n .map((c) => c.text!)\n .join(\"\")\n : typeof msg.content === \"string\"\n ? msg.content\n : typeof msg.text === \"string\"\n ? msg.text\n : \"\";\n\n const isToolMessage = isToolRelatedMessage(msg);\n const role = ((msg.role as string) === \"user\" ? \"user\" : \"assistant\") as \"user\" | \"assistant\";\n if (role === \"user\") {\n text = stripSystemPrefixes(text);\n }\n\n let thinkingText = \"\";\n if (typeof msg.thinking === \"string\" && msg.thinking.trim()) {\n thinkingText = msg.thinking.trim();\n } else if (Array.isArray(content)) {\n thinkingText = content\n .filter((c) => c.type === \"thinking\")\n .map((c) => c.thinking ?? c.text ?? \"\")\n .filter(Boolean)\n .join(\"\\n\\n\");\n }\n if (!thinkingText) {\n const { thinkingText: tagThinking, mainText } = separateThinking(text);\n if (tagThinking) {\n return {\n id: typeof msg.id === \"string\" ? msg.id : generateId(),\n role,\n text: mainText,\n timestamp: (msg.timestamp as number) ?? Date.now(),\n thinkingText: tagThinking,\n ...(isToolMessage ? { isToolMessage } : {}),\n ...(a2ui.length > 0 ? { a2ui } : {}),\n };\n }\n }\n\n return {\n id: typeof msg.id === \"string\" ? msg.id : generateId(),\n role,\n text,\n timestamp: (msg.timestamp as number) ?? Date.now(),\n ...(thinkingText ? { thinkingText } : {}),\n ...(isToolMessage ? { isToolMessage } : {}),\n ...(a2ui.length > 0 ? { a2ui } : {}),\n };\n}\n\n/**\n * Merge an agent turn — assistant messages plus tool call and result messages —\n * into one visible message. Everything except the final substantive response is\n * folded into the chain of thought. Only a genuine user message breaks the turn.\n */\nfunction mergeConsecutiveAssistantMessages(messages: AgentChatMessage[]): AgentChatMessage[] {\n const result: AgentChatMessage[] = [];\n let run: AgentChatMessage[] = [];\n\n const flush = () => {\n const first = run[0];\n if (!first) {\n return;\n }\n if (run.length === 1) {\n result.push(first);\n run = [];\n return;\n }\n let visibleIdx = run.length - 1;\n while (visibleIdx > 0 && !(run[visibleIdx]?.text ?? \"\").trim()) {\n visibleIdx--;\n }\n const visible = run[visibleIdx] ?? first;\n const cot: string[] = [];\n for (let i = 0; i < run.length; i++) {\n const entry = run[i];\n if (!entry) {\n continue;\n }\n if (i === visibleIdx) {\n if (visible.thinkingText) {\n cot.push(visible.thinkingText);\n }\n continue;\n }\n if (entry.thinkingText) {\n cot.push(entry.thinkingText);\n }\n if (entry.text.trim()) {\n cot.push(entry.text.trim());\n }\n }\n const combined = cot.filter(Boolean).join(\"\\n\\n\");\n // A surface the agent drew in an earlier message of the same turn belongs\n // to the answer the turn produced; folding the run must not drop it.\n const blocks = foldA2UIBlocks(run.flatMap((entry) => entry.a2ui ?? []));\n result.push({\n ...visible,\n ...(combined ? { thinkingText: combined } : {}),\n ...(blocks.length > 0 ? { a2ui: blocks } : {}),\n });\n run = [];\n };\n\n for (const msg of messages) {\n const isRealUser = msg.role === \"user\" && !msg.isToolMessage && msg.text.trim().length > 0;\n if (isRealUser) {\n flush();\n result.push(msg);\n } else {\n run.push(msg);\n }\n }\n flush();\n return result;\n}\n\n/** Raw transcript records to the messages the list renders. */\nexport function parseHistoryMessages(raw: readonly unknown[]): AgentChatMessage[] {\n return mergeConsecutiveAssistantMessages(raw.map(parseOneHistoryMessage));\n}\n\n/**\n * The history shrink guard.\n *\n * A history load must not shrink the session already on screen. The first\n * message of a new chat loads history while the turn is still in flight and the\n * user's message has not been persisted yet; replacing wholesale threw that\n * message away, and the reply survived, so the symptom was a conversation with\n * the answer but not the question.\n *\n * Only the same session, and only against losing messages: a longer or equal\n * history still replaces, so an edit, a deletion made elsewhere and a compaction\n * all land normally.\n */\nexport function wouldShrinkHistory(params: {\n currentSessionKey: string | null;\n loadedSessionKey: string;\n currentCount: number;\n loadedCount: number;\n}): boolean {\n return (\n params.currentSessionKey === params.loadedSessionKey &&\n params.loadedCount < params.currentCount\n );\n}\n\n/* ── the filter chain ────────────────────────────────────────────────────── */\n\n/** A JSON blob a tool or an extension API returned, echoed as a message. */\nfunction isToolResult(text: string): boolean {\n const trimmed = text.trim();\n const wrapped =\n (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) ||\n (trimmed.startsWith(\"{\") && trimmed.endsWith(\"}\"));\n if (!wrapped) {\n return false;\n }\n try {\n const parsed = JSON.parse(trimmed);\n return typeof parsed === \"object\" && parsed !== null;\n } catch {\n return false;\n }\n}\n\n/** Raw skill-file content the model echoed instead of acting on. */\nfunction isSkillContent(text: string): boolean {\n const trimmed = text.trim();\n return (\n /^name:\\s*\\S+.*\\bdescription:.*\\btype:\\s*skill\\b/i.test(trimmed) ||\n /^---\\s*\\n[\\s\\S]*?\\btype:\\s*skill\\b[\\s\\S]*?\\n---/m.test(trimmed)\n );\n}\n\n/** Runtime diagnostics: token counters, a leaked api-key hint. */\nfunction isAgentDiagnostics(text: string): boolean {\n const trimmed = text.trim();\n return /Tokens:\\s*\\d+.*Cache:.*Context:/s.test(trimmed) || /\\bapi-key\\s+sk-/.test(trimmed);\n}\n\n/** Tool execution output that leaked into history as its own message. */\nfunction isToolOutput(text: string): boolean {\n const trimmed = text.trim();\n if (!trimmed) {\n return false;\n }\n if (/^Tool\\s+\\S+\\s+not found$/i.test(trimmed)) {\n return true;\n }\n if (trimmed === \"(no output)\") {\n return true;\n }\n if (/^total\\s+\\d+\\s+[d-][rwx-]{9}/.test(trimmed)) {\n return true;\n }\n if (/^[d-][rwx-]{9}[@+]?\\s+\\d+\\s+\\S+\\s+\\S+/.test(trimmed)) {\n return true;\n }\n if (/(?:^|\\n)[A-Z_]{2,}=\\S/m.test(trimmed)) {\n if (\n /\\b(?:PATH|PWD|HOME|USER|INIT_CWD|PNPM_|NODE_|npm_|CORTENA_|CORTENACORE_|CORTENABOT_)/m.test(\n trimmed,\n )\n ) {\n return true;\n }\n if ((trimmed.match(/(?:^|\\n)[A-Z_]{3,}=\\S/gm) ?? []).length >= 3) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Should this message be hidden?\n *\n * The same chain cortenaweb's bubble applies, in the same order. It exists\n * because tool plumbing leaks into a transcript in several recognisable shapes,\n * and a chat that renders them reads as broken. Tool detail is not lost: it is\n * in the tool strip and in the chain of thought.\n *\n * A reply that is only UI the agent drew has no text at all, so an A2UI block\n * counts as content — without that, the surface the user asked for is filtered\n * away and nothing appears.\n */\nexport function isHiddenMessage(message: AgentChatMessage): boolean {\n if (message.isError) {\n return false;\n }\n if (message.isToolMessage) {\n return true;\n }\n if (\n message.role === \"assistant\" &&\n !message.text.trim() &&\n !message.thinkingText &&\n !message.a2ui?.length\n ) {\n return true;\n }\n if (message.role === \"user\") {\n return (\n !message.text.trim() && !message.attachments?.length && !message.attachmentItems?.length\n );\n }\n const text = message.text;\n return isToolResult(text) || isSkillContent(text) || isAgentDiagnostics(text) || isToolOutput(text);\n}\n"],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ //#region src/agent-chat/step-label.d.ts
3
+ /**
4
+ * The wording map: one plain sentence per tool call (SKILLS-9).
5
+ *
6
+ * A run is a sequence of tool calls, and what the user sees of it today is a
7
+ * tool name and a JSON blob. `engram_describe {"id":"tasks.task.list"}` is an
8
+ * accurate description of nothing anybody asked for. This module turns the
9
+ * three native tools — `engram_search`, `engram_describe`, `broker_invoke` —
10
+ * into sentences a person can read while they wait.
11
+ *
12
+ * It is a pure function of the tool name and the arguments, in its own module,
13
+ * for two reasons. It is the part most likely to be edited by someone who is
14
+ * not editing the store, and it is the part worth testing exhaustively: the
15
+ * store's job is start/end pairing, and the wording's job is being readable.
16
+ *
17
+ * Deliberately NOT here: anything that needs the result. The client cannot know
18
+ * whether a `broker_invoke` is a read or a write before the reply comes back —
19
+ * only the catalogue knows, and the model only carries `confirmed` once the
20
+ * user has said yes — so an unconfirmed invoke is worded "Fetching …", which is
21
+ * true of a read and harmless of a write that has not happened yet. The one
22
+ * result-dependent wording, a 409 `confirmation_required`, is
23
+ * `confirmationRequiredLabel` and the store applies it when the envelope
24
+ * arrives.
25
+ */
26
+ /**
27
+ * Which end of the step the label is for.
28
+ *
29
+ * Nearly every sentence reads the same either way, because it describes the
30
+ * step rather than claiming an outcome — "Reading skill: alerts" is as true of
31
+ * a finished read as of one in flight, and rewriting it on completion makes the
32
+ * strip jump under the reader's eye for no gain. The exception is the confirmed
33
+ * write, where "Doing: tasks task create" and "Done: tasks task create" are
34
+ * genuinely different facts and the user needs the second one.
35
+ */
36
+ export type AgentStepPhase = "running" | "done";
37
+ /**
38
+ * An id as words: `tasks.task.list` -> "tasks task list".
39
+ *
40
+ * Deterministic and dumb on purpose. A cleverer mapping — reordering to "list
41
+ * tasks", pluralising, looking up a display name — needs a table that has to be
42
+ * maintained for every capability in every extension, and a table that is one
43
+ * release out of date reads worse than the id did.
44
+ */
45
+ export declare function humaniseId(id: string): string;
46
+ /**
47
+ * One line of it, sanitised and bounded, with the ellipsis inside the limit.
48
+ *
49
+ * Everything interpolated into a label goes through this, including the ones
50
+ * that look safe: a tool name, an operation id, a server's `message`. All three
51
+ * arrive from outside — a catalogue entry, a broker reply — and none is worth
52
+ * trusting with the length of a line or with the direction of it. Control and
53
+ * format characters are stripped BEFORE the length check, so a run of
54
+ * invisibles cannot eat the budget and a U+202E cannot flip the rest of the
55
+ * strip right-to-left. Newlines have already become spaces by then, so the
56
+ * strip does not silently join two words.
57
+ */
58
+ export declare function truncate(text: string, limit?: number): string;
59
+ /**
60
+ * The line for a call nothing better can be said about yet.
61
+ *
62
+ * Its own function because two other places have to RECOGNISE it: the store
63
+ * re-words a step that finished on this line if its arguments turn up late, and
64
+ * the strip holds it out of the live region rather than announcing
65
+ * "Working: broker_invoke" to a screen reader a moment before the real sentence
66
+ * replaces it.
67
+ */
68
+ export declare function genericLabel(toolName: string): string;
69
+ /** Is this label the fallback rather than something the arguments produced? */
70
+ export declare function isGenericLabel(toolName: string, label: string): boolean;
71
+ /**
72
+ * The line shown for one tool call.
73
+ *
74
+ * | tool | label |
75
+ * | --- | --- |
76
+ * | `engram_search` | Looking for a way to \<intent\> |
77
+ * | `engram_describe`, `skill.*` | Reading skill: \<title, or the topic\> |
78
+ * | `engram_describe`, anything else | Checking how to \<id as words\> |
79
+ * | `broker_invoke`, `confirmed: true` | Doing: \<operation\> / Done: \<operation\> |
80
+ * | `broker_invoke`, otherwise | Fetching \<operation\> |
81
+ * | anything else | Working: \<tool name\> |
82
+ *
83
+ * The key argument missing falls through to the last row rather than inventing
84
+ * a sentence about nothing: arguments arrive in a second event
85
+ * (`TOOL_CALL_ARGS`), so "no arguments yet" is a state every call passes
86
+ * through, and the generic line is what it should read as until they land.
87
+ */
88
+ export declare function stepLabel(toolName: string, args: Record<string, unknown> | undefined, phase: AgentStepPhase): string;
89
+ /**
90
+ * The label for a call the broker refused pending a yes.
91
+ *
92
+ * A 409 `confirmation_required` is not a failure — it is the broker doing its
93
+ * job on a write the user has not approved — so the step keeps its tick and
94
+ * changes its words. Drawn as an error it reads as "the agent broke"; drawn as
95
+ * "Fetching …", completed, it reads as though the write went through.
96
+ */
97
+ export declare function confirmationRequiredLabel(args: Record<string, unknown> | undefined): string;
98
+ //#endregion
99
+ //# sourceMappingURL=step-label.d.ts.map
@@ -0,0 +1,116 @@
1
+ "use client";
2
+ //#region src/agent-chat/step-label.ts
3
+ /** Longest intent or title carried into a label, ellipsis included. */
4
+ const TEXT_LIMIT = 80;
5
+ /**
6
+ * An id as words: `tasks.task.list` -> "tasks task list".
7
+ *
8
+ * Deterministic and dumb on purpose. A cleverer mapping — reordering to "list
9
+ * tasks", pluralising, looking up a display name — needs a table that has to be
10
+ * maintained for every capability in every extension, and a table that is one
11
+ * release out of date reads worse than the id did.
12
+ */
13
+ function humaniseId(id) {
14
+ return id.split(/[.\-_]+/).filter(Boolean).join(" ").toLowerCase();
15
+ }
16
+ /** The topic of a skill id: `skill.platform.using-extensions` -> "using extensions". */
17
+ function skillTopic(id) {
18
+ const parts = id.split(".").filter(Boolean);
19
+ const last = parts[parts.length - 1];
20
+ return last ? humaniseId(last) : humaniseId(id);
21
+ }
22
+ /**
23
+ * One line of it, sanitised and bounded, with the ellipsis inside the limit.
24
+ *
25
+ * Everything interpolated into a label goes through this, including the ones
26
+ * that look safe: a tool name, an operation id, a server's `message`. All three
27
+ * arrive from outside — a catalogue entry, a broker reply — and none is worth
28
+ * trusting with the length of a line or with the direction of it. Control and
29
+ * format characters are stripped BEFORE the length check, so a run of
30
+ * invisibles cannot eat the budget and a U+202E cannot flip the rest of the
31
+ * strip right-to-left. Newlines have already become spaces by then, so the
32
+ * strip does not silently join two words.
33
+ */
34
+ function truncate(text, limit = TEXT_LIMIT) {
35
+ const collapsed = text.replace(/\s+/g, " ").trim().replace(/[\p{Cf}\p{Cc}]/gu, "");
36
+ return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
37
+ }
38
+ /**
39
+ * The line for a call nothing better can be said about yet.
40
+ *
41
+ * Its own function because two other places have to RECOGNISE it: the store
42
+ * re-words a step that finished on this line if its arguments turn up late, and
43
+ * the strip holds it out of the live region rather than announcing
44
+ * "Working: broker_invoke" to a screen reader a moment before the real sentence
45
+ * replaces it.
46
+ */
47
+ function genericLabel(toolName) {
48
+ return `Working: ${truncate(toolName)}`;
49
+ }
50
+ /** Is this label the fallback rather than something the arguments produced? */
51
+ function isGenericLabel(toolName, label) {
52
+ return label === genericLabel(toolName);
53
+ }
54
+ function readString(args, key) {
55
+ const value = args?.[key];
56
+ return typeof value === "string" && value.trim() ? value : "";
57
+ }
58
+ /**
59
+ * The line shown for one tool call.
60
+ *
61
+ * | tool | label |
62
+ * | --- | --- |
63
+ * | `engram_search` | Looking for a way to \<intent\> |
64
+ * | `engram_describe`, `skill.*` | Reading skill: \<title, or the topic\> |
65
+ * | `engram_describe`, anything else | Checking how to \<id as words\> |
66
+ * | `broker_invoke`, `confirmed: true` | Doing: \<operation\> / Done: \<operation\> |
67
+ * | `broker_invoke`, otherwise | Fetching \<operation\> |
68
+ * | anything else | Working: \<tool name\> |
69
+ *
70
+ * The key argument missing falls through to the last row rather than inventing
71
+ * a sentence about nothing: arguments arrive in a second event
72
+ * (`TOOL_CALL_ARGS`), so "no arguments yet" is a state every call passes
73
+ * through, and the generic line is what it should read as until they land.
74
+ */
75
+ function stepLabel(toolName, args, phase) {
76
+ switch (toolName) {
77
+ case "engram_search": {
78
+ const intent = readString(args, "intent");
79
+ if (intent) return `Looking for a way to ${truncate(intent)}`;
80
+ break;
81
+ }
82
+ case "engram_describe": {
83
+ const id = readString(args, "id");
84
+ if (!id) break;
85
+ if (id.startsWith("skill.")) {
86
+ const title = readString(args, "title");
87
+ return `Reading skill: ${truncate(title ? title : skillTopic(id))}`;
88
+ }
89
+ return `Checking how to ${truncate(humaniseId(id))}`;
90
+ }
91
+ case "broker_invoke": {
92
+ const operation = readString(args, "operation");
93
+ if (!operation) break;
94
+ const label = truncate(humaniseId(operation));
95
+ if (args?.confirmed === true) return phase === "done" ? `Done: ${label}` : `Doing: ${label}`;
96
+ return `Fetching ${label}`;
97
+ }
98
+ }
99
+ return genericLabel(toolName);
100
+ }
101
+ /**
102
+ * The label for a call the broker refused pending a yes.
103
+ *
104
+ * A 409 `confirmation_required` is not a failure — it is the broker doing its
105
+ * job on a write the user has not approved — so the step keeps its tick and
106
+ * changes its words. Drawn as an error it reads as "the agent broke"; drawn as
107
+ * "Fetching …", completed, it reads as though the write went through.
108
+ */
109
+ function confirmationRequiredLabel(args) {
110
+ const operation = readString(args, "operation");
111
+ return `Needs your confirmation: ${operation ? truncate(humaniseId(operation)) : "that action"}`;
112
+ }
113
+ //#endregion
114
+ export { confirmationRequiredLabel, genericLabel, humaniseId, isGenericLabel, stepLabel, truncate };
115
+
116
+ //# sourceMappingURL=step-label.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"step-label.js","names":[],"sources":["../../src/agent-chat/step-label.ts"],"sourcesContent":["/**\n * The wording map: one plain sentence per tool call (SKILLS-9).\n *\n * A run is a sequence of tool calls, and what the user sees of it today is a\n * tool name and a JSON blob. `engram_describe {\"id\":\"tasks.task.list\"}` is an\n * accurate description of nothing anybody asked for. This module turns the\n * three native tools — `engram_search`, `engram_describe`, `broker_invoke` —\n * into sentences a person can read while they wait.\n *\n * It is a pure function of the tool name and the arguments, in its own module,\n * for two reasons. It is the part most likely to be edited by someone who is\n * not editing the store, and it is the part worth testing exhaustively: the\n * store's job is start/end pairing, and the wording's job is being readable.\n *\n * Deliberately NOT here: anything that needs the result. The client cannot know\n * whether a `broker_invoke` is a read or a write before the reply comes back —\n * only the catalogue knows, and the model only carries `confirmed` once the\n * user has said yes — so an unconfirmed invoke is worded \"Fetching …\", which is\n * true of a read and harmless of a write that has not happened yet. The one\n * result-dependent wording, a 409 `confirmation_required`, is\n * `confirmationRequiredLabel` and the store applies it when the envelope\n * arrives.\n */\n\n/**\n * Which end of the step the label is for.\n *\n * Nearly every sentence reads the same either way, because it describes the\n * step rather than claiming an outcome — \"Reading skill: alerts\" is as true of\n * a finished read as of one in flight, and rewriting it on completion makes the\n * strip jump under the reader's eye for no gain. The exception is the confirmed\n * write, where \"Doing: tasks task create\" and \"Done: tasks task create\" are\n * genuinely different facts and the user needs the second one.\n */\nexport type AgentStepPhase = \"running\" | \"done\";\n\n/** Longest intent or title carried into a label, ellipsis included. */\nconst TEXT_LIMIT = 80;\n\n/**\n * An id as words: `tasks.task.list` -> \"tasks task list\".\n *\n * Deterministic and dumb on purpose. A cleverer mapping — reordering to \"list\n * tasks\", pluralising, looking up a display name — needs a table that has to be\n * maintained for every capability in every extension, and a table that is one\n * release out of date reads worse than the id did.\n */\nexport function humaniseId(id: string): string {\n return id\n .split(/[.\\-_]+/)\n .filter(Boolean)\n .join(\" \")\n .toLowerCase();\n}\n\n/** The topic of a skill id: `skill.platform.using-extensions` -> \"using extensions\". */\nfunction skillTopic(id: string): string {\n const parts = id.split(\".\").filter(Boolean);\n const last = parts[parts.length - 1];\n return last ? humaniseId(last) : humaniseId(id);\n}\n\n/**\n * One line of it, sanitised and bounded, with the ellipsis inside the limit.\n *\n * Everything interpolated into a label goes through this, including the ones\n * that look safe: a tool name, an operation id, a server's `message`. All three\n * arrive from outside — a catalogue entry, a broker reply — and none is worth\n * trusting with the length of a line or with the direction of it. Control and\n * format characters are stripped BEFORE the length check, so a run of\n * invisibles cannot eat the budget and a U+202E cannot flip the rest of the\n * strip right-to-left. Newlines have already become spaces by then, so the\n * strip does not silently join two words.\n */\nexport function truncate(text: string, limit: number = TEXT_LIMIT): string {\n const collapsed = text\n .replace(/\\s+/g, \" \")\n .trim()\n .replace(/[\\p{Cf}\\p{Cc}]/gu, \"\");\n return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;\n}\n\n/**\n * The line for a call nothing better can be said about yet.\n *\n * Its own function because two other places have to RECOGNISE it: the store\n * re-words a step that finished on this line if its arguments turn up late, and\n * the strip holds it out of the live region rather than announcing\n * \"Working: broker_invoke\" to a screen reader a moment before the real sentence\n * replaces it.\n */\nexport function genericLabel(toolName: string): string {\n return `Working: ${truncate(toolName)}`;\n}\n\n/** Is this label the fallback rather than something the arguments produced? */\nexport function isGenericLabel(toolName: string, label: string): boolean {\n return label === genericLabel(toolName);\n}\n\nfunction readString(args: Record<string, unknown> | undefined, key: string): string {\n const value = args?.[key];\n return typeof value === \"string\" && value.trim() ? value : \"\";\n}\n\n/**\n * The line shown for one tool call.\n *\n * | tool | label |\n * | --- | --- |\n * | `engram_search` | Looking for a way to \\<intent\\> |\n * | `engram_describe`, `skill.*` | Reading skill: \\<title, or the topic\\> |\n * | `engram_describe`, anything else | Checking how to \\<id as words\\> |\n * | `broker_invoke`, `confirmed: true` | Doing: \\<operation\\> / Done: \\<operation\\> |\n * | `broker_invoke`, otherwise | Fetching \\<operation\\> |\n * | anything else | Working: \\<tool name\\> |\n *\n * The key argument missing falls through to the last row rather than inventing\n * a sentence about nothing: arguments arrive in a second event\n * (`TOOL_CALL_ARGS`), so \"no arguments yet\" is a state every call passes\n * through, and the generic line is what it should read as until they land.\n */\nexport function stepLabel(\n toolName: string,\n args: Record<string, unknown> | undefined,\n phase: AgentStepPhase,\n): string {\n switch (toolName) {\n case \"engram_search\": {\n const intent = readString(args, \"intent\");\n if (intent) {\n return `Looking for a way to ${truncate(intent)}`;\n }\n break;\n }\n case \"engram_describe\": {\n const id = readString(args, \"id\");\n if (!id) {\n break;\n }\n if (id.startsWith(\"skill.\")) {\n // Ingest carries the title on the row, not on the call, so it is only\n // here when the model has already seen the index and repeated it.\n const title = readString(args, \"title\");\n return `Reading skill: ${truncate(title ? title : skillTopic(id))}`;\n }\n return `Checking how to ${truncate(humaniseId(id))}`;\n }\n case \"broker_invoke\": {\n const operation = readString(args, \"operation\");\n if (!operation) {\n break;\n }\n const label = truncate(humaniseId(operation));\n if (args?.confirmed === true) {\n return phase === \"done\" ? `Done: ${label}` : `Doing: ${label}`;\n }\n return `Fetching ${label}`;\n }\n default:\n break;\n }\n return genericLabel(toolName);\n}\n\n/**\n * The label for a call the broker refused pending a yes.\n *\n * A 409 `confirmation_required` is not a failure — it is the broker doing its\n * job on a write the user has not approved — so the step keeps its tick and\n * changes its words. Drawn as an error it reads as \"the agent broke\"; drawn as\n * \"Fetching …\", completed, it reads as though the write went through.\n */\nexport function confirmationRequiredLabel(args: Record<string, unknown> | undefined): string {\n const operation = readString(args, \"operation\");\n return `Needs your confirmation: ${operation ? truncate(humaniseId(operation)) : \"that action\"}`;\n}\n"],"mappings":";;;AAqCA,MAAM,aAAa;;;;;;;;;AAUnB,SAAgB,WAAW,IAAoB;CAC7C,OAAO,GACJ,MAAM,SAAS,CAAC,CAChB,OAAO,OAAO,CAAC,CACf,KAAK,GAAG,CAAC,CACT,YAAY;AACjB;;AAGA,SAAS,WAAW,IAAoB;CACtC,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;CAC1C,MAAM,OAAO,MAAM,MAAM,SAAS;CAClC,OAAO,OAAO,WAAW,IAAI,IAAI,WAAW,EAAE;AAChD;;;;;;;;;;;;;AAcA,SAAgB,SAAS,MAAc,QAAgB,YAAoB;CACzE,MAAM,YAAY,KACf,QAAQ,QAAQ,GAAG,CAAC,CACpB,KAAK,CAAC,CACN,QAAQ,oBAAoB,EAAE;CACjC,OAAO,UAAU,UAAU,QAAQ,YAAY,GAAG,UAAU,MAAM,GAAG,QAAQ,CAAC,EAAE;AAClF;;;;;;;;;;AAWA,SAAgB,aAAa,UAA0B;CACrD,OAAO,YAAY,SAAS,QAAQ;AACtC;;AAGA,SAAgB,eAAe,UAAkB,OAAwB;CACvE,OAAO,UAAU,aAAa,QAAQ;AACxC;AAEA,SAAS,WAAW,MAA2C,KAAqB;CAClF,MAAM,QAAQ,OAAO;CACrB,OAAO,OAAO,UAAU,YAAY,MAAM,KAAK,IAAI,QAAQ;AAC7D;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,UACd,UACA,MACA,OACQ;CACR,QAAQ,UAAR;EACE,KAAK,iBAAiB;GACpB,MAAM,SAAS,WAAW,MAAM,QAAQ;GACxC,IAAI,QACF,OAAO,wBAAwB,SAAS,MAAM;GAEhD;EACF;EACA,KAAK,mBAAmB;GACtB,MAAM,KAAK,WAAW,MAAM,IAAI;GAChC,IAAI,CAAC,IACH;GAEF,IAAI,GAAG,WAAW,QAAQ,GAAG;IAG3B,MAAM,QAAQ,WAAW,MAAM,OAAO;IACtC,OAAO,kBAAkB,SAAS,QAAQ,QAAQ,WAAW,EAAE,CAAC;GAClE;GACA,OAAO,mBAAmB,SAAS,WAAW,EAAE,CAAC;EACnD;EACA,KAAK,iBAAiB;GACpB,MAAM,YAAY,WAAW,MAAM,WAAW;GAC9C,IAAI,CAAC,WACH;GAEF,MAAM,QAAQ,SAAS,WAAW,SAAS,CAAC;GAC5C,IAAI,MAAM,cAAc,MACtB,OAAO,UAAU,SAAS,SAAS,UAAU,UAAU;GAEzD,OAAO,YAAY;EACrB;CAGF;CACA,OAAO,aAAa,QAAQ;AAC9B;;;;;;;;;AAUA,SAAgB,0BAA0B,MAAmD;CAC3F,MAAM,YAAY,WAAW,MAAM,WAAW;CAC9C,OAAO,4BAA4B,YAAY,SAAS,WAAW,SAAS,CAAC,IAAI;AACnF"}