diff --git a/README.md b/README.md index d60df345d..1425b060b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@

Threadplane — agent UI primitives for Angular

- Threadplane — Production-ready chat, threads, and generative UI for AI agents. + The AI agent UI framework for Angular.

- - npm version + + npm version Angular 20 | 21 | 22 @@ -33,106 +33,144 @@ --- -Threadplane is a production-ready agent UI framework for Angular. `@threadplane/chat` provides chat surfaces (headless primitives, opinionated compositions, interrupts, generative UI). `@threadplane/langgraph` adapts a LangGraph Platform endpoint into Angular Signals via `provideAgent()` + `injectAgent()`. `@threadplane/ag-ui` bridges any AG-UI-compatible backend into the same chat surface. `@threadplane/render` renders JSON specs to Angular components inside your design system. +**Threadplane is the open-source Angular AI agent UI framework.** Chat, durable +threads, human approvals, tool progress, subagents, and generative UI — built on +Angular Signals and dependency injection, for Angular 20–22. Your backend stays +where it is: Threadplane adapts a LangGraph or AG-UI agent into a runtime-neutral +`Agent` contract that the UI consumes, and renders generated UI with the design +system components you already own. -`injectAgent()` is the Angular equivalent of LangGraph's React `useStream()` hook, projected through a runtime-neutral `Agent` contract that `@threadplane/chat` consumes. Configure it once with `provideAgent({...})`, inject it into any Angular 20–22 component, and get signal-driven access to messages, status, tool calls, interrupts, subagents, history, and thread management — no subscriptions, no `async` pipe, no zone.js required. +`MIT · Angular 20–22 · no account, no cloud` --- ## Install ```bash -npm install @threadplane/langgraph @threadplane/chat +npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked ``` -**Peer dependencies:** `@angular/core ^20.0.0 || ^21.0.0 || ^22.0.0`, `@langchain/core ^1.1.33`, `@langchain/langgraph-sdk ^1.7.4`, `rxjs ~7.8.0` +Talking to an AG-UI endpoint instead: + +```bash +npm install @threadplane/chat @threadplane/ag-ui @ag-ui/client @ag-ui/core marked +``` + +**Peer dependencies:** + +``` +@angular/core ^20.0.0 || ^21.0.0 || ^22.0.0 # every Angular package here +marked ^15.0.0 || ^16.0.0 # @threadplane/chat +rxjs ~7.8.0 # @threadplane/chat and both adapters +@langchain/core ^1.1.33 # @threadplane/langgraph +@langchain/langgraph-sdk ^1.7.4 # @threadplane/langgraph +@ag-ui/client * # @threadplane/ag-ui +@ag-ui/core * # @threadplane/ag-ui +``` + +Each package README lists that package's full peer set. --- -## 30-Second Example +## First success: a chat surface with no backend + +Start with the fake agent. It streams a canned reply in the browser, so the UI +can be built and tested before a server, a graph, or an API key exists. ```typescript -// app.config.ts — wire the adapter once -import { provideAgent } from '@threadplane/langgraph'; +// app.config.ts — no server, no LLM, deterministic output +import { ApplicationConfig } from '@angular/core'; +import { provideFakeAgent } from '@threadplane/langgraph'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ - apiUrl: 'https://your-langgraph-platform.com', - assistantId: 'my-agent', - }), + provideFakeAgent({ tokens: ['Hello', ' from', ' Threadplane'] }), ], }; +``` -// support-chat.component.ts +```typescript +// support-agent.component.ts import { Component } from '@angular/core'; -import { ChatComponent as ThreadplaneChatComponent } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; +import { ChatComponent } from '@threadplane/chat'; @Component({ - selector: 'app-support-chat', - imports: [ThreadplaneChatComponent], - template: ` - + imports: [ChatComponent], + template: ``, +}) +export class SupportAgentComponent { + protected readonly agent = injectAgent(); +} +``` - @if (chat.isLoading()) { - Streaming… - } +`agent.messages()` and `agent.status()` are Angular Signals. Bind them directly +in a template — no subscriptions, no `async` pipe, no zone.js required. The same +fake agent is what tests run against: swap the transport, never the component. - - `, -}) -export class SupportChatComponent { - protected readonly chat = injectAgent(); +Walkthrough: [Try without a backend](https://threadplane.ai/docs/chat/getting-started/try-without-a-backend). - send() { - void this.chat.submit({ message: 'Hello' }); - } -} +--- + +## One UI, two adapters + +When the UI works, point it at a real runtime. Only the provider changes — the +component above is untouched, because `@threadplane/chat` consumes the +runtime-neutral `Agent` contract rather than any adapter type. + +```typescript +// LangGraph Platform, or a local `langgraph dev` server +import { provideAgent } from '@threadplane/langgraph'; + +provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'agent' }); +``` + +```typescript +// Any AG-UI-compatible endpoint +import { provideAgent } from '@threadplane/ag-ui'; + +provideAgent({ url: 'http://localhost:8000/agent' }); ``` -`chat.messages()` and `chat.status()` are Angular Signals. Bind them directly in your template — no subscriptions, no `async` pipe, no zone.js required. +Which one to pick: [Choosing an adapter](https://threadplane.ai/docs/choosing-an-adapter). --- -## Feature Comparison +## See it running -| Feature | `injectAgent()` (Angular) | `useStream()` (React) | -|---|---|---| -| Streaming state as reactive primitives | Angular Signals | React state | -| Messages signal | `messages()` | `messages` | -| Loading state | `isLoading()` | `isLoading` | -| Error state | `error()` | — | -| Runtime-neutral status | `status()` — `'idle' \| 'running' \| 'error'` | partial | -| Interrupt / human-in-the-loop | `interrupt()` (runtime-neutral) / `langGraphInterrupts()` (raw plural) | `interrupt` / `interrupts` | -| Tool call progress | `toolCalls()` | `toolCalls` | -| Branch / history | `branch()` / `history()` / `experimentalBranchTree()` | `branch` / `history` / `experimental_branchTree` | -| Pending run queue | `queue()` | `queue` | -| Subagent map and lookup | `subagents()` — `Signal>` / `getSubagent(toolCallId)` | `subagents` / helper methods | -| Reactive thread switching | `switchThread(id)` | prop | -| Submit | `submit(values, opts?)` | `submit(values, opts?)` | -| Stop | `stop()` | `stop()` | -| Regenerate response | `regenerate(assistantMessageIndex)` | — | -| Reload last submission | `reload()` | — | -| Custom transport (for testing) | `MockAgentTransport` | mock fetch | -| Angular `ResourceRef` compatibility | Full duck-type parity | N/A | -| Angular 20–22 Signals API | Native | N/A | -| SSR / Server Components | Client-side only | React Server Components (React) | +- [demo.threadplane.ai](https://demo.threadplane.ai) — the LangGraph demo: + streaming, durable threads, interrupts, subagents, and generative UI. +- [ag-ui.threadplane.ai](https://ag-ui.threadplane.ai) — the same chat surface + over an AG-UI backend. +- [Generative UI, live in the docs](https://threadplane.ai/docs/chat/guides/generative-ui?mode=run). --- ## Packages -All packages are published at version `0.0.47` under a patch-only `0.0.x` release policy. +Published packages follow a patch-only `0.0.x` release policy: no minor or major +bump silently changes a lockfile. | Package | Purpose | License | |---|---|---| -| `@threadplane/chat` | Drop-in agent chat UI for Angular: headless primitives and opinionated compositions (``, popup, sidebar, interrupts, GenUI) | MIT | -| `@threadplane/langgraph` | LangGraph adapter; `provideAgent()`/`injectAgent()` exposes a LangGraph run as Angular Signals | MIT | -| `@threadplane/ag-ui` | AG-UI adapter; bridges any `@ag-ui/client`-compatible backend into the chat surface | MIT | -| `@threadplane/render` | `@json-render/core`-backed Angular engine that renders JSON specs to components (powers GenUI) | MIT | +| `@threadplane/chat` | The Angular agent chat surface: ``, headless primitives, opinionated compositions, interrupts, subagents, generative UI | MIT | +| `@threadplane/langgraph` | LangGraph adapter; `provideAgent()` / `injectAgent()` expose a LangGraph run as Angular Signals | MIT | +| `@threadplane/ag-ui` | AG-UI adapter; bridges any `@ag-ui/client`-compatible backend into the same chat surface | MIT | +| `@threadplane/render` | `@json-render/core`-backed Angular render engine that maps JSON specs to your own components | MIT | | `@threadplane/a2ui` | A2UI protocol types, streaming parser, and dynamic-value resolver; pure TypeScript, no Angular dependency | MIT | -| `@threadplane/telemetry` | Explicit Node and opt-in browser telemetry helpers | MIT | +| `@threadplane/middleware` | Backend middleware for client-declared tools; the `/langgraph` entrypoint targets LangGraph.js | MIT | +| `@threadplane/telemetry` | Explicit Node and browser capture helpers for applications that choose to send events | MIT | + +Generated UI renders through a registry you control: + +```typescript +import { provideViews, views } from '@threadplane/render'; + +provideViews(views({ KpiCard: KpiCardComponent, DisruptionsTable: DisruptionsTableComponent })); +``` + +An agent can render those components and nothing else, so generative UI stays +inside your design system. --- @@ -146,25 +184,78 @@ All packages are published at version `0.0.47` under a patch-only `0.0.x` releas />

