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
91 changes: 91 additions & 0 deletions .llm/runs/docs-661b-chat--opus/worklog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Worklog — docs-661b-chat (Opus 4.8, beta-8 orchestrator)

Issue #661: CHAT tutorial series quality + durable-chat callouts.
Branch `docs/661-chat-quality`, worktree `/home/codex/repos/ns-b8-661b`.

## Preflight (passed)

- `git rev-parse HEAD` = `955b4abf639522c7da50bd15d20c6e999acb808f` (starts `955b4abf`). OK.
- `docs/site/tutorials/chat/05-mcp.md` exists. OK.

## API grounding (never invent APIs)

`deno doc packages/fresh/src/application/route/mod.ts`:

- `createRouteReference(pattern)` → `RouteReference`, doc example
`createRouteReference("/orders/[id]").href({ path: { id: "42" } })`.
- `RouteReference` exposes `href(...)`, `parsePath(input): TPath`, `safeParsePath`,
`parseSearch`, `getLinkProps`, `Link`, `withPartial`.

Scratch type-check of the exact new example (`createRouteReference('/api/chat/[sessionId]')`
+ `.href({ path: { sessionId } })` + `.parsePath(params)`): types resolve; the only diagnostic
is `isolatedDeclarations` (a library-publish constraint that does NOT apply to scaffolded app
`contracts/` files). Sound for the doc's app-code context. Scratch removed.

## Slices

### Proposal #4 — one-line honesty callout (3 files, identical wording)

Placed immediately after the `target: (req) => ...` stream-proxy block in:
`tutorials/chat/02-durable-chat-route.md`, `how-to/build-a-durable-chat.md`, `ai/durable-chat.md`.
Identical `note` callout: the proxy `target` resolver only ever receives the raw `Request`, so
the session id is parsed from `req.url` by hand; this is the one documented exception — elsewhere
prefer a bound `createRouteReference` contract. Converts a silent look-alike anti-pattern into a
documented exception (source-verified in the audit: `stream-proxy.ts` target resolver receives a
bare `Request`, not `ctx.params`).

- code:prose: +3 lines prose each, no code change. Balance stays good.

### Proposal #5 + matrix gap (chat/02 typed `[sessionId]`; chat/03 & 06 bound URL)

- **chat/02**: introduced the small route-contract file `contracts/routes/chat-turn.ts`
(`createRouteReference('/api/chat/[sessionId]')`) the way the workspace/05 exemplar introduces
its bound contract. The session route now imports it and reads its param via
`chatTurnRoute.parsePath(ctx.params)` instead of `ctx.params.sessionId`. Closes the matrix gap
"dynamic `[sessionId]` route never shown typed."
- before: `const target = { sessionId: ctx.params.sessionId } as const;`
- after: `const { sessionId } = chatTurnRoute.parsePath(ctx.params); const target = { sessionId } as const;`
- **chat/03**: POST URL now `chatTurnRoute.href({ path: { sessionId } })` (was template
`` `/api/chat/${sessionId}` ``); imports the shared contract.
- **chat/06**: identical `.href(...)` swap + contract import.
- code:prose: URL construction now flows from the one contract in all three chapters; the server
param read and the client URL can no longer drift.

### Matrix gap — chat/03 island-query (not forced)

Added a `note` callout "Why not useIslandQuery for the transcript?" — honestly scopes when
`useIslandQuery`/`useLiveQuery` apply (typed service-contract reads, per live-dashboard/04) vs why
the chat transcript stays on `createNetScriptChatConnection` (durable session stream, one
projection). Points readers to where an island query WOULD belong (e.g. a typed past-conversations
sidebar) without fabricating a service the chat app does not have. Honors "do not force it."

### Proposal #17 — chat index lede

