Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## Unreleased

### Added

- Export renderer-neutral `groupConversationMessages` from the existing `/web`
foundation. Repeated assistant updates share attribution until a real user,
speaker, or conversation boundary; original messages remain intact.
- Add attribution tests and usage/accessibility guidance for framework-neutral
and React consumers. No new rendering framework or execution state is added.

### Internal

- Move the existing web utilities unchanged into `web/core.ts` behind the same
public barrel. All previous exports and optional-peer boundaries are preserved.

No release version is assigned here; publishing remains owned by the existing
repository release workflow.
6 changes: 3 additions & 3 deletions docs/CODEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ _99 entries — tsup.config `entry`. Regenerate with `agent-docs`._
| [`./vault`](api/vault.md) | 17 | — |
| [`./vault/lazy`](api/vault-lazy.md) | 2 | — |
| [`./vault/server`](api/vault-server.md) | 8 | — |
| [`./web`](api/web.md) | 33 | — |
| [`./web`](api/web.md) | 36 | — |
| [`./web-react`](api/web-react.md) | 427 | `brand`, `chat-routes`, `chat-store`, `harness`, `interactions`, `missions`, `plans`, `platform`, `runtime`, `session-shell`, `trace`, `work-product` |
| [`./web-react/async`](api/web-react-async.md) | 35 | — |
| [`./web-react/session-gateway`](api/web-react-session-gateway.md) | 22 | `brand`, `chat-routes`, `chat-store`, `harness`, `interactions`, `missions`, `plans`, `platform`, `runtime`, `session-shell`, `trace`, `work-product` |
Expand Down Expand Up @@ -924,9 +924,9 @@ Source: `src/vault/server.ts` · 8 exports

## `./web`

Source: `src/web/index.ts` · 33 exports
Source: `src/web/index.ts` · 36 exports

`addSecurityHeaders`, `assertMediaUrl`, `checkFreeRouteLimit`, `checkRateLimit`, `clearCookieHeader`, `CookieOptions`, `extractRequestContext`, `FREE_ROUTE_BUDGETS`, `FreeRouteAllowance`, `FreeRouteClass`, `FreeRouteDenialReason`, `FreeRouteDimension`, `FreeRouteIdentity`, `FreeRouteLimitError`, `FreeRouteLimitInput`, `FreeRouteLimitOutcome`, `freeRouteLimitResponse`, `FreeRouteLimitResponseOptions`, `isWorkspaceFileExportable`, `JsonObject`, `KvLike`, `parseJsonObjectBody`, `RateLimitBudget`, `RateLimitResult`, `readCookieValue`, `RequestContext`, `requireString`, `SecurityHeaderOptions`, `serializeCookie`, `STANDARD_SECURITY_HEADERS`, `withFreeRouteLimit`, `WithFreeRouteLimitOptions`, `WORKSPACE_BUDGET_MULTIPLIER`
`addSecurityHeaders`, `assertMediaUrl`, `checkFreeRouteLimit`, `checkRateLimit`, `clearCookieHeader`, `ConversationGroupItem`, `CookieOptions`, `extractRequestContext`, `FREE_ROUTE_BUDGETS`, `FreeRouteAllowance`, `FreeRouteClass`, `FreeRouteDenialReason`, `FreeRouteDimension`, `FreeRouteIdentity`, `FreeRouteLimitError`, `FreeRouteLimitInput`, `FreeRouteLimitOutcome`, `freeRouteLimitResponse`, `FreeRouteLimitResponseOptions`, `groupConversationMessages`, `GroupedConversationItem`, `isWorkspaceFileExportable`, `JsonObject`, `KvLike`, `parseJsonObjectBody`, `RateLimitBudget`, `RateLimitResult`, `readCookieValue`, `RequestContext`, `requireString`, `SecurityHeaderOptions`, `serializeCookie`, `STANDARD_SECURITY_HEADERS`, `withFreeRouteLimit`, `WithFreeRouteLimitOptions`, `WORKSPACE_BUDGET_MULTIPLIER`

[Full API →](api/web.md)

Expand Down
26 changes: 25 additions & 1 deletion docs/api/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Source: `src/web/index.ts`

33 exports.
36 exports.

### `addSecurityHeaders`

Expand Down Expand Up @@ -46,6 +46,14 @@ Source: `src/web/index.ts`
(opts: Omit<CookieOptions, "maxAgeSeconds">) => string
```

### `ConversationGroupItem`

`interface` — Renderer-neutral attribution.

```ts
interface ConversationGroupItem
```

### `CookieOptions`

`interface` — Define options for configuring cookie attributes and behavior
Expand Down Expand Up @@ -150,6 +158,22 @@ type FreeRouteLimitOutcome
interface FreeRouteLimitResponseOptions
```