-`provideAgent()` creates the agent's internal `BehaviorSubject`s at injection-context time — once, when the provider factory runs. `injectAgent()` retrieves the configured `LangGraphAgent` in any component. The `StreamManager` bridge (the only file that touches `@langchain/langgraph-sdk` internals) pushes stream events into those subjects. `toSignal()` converts each subject to an Angular Signal, also at construction time. Dynamic actions (`submit`, `stop`, `switchThread`) push into the existing subjects — no new subjects are ever created after construction. This architecture is required because `toSignal()` must be called in an injection context and cannot be called again later. +`provideAgent()` creates the agent's internal `BehaviorSubject`s at +injection-context time — once, when the provider factory runs. `injectAgent()` +retrieves the configured agent in any component. The `StreamManager` bridge (the +only file that touches `@langchain/langgraph-sdk` internals) pushes stream events +into those subjects. `toSignal()` converts each subject to an Angular Signal, +also at construction time. Dynamic actions (`submit`, `stop`, `switchThread`) +push into the existing subjects — no new subjects are ever created after +construction. This architecture is required because `toSignal()` must be called +in an injection context and cannot be called again later. + +The runtime-neutral `Agent` contract is the stability boundary between adapters +and the chat surface. `@threadplane/chat` consumes `Agent` — not +`LangGraphAgent` — so swapping `@threadplane/langgraph` for +`@threadplane/ag-ui` requires no changes to chat components or templates. + +**Reliability:** every pull request runs the "Library — lint / test / build" CI +job across all packages. Testing uses `MockAgentTransport` to swap the transport +layer, so `injectAgent()` itself never needs to be mocked — just substitute the +transport. -The runtime-neutral `Agent` contract is the stability boundary between adapters and the chat surface. `@threadplane/chat` consumes `Agent` — not `LangGraphAgent` — so swapping `@threadplane/langgraph` for `@threadplane/ag-ui` requires no changes to your chat components or templates. +--- + +## The Signals surface -**Reliability:** Every pull request runs the "Library — lint / test / build" CI job across all packages. Testing uses `MockAgentTransport` to swap the transport layer, so you never need to mock `injectAgent()` itself — just substitute the transport. The patch-only `0.0.x` release policy ensures no minor or major version bumps silently break your lockfile. +`injectAgent()` is the Angular counterpart to LangGraph's React `useStream()` +hook, projected through the runtime-neutral `Agent` contract. + +| Capability | `injectAgent()` (Angular) | `useStream()` (React) | +|---|---|---| +| Streaming state as reactive primitives | Angular Signals | React state | +| Messages signal | `messages()` | `messages` | +| Loading state | `isLoading()` | `isLoading` | +| Error state | `error()` | — | +| Runtime-neutral status | `status()` — `'idle' \| 'running' \| 'error'` | partial | +| Interrupt / human-in-the-loop | `interrupt()` (runtime-neutral) / `langGraphInterrupts()` (raw plural) | `interrupt` / `interrupts` | +| Tool call progress | `toolCalls()` | `toolCalls` | +| Branch / history | `branch()` / `history()` / `experimentalBranchTree()` | `branch` / `history` / `experimental_branchTree` | +| Pending run queue | `queue()` | `queue` | +| Subagent map and lookup | `subagents()` — `Signal>` / `getSubagent(toolCallId)` | `subagents` / helper methods | +| Reactive thread switching | `switchThread(id)` | prop | +| Submit | `submit(values, opts?)` | `submit(values, opts?)` | +| Stop | `stop()` | `stop()` | +| Regenerate response | `regenerate(assistantMessageIndex)` | — | +| Reload last submission | `reload()` | — | +| Custom transport (for testing) | `MockAgentTransport` | mock fetch | +| Angular `ResourceRef` compatibility | Full duck-type parity | N/A | +| Angular 20–22 Signals API | Native | N/A | +| SSR / Server Components | Client-side only | React Server Components (React) | --- ## Documentation -- [LangGraph Quickstart](https://threadplane.ai/docs/langgraph/getting-started/quickstart) -- [injectAgent() API](https://threadplane.ai/docs/langgraph/api/inject-agent) -- [Choosing an adapter (LangGraph vs AG-UI)](https://threadplane.ai/docs/choosing-an-adapter) -- [Chat Introduction](https://threadplane.ai/docs/chat/getting-started/introduction) -- [Human-in-the-Loop / Interrupts](https://threadplane.ai/docs/langgraph/guides/interrupts) -- [Subgraph and Subagent Streaming](https://threadplane.ai/docs/langgraph/guides/subgraphs) +- [Try without a backend](https://threadplane.ai/docs/chat/getting-started/try-without-a-backend) +- [LangGraph quickstart](https://threadplane.ai/docs/langgraph/getting-started/quickstart) +- [AG-UI quickstart](https://threadplane.ai/docs/ag-ui/getting-started/quickstart) +- [Choosing an adapter](https://threadplane.ai/docs/choosing-an-adapter) +- [`injectAgent()` API](https://threadplane.ai/docs/langgraph/api/inject-agent) +- [Chat introduction](https://threadplane.ai/docs/chat/getting-started/introduction) +- [Human approvals and interrupts](https://threadplane.ai/docs/langgraph/guides/interrupts) +- [Durable threads](https://threadplane.ai/docs/langgraph/guides/persistence) +- [Subgraph and subagent streaming](https://threadplane.ai/docs/langgraph/guides/subgraphs) --- -## License +## License and data handling + +Every published package in this repository is released under the **MIT +License** — free for commercial and noncommercial use, modification, and +redistribution with the required notice. -Every published package in this repository is released under the **MIT License** — free for commercial and noncommercial use, modification, and redistribution with the required notice. +There is no account to create and no Threadplane service between the application +and the agent backend: adapters talk to the endpoint that is configured. How +Threadplane handles data on its own properties is described at +[threadplane.ai/privacy](https://threadplane.ai/privacy). diff --git a/apps/website/src/lib/public-copy.spec.ts b/apps/website/src/lib/public-copy.spec.ts index 3e77dde2d..c26d8863f 100644 --- a/apps/website/src/lib/public-copy.spec.ts +++ b/apps/website/src/lib/public-copy.spec.ts @@ -1,19 +1,24 @@ // SPDX-License-Identifier: MIT -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { BANNED_CLAIMS, NARRATIVE_MENTIONS, RETIRED_ROUTE_PATTERN, + allBarredPatterns, findBarredCopy, } from './public-copy-contract'; const WEBSITE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const CONTENT_ROOT = join(WEBSITE_ROOT, 'content'); +const SOURCE_ROOT = join(WEBSITE_ROOT, 'src'); +const WORKSPACE_ROOT = join(WEBSITE_ROOT, '..', '..'); +const LIBS_ROOT = join(WORKSPACE_ROOT, 'libs'); function publicContentFiles(directory: string): string[] { const found: string[] = []; @@ -45,6 +50,204 @@ function offenders( return hits; } +/* ──────────────────────────────────────────────────────────────────────────── + * Copy rendered from source, not from `content/**`. + * + * WHY THIS EXISTS. The scan above reads `content/**` only, so every word a + * component renders was invisible to it. That is not theoretical: the homepage + * rebuild shipped an FAQ answer asserting "Installation is inert" and linking + * `/docs/telemetry/guides/browser`. Lint, types and the whole unit suite were + * green; only the production crawl in `e2e/public-copy.spec.ts` caught it, and + * that crawl runs against a deployed preview — days of feedback latency for a + * claim the repo had already decided not to make. + * + * HOW IT WORKS. Reading the file bytes would be useless here: this repository + * discusses the barred phrases in prose. `HomeFAQ.tsx` carries a comment naming + * the exact retired claim so the next author does not reintroduce it, and + * `FinalCTA.tsx` does the same. A byte scan flags both, someone adds an + * ignore-comment, and the gate is dead within a month. + * + * So the scan parses each file with the TypeScript compiler and looks only at + * the nodes whose text can reach a visitor: + * - string literals (which is how `href="/docs/telemetry/…"` is spelled), + * - template literals, static chunks only, + * - JSX text. + * Everything else in the AST — identifiers, comments, regex literals, JSX + * element and attribute names — is skipped by construction, not by an ignore + * list. That is what makes the exclusions below short. + * + * WHAT IT CATCHES. A banned claim or a retired route written literally into any + * shipped `.ts`/`.tsx`/`.mjs` under `src/`, in a string, a template chunk, a JSX + * attribute value, or JSX body text — including copy modules such as + * `lib/positioning.ts` and route handlers such as `app/llms.txt/route.ts`. + * + * WHAT IT STILL CANNOT CATCH, and the e2e crawl remains the backstop for: + * - copy assembled at runtime across a substitution — `Installation is + * ${state}`, or `${DOCS_BASE}/telemetry/guides/browser` — since only the + * static chunks are compared; + * - copy that arrives from outside this tree: MDX frontmatter rendered by a + * template, the API-docs generator, a CMS or fetch response; + * - `content:` strings in `src/styles/*.css`, alt text baked into an image, + * anything in `public/`; + * - a claim rephrased so it matches no pattern. The contract is a list of + * known-bad sentences, never a semantic judge. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Files excluded from the source scan, and why each one is not public copy. + * + * Kept to a rule rather than a path list so it cannot quietly grow: + * - `public-copy-contract.ts` — defines the bans. (Its phrases live in regex + * literals, which the extractor skips anyway, so this is for clarity, not + * for coverage.) + * - `*.spec.ts` / `*.spec.tsx` — assert on the bans. `app/api/ingest/ + * route.spec.ts` names a payload "browser telemetry" in a test title; test + * titles are not served to anyone. + * There is no per-line allowlist and none is needed: on the current tree the + * scan reports zero hits, so any future entry would be a real new occurrence + * that deserves an argument in review rather than a suppression. + */ +function isExcludedFromSourceScan(fileName: string): boolean { + return ( + fileName === 'public-copy-contract.ts' || + /\.spec\.(?:ts|tsx)$/u.test(fileName) + ); +} + +function renderedCopyFiles(directory: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + found.push(...renderedCopyFiles(path)); + } else if ( + /\.(?:tsx?|mjs)$/u.test(entry.name) && + !isExcludedFromSourceScan(entry.name) + ) { + found.push(path); + } + } + return found; +} + +interface CopyFragment { + /** Literal text as authored, minus the quotes or braces around it. */ + readonly text: string; + /** One-based line the fragment starts on. */ + readonly line: number; + /** Which of the three copy-bearing node shapes this came from. */ + readonly kind: 'string' | 'template' | 'jsx'; +} + +/** + * The visitor-visible text of one source file. + * + * Module specifiers are dropped: `from './TelemetryHowItFits'` is an identifier + * that happens to be spelled with quotes, and nothing renders it. + */ +function extractRenderedCopy( + source: string, + fileName: string +): CopyFragment[] { + const parsed = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.ESNext, + /* setParentNodes */ true, + fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + const fragments: CopyFragment[] = []; + + const isModuleSpecifier = (node: ts.Node): boolean => { + const parent = node.parent; + if (!parent) return false; + return ( + (ts.isImportDeclaration(parent) && parent.moduleSpecifier === node) || + (ts.isExportDeclaration(parent) && parent.moduleSpecifier === node) || + ts.isImportTypeNode(parent) || + ts.isExternalModuleReference(parent) || + (ts.isCallExpression(parent) && + parent.expression.kind === ts.SyntaxKind.ImportKeyword) + ); + }; + + const copyKind = (node: ts.Node): CopyFragment['kind'] | null => { + if (ts.isJsxText(node)) return 'jsx'; + if (ts.isStringLiteral(node)) return 'string'; + if ( + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isTemplateHead(node) || + ts.isTemplateMiddle(node) || + ts.isTemplateTail(node) + ) { + return 'template'; + } + return null; + }; + + const visit = (node: ts.Node): void => { + const kind = copyKind(node); + + if (kind && !isModuleSpecifier(node)) { + // `getStart()` skips leading trivia, which for JSX text is part of the + // text itself, so locate the literal text in the raw source instead and + // fall back to the node start when escapes make it unfindable. + const nodeStart = node.getStart(parsed); + const rawStart = source.indexOf((node as ts.LiteralLikeNode).text, node.pos); + const textStart = + rawStart >= node.pos && rawStart < node.end ? rawStart : nodeStart; + fragments.push({ + text: (node as ts.LiteralLikeNode).text, + line: parsed.getLineAndCharacterOfPosition(textStart).line + 1, + kind, + }); + } + ts.forEachChild(node, visit); + }; + + visit(parsed); + return fragments; +} + +/** + * Parse every scanned file once. Three assertions read the same fragments, and + * re-parsing the tree for each one tripled the cost of the gate for nothing. + */ +let parsedTree: ReadonlyArray<{ + readonly path: string; + readonly fragments: readonly CopyFragment[]; +}> | null = null; + +function renderedCopy(): ReadonlyArray<{ + readonly path: string; + readonly fragments: readonly CopyFragment[]; +}> { + parsedTree ??= renderedCopyFiles(SOURCE_ROOT).map((path) => ({ + path, + fragments: extractRenderedCopy(readFileSync(path, 'utf8'), path), + })); + return parsedTree; +} + +function sourceOffenders( + patterns: ReadonlyArray +): string[] { + const hits: string[] = []; + for (const { path, fragments } of renderedCopy()) { + for (const fragment of fragments) { + for (const [label, pattern] of patterns) { + const match = fragment.text.match(pattern); + if (!match) continue; + const line = + fragment.line + + (fragment.text.slice(0, match.index).match(/\n/gu)?.length ?? 0); + hits.push(`${relative(WEBSITE_ROOT, path)}:${line} — ${label}`); + } + } + } + return hits; +} + describe('public copy', () => { it('makes none of the barred absolute claims', () => { expect(offenders(BANNED_CLAIMS)).toEqual([]); @@ -87,6 +290,241 @@ describe('public copy', () => { }); }); +const RETIRED_ROUTE_RULE: ReadonlyArray = [ + ['retired documentation route', RETIRED_ROUTE_PATTERN], +]; + +/* ──────────────────────────────────────────────────────────────────────────── + * The npm package pages. + * + * `libs/*\/README.md` is the body of every package listing on npmjs.com, which + * is a more public surface than most of this website and had no copy gate of + * any kind. It showed: `libs/telemetry/README.md` shipped "Installation is + * inert", "Browser telemetry is opt-in", and a "never collects …" absolute — + * three barred claims, live on npm, while the website scan next to it was + * green. Markdown is not linted here, and the only checks that read these + * files are `scripts/verify-angular-support.mjs` (the Angular badge and peer + * block) and `scripts/mit-cutover.spec.mjs`. + * + * No overlap with `mit-cutover.spec.mjs`: it looks for the retired *licensing* + * vocabulary — read its own `retiredTerms` list for the exact strings — and + * only in `README.md` and `libs/chat/README.md`. Different words, different + * concern, seven READMEs it never opens. Nothing here is reported twice. + * + * Do not spell those terms out here. That spec bans them anywhere under + * `apps/website/src`, so naming them in this comment turns it red — which is + * why it assembles its own list with `.join('')` rather than writing the + * literals. This comment did exactly that and broke CI. + * + * These are prose, so they get the plain line scan that `content/**` gets — + * none of the AST machinery above, which exists only because `src/**` mixes + * copy with code. The scan reads fenced code blocks too. That is deliberate: a + * false positive in a README is an argument in review, whereas a miss is a + * claim on npm, and the failure mode this whole file guards against is the + * silent one. + * + * What it cannot catch: a README not under `libs/*` (an `examples/*` one, say), + * a `NOTICE.md` or `CHANGELOG.md`, and the `description` field of a + * `package.json`, which npm also renders. Nothing scans those yet. + * ──────────────────────────────────────────────────────────────────────────── */ +function packageReadmeFiles(): string[] { + const libraryReadmes = readdirSync(LIBS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(LIBS_ROOT, entry.name, 'README.md')) + .filter((path) => existsSync(path)); + return [join(WORKSPACE_ROOT, 'README.md'), ...libraryReadmes]; +} + +function readmeOffenders( + patterns: ReadonlyArray +): string[] { + const hits: string[] = []; + for (const path of packageReadmeFiles()) { + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, index) => { + for (const [label, pattern] of patterns) { + if (pattern.test(line)) { + hits.push( + `${relative(WORKSPACE_ROOT, path)}:${index + 1} — ${label}` + ); + } + } + }); + } + return hits; +} + +describe('published package READMEs', () => { + it('makes none of the barred absolute claims', () => { + expect(readmeOffenders(BANNED_CLAIMS)).toEqual([]); + }); + + it('carries no narrative telemetry positioning', () => { + expect(readmeOffenders(NARRATIVE_MENTIONS)).toEqual([]); + }); + + it('links no retired documentation route', () => { + expect(readmeOffenders(RETIRED_ROUTE_RULE)).toEqual([]); + }); + + /** + * Anti-vacuity for the walk: `libs/*` is discovered, not listed, so a rename + * or a moved directory could quietly empty it. The floor and the two named + * files below make that loud. `telemetry` is named because it is the package + * whose README carried the shipped violations. + */ + it('reads the root README and every published package README', () => { + const files = packageReadmeFiles().map((path) => + relative(WORKSPACE_ROOT, path) + ); + expect(files.length).toBeGreaterThanOrEqual(8); + expect(files).toContain('README.md'); + expect(files).toContain(join('libs', 'telemetry', 'README.md')); + expect(files).toContain(join('libs', 'chat', 'README.md')); + }); +}); + +describe('copy rendered from source', () => { + it('makes none of the barred absolute claims', () => { + expect(sourceOffenders(BANNED_CLAIMS)).toEqual([]); + }); + + it('carries no narrative telemetry positioning', () => { + expect(sourceOffenders(NARRATIVE_MENTIONS)).toEqual([]); + }); + + it('links no retired documentation route', () => { + expect(sourceOffenders(RETIRED_ROUTE_RULE)).toEqual([]); + }); + + it('actually walks the component tree', () => { + const files = renderedCopyFiles(SOURCE_ROOT).map((path) => + relative(SOURCE_ROOT, path) + ); + expect(files.length).toBeGreaterThan(100); + expect(files).toContain(join('components', 'landing', 'HomeFAQ.tsx')); + expect(files).toContain(join('lib', 'positioning.ts')); + expect(files).toContain(join('app', 'llms.txt', 'route.ts')); + expect(files).not.toContain(join('lib', 'public-copy-contract.ts')); + }); + + /** + * Anti-vacuity at the walk, not just the extractor. The fixtures below prove + * the parser finds violations in a synthetic file; nothing there would notice + * if the walk started handing every real file back as zero fragments. These + * floors are far under the current counts, so ordinary editing will not move + * them — only the extractor going quiet will. + */ + it('extracts real copy from the real tree, JSX body text included', () => { + const fragments = renderedCopy().flatMap((file) => file.fragments); + expect(fragments.length).toBeGreaterThan(2000); + expect(fragments.filter((f) => f.kind === 'jsx').length).toBeGreaterThan( + 200 + ); + expect( + renderedCopy().find((file) => file.path.endsWith('HomeFAQ.tsx')) + ?.fragments.some((f) => f.kind === 'jsx') + ).toBe(true); + }); +}); + +/** + * The gate proves itself against the regression it exists for. + * + * `HOMEPAGE_FAQ_REGRESSION` is the shipped answer that got through: a banned + * claim and a link to a retired route, inside a copy table exactly like the one + * `HomeFAQ.tsx` renders. If the extractor is ever narrowed until it catches + * nothing — the failure mode that matters, because a green vacuous gate is + * worse than no gate — these two assertions go red. + * + * The counter-fixture is the other half: the surrounding comments name the same + * phrases, as the real files do, and must not be reported. A scan that flags + * them gets suppressed by the next person who trips over it. + */ +const HOMEPAGE_FAQ_REGRESSION = ` +export const FAQ = [ + { + q: 'What does Threadplane report about my application?', + a: ( + <> + Nothing you have not asked for. Installation is inert — see the{' '} + browser telemetry guide. + + ), + }, +]; +`; + +const HOMEPAGE_FAQ_FIXED = ` +// The absolute framing this question used to carry ("installation is +// inert", linking the retired telemetry docs library) is barred copy. +export const FAQ = [ + { + q: 'What does Threadplane report about my application?', + a: <>See the privacy policy., + }, +]; +`; + +describe('the source scan is not vacuous', () => { + function hits( + source: string, + patterns: ReadonlyArray + ): string[] { + return extractRenderedCopy(source, 'HomeFAQ.tsx').flatMap((fragment) => + patterns + .filter(([, pattern]) => pattern.test(fragment.text)) + .map(([label]) => label) + ); + } + + it('flags the banned claim the homepage actually shipped', () => { + expect(hits(HOMEPAGE_FAQ_REGRESSION, BANNED_CLAIMS)).toContain( + 'installation inertness claim' + ); + }); + + it('flags the retired route the same answer linked', () => { + expect(hits(HOMEPAGE_FAQ_REGRESSION, RETIRED_ROUTE_RULE)).toEqual([ + 'retired documentation route', + ]); + }); + + it('flags narrative positioning in JSX body text', () => { + expect(hits(HOMEPAGE_FAQ_REGRESSION, NARRATIVE_MENTIONS)).toContain( + 'browser-telemetry positioning' + ); + }); + + it('reports nothing once the answer is rewritten', () => { + expect(hits(HOMEPAGE_FAQ_FIXED, allBarredPatterns())).toEqual([]); + expect(hits(HOMEPAGE_FAQ_FIXED, RETIRED_ROUTE_RULE)).toEqual([]); + }); + + it('ignores the comments and regex literals that discuss the bans', () => { + const discussion = ` + // "Installation is inert" was retired; do not link /docs/telemetry. + /** Off by default is a claim we do not make. */ + const BARRED = /installation is inert|off by default/iu; + export const answer = 'See the privacy policy.'; + `; + expect(hits(discussion, allBarredPatterns())).toEqual([]); + expect(hits(discussion, RETIRED_ROUTE_RULE)).toEqual([]); + }); + + it('ignores module specifiers but not the copy beside them', () => { + const module = ` + import { Diagram } from './docs/telemetry/Diagram'; + export const blurb = 'Telemetry is opt-in.'; + `; + expect(hits(module, RETIRED_ROUTE_RULE)).toEqual([]); + expect(hits(module, NARRATIVE_MENTIONS)).toEqual([ + 'opt-in telemetry positioning', + ]); + }); +}); + /** * Generated API JSON is projected rather than authored, so the guard belongs * with the generator. This asserts the projection is actually wired in: the diff --git a/libs/a2ui/README.md b/libs/a2ui/README.md index 9d51d20f3..ae0926b6b 100644 --- a/libs/a2ui/README.md +++ b/libs/a2ui/README.md @@ -1,6 +1,6 @@ # @threadplane/a2ui -The A2UI (Agent-to-UI) protocol type system and parsing/resolution utilities for TypeScript, targeting the **A2UI v0.9.1 stable release**. Defines the wire format agents use to drive generative UI surfaces — framework-agnostic, no Angular dependency, runs in any TypeScript environment. +The A2UI (Agent-to-UI) protocol layer behind generative UI in [Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI agent UI framework for Angular, targeting the **A2UI v0.9.1 stable release**. It defines the wire format an agent uses to drive a UI surface, plus the parser and resolver that read it — framework-agnostic, pure TypeScript, no Angular dependency, usable in any TypeScript environment.

diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index 4fabea3b5..d80a8a113 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,7 @@ { "name": "@threadplane/a2ui", "version": "0.0.65", + "description": "A2UI protocol types, streaming parser, and dynamic-value resolver for agent-generated UI. Pure TypeScript.", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/README.md b/libs/ag-ui/README.md index 91e966640..d47a487a2 100644 --- a/libs/ag-ui/README.md +++ b/libs/ag-ui/README.md @@ -1,6 +1,6 @@ # @threadplane/ag-ui -Adapter that wraps an [AG-UI](https://github.com/ag-ui-protocol/ag-ui) `AbstractAgent` into the runtime-neutral `Agent` contract from `@threadplane/chat`. Works with any AG-UI-compatible backend. +The AG-UI adapter for [Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI agent UI framework for Angular. Wraps an [AG-UI](https://github.com/ag-ui-protocol/ag-ui) `AbstractAgent` into the runtime-neutral `Agent` contract that `@threadplane/chat` consumes, so any AG-UI-compatible backend drives the same chat surface.

@@ -32,7 +32,7 @@ Part of [Threadplane](https://github.com/cacheplane/angular-agent-framework). ## Install ```bash -npm install @threadplane/ag-ui @threadplane/chat @ag-ui/client @ag-ui/core marked +npm install @threadplane/chat @threadplane/ag-ui @ag-ui/client @ag-ui/core marked ``` **Peer dependencies:** `@threadplane/chat: *`, `@angular/core: ^20.0.0 || ^21.0.0 || ^22.0.0`, `@ag-ui/client: *`, `@ag-ui/core: *`, `rxjs: ~7.8.0` diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index a014b4834..e96555e50 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,7 +1,7 @@ { "name": "@threadplane/ag-ui", "version": "0.0.65", - "description": "AG-UI protocol adapter for @threadplane/chat — works with any AG-UI-compatible backend.", + "description": "AG-UI adapter for the Angular AI agent UI — connect any AG-UI-compatible backend to the same chat surface.", "keywords": [ "angular", "ag-ui", diff --git a/libs/chat/README.md b/libs/chat/README.md index 75f3b52b6..a8d190d4c 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -1,8 +1,6 @@ # @threadplane/chat -Drop-in agent chat UI for Angular 20–22. Headless UI primitives plus opinionated compositions that read a runtime-neutral `Agent` contract — ship a production chat surface in days without coupling to a specific backend. - -Part of [Threadplane](https://github.com/cacheplane/angular-agent-framework). +The chat surface of [Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI agent UI framework for Angular. Headless primitives plus opinionated compositions read a runtime-neutral `Agent` contract, so the UI is built once and runs over LangGraph or AG-UI without changes. Angular 20–22, on Signals and DI.

diff --git a/libs/chat/package.json b/libs/chat/package.json index 6d6becea9..d544294d4 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,7 @@ { "name": "@threadplane/chat", "version": "0.0.65", + "description": "The Angular AI agent chat UI: , headless primitives, interrupts, subagents, and generative UI on Signals.", "exports": { "./chat.css": "./chat.css", "./themes/default-dark.css": "./themes/default-dark.css", diff --git a/libs/langgraph/README.md b/libs/langgraph/README.md index 838eadc9c..fc00cfb3a 100644 --- a/libs/langgraph/README.md +++ b/libs/langgraph/README.md @@ -1,6 +1,6 @@ # @threadplane/langgraph -Adapter that wraps a LangGraph agent into the runtime-neutral `Agent` contract from `@threadplane/chat`. The Angular equivalent of LangGraph's React `useStream()` hook — signal-driven access to messages, status, tool calls, interrupts, subagents, branch history, and thread persistence. +The LangGraph adapter for [Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI agent UI framework for Angular. Wraps a LangGraph agent into the runtime-neutral `Agent` contract that `@threadplane/chat` consumes — the Angular counterpart to LangGraph's React `useStream()` hook, with signal-driven access to messages, status, tool calls, interrupts, subagents, branch history, and thread persistence.

@@ -27,7 +27,7 @@ Adapter that wraps a LangGraph agent into the runtime-neutral `Agent` contract f ## Install ```bash -npm install @threadplane/langgraph @threadplane/chat @langchain/core @langchain/langgraph-sdk +npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked ``` **Peer dependencies:** @@ -40,6 +40,8 @@ npm install @threadplane/langgraph @threadplane/chat @langchain/core @langchain/ rxjs ~7.8.0 ``` +`marked` is the markdown parser peer that `@threadplane/chat` requires when assistant messages are rendered through ``. + ## Quick start Configure the LangGraph endpoint once in `app.config.ts`: diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index 354edfe7f..99d741708 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,7 +1,7 @@ { "name": "@threadplane/langgraph", "version": "0.0.65", - "description": "LangGraph adapter for @threadplane/chat — Angular bindings for LangGraph Platform.", + "description": "LangGraph adapter for the Angular AI agent UI — provideAgent()/injectAgent() expose a run as Angular Signals.", "keywords": [ "angular", "langgraph", diff --git a/libs/middleware/README.md b/libs/middleware/README.md index bc8460d8e..8945477a7 100644 --- a/libs/middleware/README.md +++ b/libs/middleware/README.md @@ -1,7 +1,8 @@ # @threadplane/middleware -Backend middleware for the [Threadplane](https://github.com/cacheplane/angular-agent-framework) -client-tools capability — frontend-declared tools the model calls and the browser executes. +The backend half of client tools in [Threadplane](https://github.com/cacheplane/angular-agent-framework), +the AI agent UI framework for Angular. Client tools are declared in the browser: the model +calls them, and the browser executes them. The `@threadplane/middleware/langgraph` entrypoint is the LangGraph.js twin of the Python `threadplane-middleware` package: it binds client-declared tool stubs onto your model and diff --git a/libs/middleware/package.json b/libs/middleware/package.json index 04495b7c0..dee085256 100644 --- a/libs/middleware/package.json +++ b/libs/middleware/package.json @@ -1,7 +1,7 @@ { "name": "@threadplane/middleware", "version": "0.0.2", - "description": "Backend middleware for the Threadplane client-tools capability. The /langgraph entrypoint targets LangGraph.js.", + "description": "Backend middleware for Threadplane client tools — the model calls, the browser executes. LangGraph.js entrypoint.", "keywords": ["langgraph", "agent", "client-tools", "middleware", "threadplane"], "license": "MIT", "type": "module", diff --git a/libs/render/README.md b/libs/render/README.md index be19e7122..9210bc4d1 100644 --- a/libs/render/README.md +++ b/libs/render/README.md @@ -1,6 +1,6 @@ # @threadplane/render -`@json-render/core`-backed Angular render engine — maps JSON specs to Angular components via a registry, used internally by `@threadplane/chat` for generative-UI rendering. +The generative-UI render engine of [Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI agent UI framework for Angular. `@json-render/core`-backed: it maps a JSON spec to Angular components through a registry you define, so agent-generated UI can only render the design-system components you registered. `@threadplane/chat` uses it for generative UI.

diff --git a/libs/render/package.json b/libs/render/package.json index 2446ae6a7..69b90aefc 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,7 @@ { "name": "@threadplane/render", "version": "0.0.65", + "description": "Angular render engine for agent-generated UI: maps JSON specs onto your own design-system components.", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0", "@angular/common": "^20.0.0 || ^21.0.0 || ^22.0.0", diff --git a/libs/telemetry/README.md b/libs/telemetry/README.md index 12e5c2e67..bcaac9b50 100644 --- a/libs/telemetry/README.md +++ b/libs/telemetry/README.md @@ -1,7 +1,9 @@ # @threadplane/telemetry -Explicit, opt-in telemetry helpers for Threadplane applications. Installing this -package does not execute telemetry code or make network requests. +Explicit capture helpers for applications built with +[Threadplane](https://github.com/cacheplane/angular-agent-framework), the AI +agent UI framework for Angular. Every send is one the application asked for: +this package has no ambient collection path.

@@ -15,19 +17,22 @@ package does not execute telemetry code or make network requests.

-## Trust contract +## How sending works -- **Installation is inert.** The package has no install lifecycle scripts. -- **Browser telemetry is opt-in.** It stays disabled unless an application calls - `provideThreadplaneTelemetry({ enabled: true })`. -- **Node telemetry is explicit.** An event is sent only when application code +- **No install lifecycle scripts.** The manifest declares none, so `npm install` + runs no code from this package. +- **The browser service starts disabled.** It sends nothing until an application + calls `provideThreadplaneTelemetry({ enabled: true })`. +- **Node capture is explicit.** An event is sent only where application code calls a capture helper. - **Disable controls win.** `TPLANE_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, CI detection, or `disableTelemetry()` prevent sends before a network call. +- **Point it anywhere.** `TPLANE_TELEMETRY_INGEST_URL`, or an `endpoint` or + `sink` on the browser provider, routes events to infrastructure you control. -Threadplane telemetry never collects message content, prompts, completions, tool -inputs or outputs, credentials, project paths, raw environment variables, or -personally identifiable information. +The event categories, purposes, and retention that apply to Threadplane's own +properties are described at +[threadplane.ai/privacy](https://threadplane.ai/privacy). ## Install diff --git a/libs/telemetry/package.json b/libs/telemetry/package.json index 7288c95ca..12afc8aaa 100644 --- a/libs/telemetry/package.json +++ b/libs/telemetry/package.json @@ -1,6 +1,7 @@ { "name": "@threadplane/telemetry", "version": "0.0.65", + "description": "Explicit Node and browser capture helpers for Angular AI agent applications that choose to send events.", "license": "MIT", "publishConfig": { "access": "public" diff --git a/libs/telemetry/scripts/assemble-dist.mjs b/libs/telemetry/scripts/assemble-dist.mjs index fee86db3f..2af506d5a 100644 --- a/libs/telemetry/scripts/assemble-dist.mjs +++ b/libs/telemetry/scripts/assemble-dist.mjs @@ -87,6 +87,10 @@ export function createCanonicalPackageJson(srcPkg) { const out = { name: srcPkg.name, version: srcPkg.version, + // The npm page's subtitle. This manifest is an allowlist, so a field left + // out here is a field that never ships, however carefully it is authored + // in libs/telemetry/package.json. + description: srcPkg.description, license: srcPkg.license, publishConfig: srcPkg.publishConfig, repository: srcPkg.repository,