`tutorials/chat/index.md`: added an explicit differentiator + contrast sentence to the lede
(mirrors live-dashboard/index.md's move): most chat UIs keep the conversation in component state
and lose it on refresh/dropped socket/second tab; here the transcript lives in the durable session
and the UI is only a view, so the same log replays identically everywhere.

### Medusa rebalance (scope item 5)

No action: the audit matrix marks all touched chat chapters **good** on code:prose balance
(chapters are 150–210 lines with complete files). The audit's code:prose "thin" cells are elsewhere
(`web-layer/query.md`, etc.), out of this brief's scope. Did not rewrite good chapters.

## Validation (evidence)

- `deno task verify` (docs/site) → EXIT 0. Build: 512 files / 15.3s.
`check:links`: **23454 internal links across 169 pages — all resolve** (≥169 met).
`check:caveats`: 27 caveat markers across 22 pages — all resolve.
- Public-docs grep gate on the 6 touched files
(`eis-chat|eischat|VIF|CSB|PR #|pull/[0-9]|dogfood|issues/[0-9]`): **0 hits**.

## Touched files

- docs/site/tutorials/chat/02-durable-chat-route.md
- docs/site/tutorials/chat/03-chat-ui.md
- docs/site/tutorials/chat/06-live-streaming.md
- docs/site/tutorials/chat/index.md
- docs/site/how-to/build-a-durable-chat.md
- docs/site/ai/durable-chat.md
- .llm/runs/docs-661b-chat--opus/worklog.md (this file)
4 changes: 4 additions & 0 deletions docs/site/ai/durable-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ const proxy = createNetScriptChatStreamProxy({
export const handler = { POST: proxy, GET: proxy };
```

{{ comp callout { type: "note", title: "Why a raw Request here, not a route contract" } }}
This resolver is the one documented exception to NetScript's typed-route rule: <code>createNetScriptChatStreamProxy</code>'s <code>target</code> only ever receives the raw <code>Request</code>, so the session id is parsed from <code>req.url</code> by hand. Everywhere else you build a URL or read a path param, prefer a bound <a href="/reference/fresh/"><code>createRouteReference</code></a> contract so the pattern and its typed params come from one source of truth.
{{ /comp }}

`NetScriptChatStreamProxyOptions` is small: a `target` (a `NetScriptChatSessionTarget`
or a `(request) => NetScriptChatSessionTarget` resolver), an optional `auth` header
provider (defaults to `getStreamsAuth` from `@netscript/plugin-streams-core`), and an
Expand Down
4 changes: 4 additions & 0 deletions docs/site/how-to/build-a-durable-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ const proxy = createNetScriptChatStreamProxy({
export const handler = { GET: proxy, POST: proxy };
```

{{ comp callout { type: "note", title: "Why a raw Request here, not a route contract" } }}
This resolver is the one documented exception to NetScript's typed-route rule: <code>createNetScriptChatStreamProxy</code>'s <code>target</code> only ever receives the raw <code>Request</code>, so the session id is parsed from <code>req.url</code> by hand. Everywhere else you build a URL or read a path param, prefer a bound <a href="/reference/fresh/"><code>createRouteReference</code></a> contract so the pattern and its typed params come from one source of truth.
{{ /comp }}

The proxy passes the durable-stream body through **unbuffered**, strips
`content-encoding` / `content-length` (they no longer describe the re-framed bytes) plus
the hop-by-hop headers, and propagates the client `AbortSignal` so a disconnect tears the
Expand Down
27 changes: 26 additions & 1 deletion docs/site/tutorials/chat/02-durable-chat-route.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,30 @@ This route runs one turn and persists it. It pulls the transcript so far through
`resolveChatSnapshot` (so the model has context), calls the model, and hands the stream to
`toNetScriptChatResponse` — gated by an `authorize` hook.

First give that dynamic route a **typed identity**. `createRouteReference` from
`@netscript/fresh/route` infers the `{ sessionId }` path param straight from the `[sessionId]`
pattern, so the id the server reads and the URL the browser posts to in chapter 3 both come from
one declaration — never a string assembled two different ways. Put it in the shared `contracts/`
tree so the route and the island import the *same* object:

```ts
// contracts/routes/chat-turn.ts
import { createRouteReference } from '@netscript/fresh/route';

/** The one place the chat turn route pattern is written. */
export const chatTurnRoute = createRouteReference('/api/chat/[sessionId]');
// chatTurnRoute.parsePath({ sessionId }) -> typed { sessionId: string }
// chatTurnRoute.href({ path: { sessionId } }) -> "/api/chat/<id>" (the island posts here in ch. 3)
```

Now the route reads its param **through the contract** instead of touching `ctx.params` by hand:

```ts
// apps/dashboard/routes/api/chat/[sessionId].ts
import { chat } from '@tanstack/ai';
import { anthropicText } from '@tanstack/ai-anthropic';
import { resolveChatSnapshot, toNetScriptChatResponse } from '@netscript/fresh/ai';
import { chatTurnRoute } from '../../../../contracts/routes/chat-turn.ts';

// REQUIRED in production — no default allow-all. Replace with your real check
// (session cookie → owner lookup). Returning false denies the turn with a 403.
Expand All @@ -80,7 +99,9 @@ const authorize = (request: Request, sessionId: string): boolean =>

export const handler = {
async POST(ctx: { req: Request; params: { sessionId: string } }): Promise<Response> {
const target = { sessionId: ctx.params.sessionId } as const;
// Typed off the one route contract — the same object the island builds its URL from.
const { sessionId } = chatTurnRoute.parsePath(ctx.params);
const target = { sessionId } as const;

// History so far, reduced through the SAME projection the island seeds from.
const snapshot = await resolveChatSnapshot({ target });
Expand Down Expand Up @@ -124,6 +145,10 @@ const proxy = createNetScriptChatStreamProxy({
export const handler = { GET: proxy, POST: proxy };
```

{{ comp callout { type: "note", title: "Why a raw Request here, not a route contract" } }}
This resolver is the one documented exception to NetScript's typed-route rule: <code>createNetScriptChatStreamProxy</code>'s <code>target</code> only ever receives the raw <code>Request</code>, so the session id is parsed from <code>req.url</code> by hand. Everywhere else you build a URL or read a path param, prefer a bound <a href="/reference/fresh/"><code>createRouteReference</code></a> contract so the pattern and its typed params come from one source of truth.
{{ /comp }}

{{ comp callout { type: "note", title: "Why a proxy at all?" } }}
The proxy exists so the browser gets a durable stream <em>without</em> streams credentials. It attaches the <code>Authorization</code> header only on the server → streams hop (never echoed back), passes the body through unbuffered, and strips <code>content-encoding</code> / <code>content-length</code> — which stop describing the bytes once the stream is re-framed across the proxy. A client disconnect aborts the upstream fetch, so no stream is left dangling.
{{ /comp }}
Expand Down
8 changes: 7 additions & 1 deletion docs/site/tutorials/chat/03-chat-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ connection pointed at the proxy from chapter 2, and on submit: appends the user
import { useSignal } from '@preact/signals';
import { createNetScriptChatConnection, resolveChatSnapshot } from '@netscript/fresh/ai';
import type { NetScriptChatMessage, NetScriptChatSnapshot } from '@netscript/fresh/ai';
import { chatTurnRoute } from '@app/contracts/routes/chat-turn.ts';
import { Message, type MessageData } from '@app/components/ui/message.tsx';
import { PromptInput } from '@app/components/ui/prompt-input.tsx';

Expand Down Expand Up @@ -115,7 +116,8 @@ const Chat = ({ sessionId, seed }: ChatProps) => {
// 1. Append the user message to the durable session.
await connection.send([{ id: crypto.randomUUID(), role: 'user', content: text }]);
// 2. Fire the model turn (chapter 2's route streams + persists the reply).
await fetch(`/api/chat/${sessionId}`, { method: 'POST' });
// The URL comes from the same route contract the server reads its param through.
await fetch(chatTurnRoute.href({ path: { sessionId } }), { method: 'POST' });
// 3. Re-materialize the settled transcript through the same projection.
snapshot.value = await resolveChatSnapshot({ target: { sessionId, baseUrl: base } });
pending.value = false;
Expand Down Expand Up @@ -148,6 +150,10 @@ connection's `close` / `stop` / `dispose` are one idempotent teardown — call
This island re-materializes each turn once it <strong>settles</strong>, which keeps the code mechanical and correct — and reload durability, the headline feature, is fully in hand this way. The connection also exposes <code>subscribe(signal)</code> for token-by-token live chunks; <a href="/tutorials/chat/06-live-streaming/">chapter 6</a> upgrades this island to that live subscription so replies stream in as they arrive.
{{ /comp }}

{{ comp callout { type: "note", title: "Why not useIslandQuery for the transcript?" } }}
NetScript's typed island-query hooks (<code>useIslandQuery</code> / <code>useLiveQuery</code>) are the right tool when a page reads a <strong>typed service contract</strong> — a cache-first read whose data lives behind an oRPC service, as the <a href="/tutorials/live-dashboard/04-definePage-QueryIsland/">live-dashboard track</a> shows end to end. The chat transcript is different: it is not a service query but a <strong>durable session stream</strong>, so it is read through <code>createNetScriptChatConnection</code> and reduced by the one projection, not fetched through a query key. If <em>this</em> page also showed, say, a typed list of past conversations from a service, that sidebar is exactly where a <code>useIslandQuery</code> island would belong — the transcript itself stays on the durable connection.
{{ /comp }}

## Step 4 — Rich blocks with chat-render

Assistant replies are markdown, and they can embed fenced data blocks (`chart`, `donut`,
Expand Down
3 changes: 2 additions & 1 deletion docs/site/tutorials/chat/06-live-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks';
import { createNetScriptChatConnection, resolveChatSnapshot } from '@netscript/fresh/ai';
import type { NetScriptChatSnapshot } from '@netscript/fresh/ai';
import { chatTurnRoute } from '@app/contracts/routes/chat-turn.ts';

const Chat = ({ sessionId, seed }: { sessionId: string; seed: NetScriptChatSnapshot }) => {
const snapshot = useSignal(seed);
Expand Down Expand Up @@ -93,7 +94,7 @@ const Chat = ({ sessionId, seed }: { sessionId: string; seed: NetScriptChatSnaps
await connection.send([{ id: crypto.randomUUID(), role: 'user', content: text }]);
// 2. Fire the model turn. Its chunks now arrive through the subscription above —
// no manual re-materialize here; the live loop owns rendering.
await fetch(`/api/chat/${sessionId}`, { method: 'POST' });
await fetch(chatTurnRoute.href({ path: { sessionId } }), { method: 'POST' });
};

return (
Expand Down
6 changes: 5 additions & 1 deletion docs/site/tutorials/chat/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ support-chat surface, the kind of assistant a real product ships next to its doc
last chapter you will have a chat whose transcript — messages, streaming markdown, tool-call
cards, and MCP widgets — survives reload, reconnect, and a second tab, and whose replies
stream in live as the model produces them, because it is backed by a durable session stream
rather than component state. It runs on shipped NetScript seams: the
rather than component state. That is the differentiator this track proves: most chat UIs keep
the conversation in component state, so a refresh, a dropped socket, or a second tab loses it —
here the transcript lives in the durable session and the UI is only a view of it, so the same
log replays identically on reload, reconnect, and every other tab watching. It runs on shipped
NetScript seams: the
[`@netscript/fresh/ai`](/reference/fresh/) durable-chat plane (published on JSR in
`@netscript/fresh` and usable now, including the `ai/sandbox` MCP-UI subpath), the
[`@netscript/fresh-ui`](/reference/fresh-ui/) copy-registry chat components, and the
Expand Down
Loading