### `groupConversationMessages`

`function` — Annotate assistant rows until an actual user message, speaker or conversation change.

```ts
<T extends ConversationGroupItem>(items?: readonly T[]) => GroupedConversationItem<T>[]
```

### `GroupedConversationItem`

`type`

```ts
type GroupedConversationItem
```

### `isWorkspaceFileExportable`

`function` — Workspace exports exclude runtime configuration and hidden credential stores.
Expand Down
18 changes: 18 additions & 0 deletions docs/codemap.json
Original file line number Diff line number Diff line change
Expand Up @@ -17604,6 +17604,12 @@
"signature": "(opts: Omit<CookieOptions, \"maxAgeSeconds\">) => string",
"doc": "Set-Cookie header value that deletes the cookie (empty value, Max-Age=0)."
},
{
"name": "ConversationGroupItem",
"kind": "interface",
"signature": "interface ConversationGroupItem",
"doc": "Renderer-neutral attribution."
},
{
"name": "CookieOptions",
"kind": "interface",
Expand Down Expand Up @@ -17682,6 +17688,18 @@
"signature": "interface FreeRouteLimitResponseOptions",
"doc": null
},
{
"name": "groupConversationMessages",
"kind": "function",
"signature": "<T extends ConversationGroupItem>(items?: readonly T[]) => GroupedConversationItem<T>[]",
"doc": "Annotate assistant rows until an actual user message, speaker or conversation change."
},
{
"name": "GroupedConversationItem",
"kind": "type",
"signature": "type GroupedConversationItem",
"doc": null
},
{
"name": "isWorkspaceFileExportable",
"kind": "function",
Expand Down
49 changes: 49 additions & 0 deletions docs/conversation-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Conversation attribution without transcript merging

`groupConversationMessages` is available from the existing browser-safe
`@tangle-network/agent-app/web` entrypoint. It is a pure L0 presentation helper:
no React, DOM, database, model loop, or provider imports.

```ts
import { groupConversationMessages } from '@tangle-network/agent-app/web'

const rows = groupConversationMessages(messagesInDisplayOrder)
for (const row of rows) {
// Keep accessible attribution on every message, even when its visual label is hidden.
renderMessage(row, { showSpeaker: !row.isContinuation })
}
```

Each assistant turn receives a group ID. Consecutive assistant updates, streaming
rows and `kind: 'thinking'` rows continue that group. Ordinary notices, tool rows
and progress do not create another assistant attribution. A real user message
resets it. Different `speakerId` or `conversationId` values start a new group;
provide these identities when rendering multiple agents or threads. Missing IDs
receive a display-only fallback, not a durable identity.

The helper annotates rows in display order. It does not combine content, move
messages, create timestamps, mutate inputs, change roles, or affect scheduling.
The first visible assistant row is always labeled, even if history pagination
removed the start of its turn. Run IDs are not speaker identities: a resumed run
alone does not require another label. Renderers retain original IDs and maintain
their own scroll, focus, accessibility, and streaming state.

SUPER is the reference consumer: both its operator and public chat use the same
attribution behavior. This extraction is not a claim that SUPER's custom DOM
renderer was replaced with the maintained React surface. Layout and product copy
remain application code.

The existing `/web` implementation is moved byte-for-byte to `core.ts` and
re-exported by `index.ts`; existing exports remain available. No package peer,
new public subpath, build entry, or dependency version is introduced.

Run `pnpm test tests/web/message-groups.test.ts`, the browser-safe entrypoint
checks, the complete build/typecheck, and `pnpm signoff --source head` before
merge. Authoring verification ran nine identical assertion bodies via Node's
runner, plus a scoped TypeScript 5.8.3 build. A deliberately broken grouping
implementation made the tests fail; the source was restored and the tests passed.
The full package Vitest/build/signoff and rendered component qualification are
separate gates, not inferred from these pure-function checks.

See CHANGELOG.md for unreleased notes. The existing release workflow owns version
selection; no unpublished version is guessed by this change.
24 changes: 24 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22763,6 +22763,14 @@ Source: `src/web/index.ts`
(opts: Omit<CookieOptions, "maxAgeSeconds">) => string
```

### `ConversationGroupItem`

`interface` — Renderer-neutral attribution.

```ts
interface ConversationGroupItem
```

### `CookieOptions`

`interface` — Define options for configuring cookie attributes and behavior
Expand Down Expand Up @@ -22867,6 +22875,22 @@ type FreeRouteLimitOutcome
interface FreeRouteLimitResponseOptions
```

### `groupConversationMessages`

`function` — Annotate assistant rows until an actual user message, speaker or conversation change.

```ts
<T extends ConversationGroupItem>(items?: readonly T[]) => GroupedConversationItem<T>[]
```

### `GroupedConversationItem`

`type`

```ts
type GroupedConversationItem
```

### `isWorkspaceFileExportable`

`function` — Workspace exports exclude runtime configuration and hidden credential stores.
Expand Down
2 changes: 1 addition & 1 deletion docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ _Generated by agent-docs from tsup.config `entry`; 99 entries. Regenerate with `
- [`./vault`](api/vault.md): 17 exports — ConfirmDialog, ConfirmDialogProps, VaultArtifactRenderProps, VaultDataPort, VaultDockRenderProps, VaultDockToggle, VaultEditorMode, VaultFile, …
- [`./vault/lazy`](api/vault-lazy.md): 2 exports — VaultPaneLazy, VaultPaneProps
- [`./vault/server`](api/vault-server.md): 8 exports — assessVaultDeletionBatch, compareIncarnationBaseline, FilesystemIncarnationLike, IncarnationComparison, VAULT_DELETION_REFUSAL_MIN_LIVE_FILES, VAULT_DELETION_REFUSAL_RATIO, VaultDeletionAssessment, VaultDeletionPolicy
- [`./web`](api/web.md): 33 exports — addSecurityHeaders, assertMediaUrl, checkFreeRouteLimit, checkRateLimit, clearCookieHeader, CookieOptions, extractRequestContext, FREE_ROUTE_BUDGETS, …
- [`./web`](api/web.md): 36 exports — addSecurityHeaders, assertMediaUrl, checkFreeRouteLimit, checkRateLimit, clearCookieHeader, ConversationGroupItem, CookieOptions, extractRequestContext, …
- [`./web-react`](api/web-react.md): 427 exports — acceptRejectionReason, activityTone, ActivityTone, AgentActivityPage, AgentActivityPanel, AgentActivityPanelProps, AgentActivityRecord, AgentSessionControls, …
- [`./web-react/async`](api/web-react-async.md): 35 exports — AsyncEmptyAction, AsyncEmptySpec, asyncErrorMessage, AsyncErrorRenderProps, AsyncLoadContext, AsyncRequestError, AsyncResolution, AsyncResourceState, …
- [`./web-react/session-gateway`](api/web-react-session-gateway.md): 22 exports — APPLIED_SEQ_CAP, createSessionGatewayLane, createSessionStreamGrantFetcher, GATEWAY_TERMINAL_EVENT_TYPES, GATEWAY_TRANSPORT_NOTICE_TYPES, gatewayFrameToTurnEvent, GatewayTurnEvent, isGatewayTransportNotice, …
Expand Down
182 changes: 182 additions & 0 deletions src/web/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Web-boundary utilities every agent app's routes hand-roll: JSON body parsing
* + narrowing, request-context extraction (real client IP behind Cloudflare),
* a KV-backed sliding-window rate limiter, the free-route budget policy built
* on it, and security response headers. Pure mechanism — no DB, no domain. The
* KV is a structural interface so this needs no `@cloudflare/workers-types`
* dependency.
*/

export * from './rate-limit'
export * from './free-route-limit'

export type JsonObject = Record<string, unknown>

/** Parse + object-narrow a Request body. `[body, null]` on success, `[null,
* errorResponse]` on a non-object body (callers `if (err) return err`). */
export async function parseJsonObjectBody(request: Request): Promise<[JsonObject, null] | [null, Response]> {
let raw: unknown
try {
raw = await request.json()
} catch {
return [null, Response.json({ error: 'Invalid JSON body' }, { status: 400 })]
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return [null, Response.json({ error: 'Body must be a JSON object' }, { status: 400 })]
}
return [raw as JsonObject, null]
}

/** Narrow one required string field, 400 if missing/empty. */
export function requireString(body: JsonObject, field: string): string | Response {
const v = body[field]
if (typeof v !== 'string' || v.length === 0) {
return Response.json({ error: `Missing or non-string field: ${field}` }, { status: 400 })
}
return v
}

/** Define the context of a request including IP address, user agent, timestamp, and request ID */
export interface RequestContext {
ipAddress: string
userAgent: string
timestamp: string
requestId: string
}

/** Extract request context for audit trails. Uses `CF-Connecting-IP` for the
* real client IP behind Cloudflare. */
export function extractRequestContext(request: Request): RequestContext {
const ipAddress =
request.headers.get('CF-Connecting-IP') ??
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ??
'0.0.0.0'
return {
ipAddress,
userAgent: request.headers.get('User-Agent') ?? '',
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
}
}

/** Define options for configuring cookie attributes and behavior */
export interface CookieOptions {
name: string
/** Default '/'. */
path?: string
/** Default true. */
httpOnly?: boolean
/** Adds the `Secure` attribute. Default false. */
secure?: boolean
/** Default 'Lax'. */
sameSite?: 'Lax' | 'Strict' | 'None'
maxAgeSeconds?: number
}

/** Serialize a Set-Cookie header value: `name=encodeURIComponent(value)` plus
* attributes in Path / HttpOnly / SameSite / Max-Age / Secure order.
* Throws on `SameSite=None` without `secure` — browsers silently drop that
* combination, which would otherwise fail invisibly. */
export function serializeCookie(value: string, opts: CookieOptions): string {
if (opts.sameSite === 'None' && !opts.secure) {
throw new Error('SameSite=None cookies require secure: true (browsers reject them otherwise)')
}
const parts = [`${opts.name}=${encodeURIComponent(value)}`, `Path=${opts.path ?? '/'}`]
if (opts.httpOnly !== false) parts.push('HttpOnly')
parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`)
if (opts.maxAgeSeconds !== undefined) parts.push(`Max-Age=${opts.maxAgeSeconds}`)
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}

/** Set-Cookie header value that deletes the cookie (empty value, Max-Age=0). */
export function clearCookieHeader(opts: Omit<CookieOptions, 'maxAgeSeconds'>): string {
return serializeCookie('', { ...opts, maxAgeSeconds: 0 })
}

/** Read + decode one cookie from a Cookie request header; null when absent. */
export function readCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null
for (const part of cookieHeader.split(/;\s*/)) {
const [cookieName, ...rest] = part.split('=')
if (cookieName === name) {
try {
return decodeURIComponent(rest.join('='))
} catch {
return null
}
}
}
return null
}

/** Define options for configuring security-related HTTP headers including disclaimers and retention labels */
export interface SecurityHeaderOptions {
/** Product disclaimer (e.g. "AI-powered tool. Not legal advice."). Omitted if absent. */
disclaimer?: string
/** Data-retention label (e.g. "7-years"). Omitted if absent. */
retention?: string
/** Extra headers to set. */
extra?: Record<string, string>
}

/** Canonical generic response headers used by {@link addSecurityHeaders}.
* Exported so static-asset hosts can apply the same policy without copying
* values that silently drift from Worker/API responses. */
export const STANDARD_SECURITY_HEADERS = Object.freeze({
'Strict-Transport-Security':
'max-age=31536000; includeSubDomains; preload',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'same-origin',
'X-XSS-Protection': '1; mode=block',
} as const)

/** Set standard security headers on a response (HSTS, nosniff, frame-options,
* referrer-policy, XSS) + optional product disclaimer/retention. The security
* set is generic; the disclaimer/retention are the product's. */
export function addSecurityHeaders(response: Response, opts: SecurityHeaderOptions = {}): Response {
for (const [name, value] of Object.entries(STANDARD_SECURITY_HEADERS)) {
response.headers.set(name, value)
}
if (opts.disclaimer) response.headers.set('X-AI-Disclaimer', opts.disclaimer)
if (opts.retention) response.headers.set('X-Data-Retention', opts.retention)
for (const [k, v] of Object.entries(opts.extra ?? {})) response.headers.set(k, v)
return response
}

/** Local-sandbox / inline schemes a stored media reference must never use.
* Reachable from neither a browser nor the product worker, and a `file:`/`data:`
* url is the tell of an agent substituting local ffmpeg output for a real
* provider artifact. `blob:` and `javascript:` are inert/active client schemes
* with no server reachability. */
const REJECTED_MEDIA_SCHEMES = ['file:', 'data:', 'blob:', 'javascript:', 'vbscript:'] as const

/**
* Canonical media-reference boundary shared by every surface that persists a
* media url (sequences clips, design-canvas image/video src). The ONE rule:
* remote `http(s)` or a rooted `/api/` path are allowed; everything else is
* rejected, with a named reason for known-bad local/inline schemes so the
* thrown message is actionable for an LLM planner. The url is trimmed before
* the scheme check so leading whitespace cannot smuggle a rejected scheme past
* a naive `startsWith`.
*
* @param what - noun for the error message (e.g. 'media url', 'src').
*/
export function assertMediaUrl(url: string, what = 'media url'): void {
const trimmed = url.trim()
if (/^https?:\/\//i.test(trimmed)) return
if (trimmed.startsWith('/api/')) return
const shown = trimmed.length > 96 ? `${trimmed.slice(0, 96)}…` : trimmed
const lower = trimmed.toLowerCase()
if (
REJECTED_MEDIA_SCHEMES.some((scheme) => lower.startsWith(scheme)) ||
lower.startsWith('/tmp/') ||
lower.startsWith('/home/')
) {
throw new Error(`${what} must reference a provider http(s) URL or a rooted /api/ path, not a local sandbox file (${shown})`)
}
throw new Error(`${what} must be http(s) or a rooted /api/ path (${shown})`)
}

export { isWorkspaceFileExportable } from './file-export'
Loading