@clone-ai/prompt-prediction 0.7.0-bootstrap.0 → 0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/CONTRIBUTING.md +52 -0
  3. package/LICENSE +21 -0
  4. package/README.md +119 -1
  5. package/SECURITY.md +9 -0
  6. package/dist/assistant-ui.d.ts +5 -0
  7. package/dist/assistant-ui.js +26 -0
  8. package/dist/clone-mode.d.ts +64 -0
  9. package/dist/clone-mode.js +156 -0
  10. package/dist/controller.d.ts +61 -0
  11. package/dist/controller.js +155 -0
  12. package/dist/deadline.d.ts +2 -0
  13. package/dist/deadline.js +25 -0
  14. package/dist/feedback.d.ts +50 -0
  15. package/dist/feedback.js +118 -0
  16. package/dist/generated/api-types.d.ts +1663 -0
  17. package/dist/generated/api-types.js +5 -0
  18. package/dist/http.d.ts +8 -0
  19. package/dist/http.js +35 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.js +4 -0
  22. package/dist/react.d.ts +50 -0
  23. package/dist/react.js +147 -0
  24. package/dist/server.d.ts +33 -0
  25. package/dist/server.js +93 -0
  26. package/dist/transport.d.ts +15 -0
  27. package/dist/transport.js +42 -0
  28. package/dist/types.d.ts +8 -0
  29. package/dist/types.js +1 -0
  30. package/docs/agent-integration.md +122 -0
  31. package/docs/api.md +69 -0
  32. package/docs/billing.md +53 -0
  33. package/docs/browser-onboarding.md +69 -0
  34. package/docs/clone-mode.md +40 -0
  35. package/docs/context-mapping.md +28 -0
  36. package/docs/data-and-service.md +25 -0
  37. package/docs/developer-apps.md +87 -0
  38. package/docs/feedback.md +70 -0
  39. package/docs/pilot-validation.md +35 -0
  40. package/docs/releases.md +88 -0
  41. package/docs/reliability.md +33 -0
  42. package/docs/start.md +91 -0
  43. package/examples/react/composer-events.ts +3 -0
  44. package/examples/react/demo.tsx +195 -0
  45. package/examples/react/index.html +12 -0
  46. package/examples/react/mode-demo.tsx +95 -0
  47. package/examples/react/pilot-observations.ts +57 -0
  48. package/examples/react/vite.config.ts +123 -0
  49. package/openapi.json +2268 -0
  50. package/package.json +99 -4
  51. package/playwright.config.ts +13 -0
  52. package/release-manifest.json +63 -0
  53. package/scripts/create-example.mjs +31 -0
  54. package/scripts/pilot-metrics.mjs +91 -0
  55. package/tests/browser/clone-mode-options.tsx +38 -0
  56. package/tests/browser/clone-mode.spec.ts +68 -0
  57. package/tests/browser/composer.spec.ts +238 -0
@@ -0,0 +1,33 @@
1
+ # Reliability and limits
2
+
3
+ ## Your composer must keep working
4
+
5
+ Mount the editor and process typing, attachments, Undo, IME and manual send independently of Clone. Never await prediction in the normal send path, disable the input during prediction, or gate the whole product on Clone health. Hide suggestions on failures. Telemetry is best effort.
6
+
7
+ SDK 0.7.0 uses a 15-second default client deadline (configurable up to 30 seconds) including response-body reads. The reusable server client limits concurrent requests to 16 by default and fails fast on saturation. The controller drops expired, canceled and identity-mismatched results. Recovery does not send anything automatically. A request whose outcome is unknown must retain its ID and body for reconciliation.
8
+
9
+ ## Configured limits are not measured capacity
10
+
11
+ The developer console reports your app's current requests-per-minute limit and prediction time limit. These are configured limits, not a guarantee of sustained throughput, active users or an SLA.
12
+
13
+ Before increasing traffic, measure latency, accepted versus rejected requests, timeouts and normal composer behavior in your integration. Ask Clone for a capacity review when your expected peak exceeds the app limit.
14
+
15
+ Use optional `onMetric` on `createPredictionTransport` to observe duration, outcome and HTTP status without logging draft text. The [pilot validation guide](pilot-validation.md) covers real proxy fault injection and opt-in measurements.
16
+
17
+ ## Errors
18
+
19
+ | Status / code | Integration behavior |
20
+ | --- | --- |
21
+ | 402 quota or configured hard limit | Hide suggestions; preserve manual input/send; do not activate payment automatically |
22
+ | 429 `rate_limited` | Observe `Retry-After`; avoid per-keystroke retry loops |
23
+ | 503 `prediction_capacity_exceeded` | Service temporarily at capacity; request rejected without a billable suggestion |
24
+ | 503 `client_capacity_exceeded` | SDK client's local concurrency bound reached |
25
+ | 502 / 503 / 504 | Hide candidate and preserve core service; reconcile the original ID if settlement is unknown |
26
+ | malformed JSON / network / never returns | Bound the request and show no candidate; never lose the draft |
27
+ | revoked connection | Clear local personalization state; do not retry the same work as a new anonymous request |
28
+
29
+ ## Required outage test
30
+
31
+ Inject 503, 429, 402, network failure, malformed JSON and a never-resolving request into the real customer proxy. Type and edit text, use Undo/IME, ensure Tab without a candidate traverses focus, and send the exact draft once. Restore the proxy and verify fresh Tab acceptance without sending. Check both empty and nonempty drafts. Test Clone mode separately: Stop, input, expiry and a pending unknown send must not cause duplicate submission.
32
+
33
+ SDK fixture tests do not establish that an external customer's composer obeys these rules. Inspect its integration and exercise the actual product before asserting customer-service resilience.
package/docs/start.md ADDED
@@ -0,0 +1,91 @@
1
+ # Integrate Clone SDK into an existing product
2
+
3
+ This is the entry point for your coding agent. Implement next-prompt prediction and Tab Completion using the product’s own context by default, then run automated verification. End users need no Clone account. Offer Clone account personalization only as an optional enhancement in settings; do not put a Connect screen before the composer. Preserve the product's authentication, composer, explicit send behavior and agent execution. Automatic submission is a separate, optional Clone mode with explicit end-user start and bounded scope.
4
+
5
+ This guide targets **SDK 0.7.0**. Version 0.3.1 lacks the presentation, deadline and Clone mode options; inspect the installed version before using them. Onboarding documentation can change independently of that immutable package. Read this guide, [agent integration](agent-integration.md), [context mapping](context-mapping.md), [service boundaries](data-and-service.md), and the installed package's exported types and `openapi.json`. The types and schema define the wire contract. Do not stop after writing a plan or building a separate demo.
6
+
7
+ ## Start from one prompt
8
+
9
+ The customer opens their product repository in a coding agent and enters:
10
+
11
+ > Read https://clone.is/docs/sdk-quickstart and integrate Clone SDK into this product using the documented npm version and a lockfile. Discover our composer, backend and authentication; reuse existing setup and keep app keys server-side. Wire prediction, acceptance, edits, explicit evaluations and rejection, successful submission and observed task outcomes through the authenticated backend. Preserve typing, IME, Undo and manual sending during delays and outages. Ask only about unresolved product choices; keep personalization, automatic sending, feedback text collection and paid usage opt-in. Verify the actual integration and report installation, feedback delivery, outage results and pending live/customer checks separately.
12
+
13
+ Treat account preparation, app/key issuance, callback registration, implementation and automated tests as parts of this task. First inspect the actual product, available browser/computer-use tools, existing sessions and approved secret destination. Do not begin by asking the customer to create keys, invent a callback, or complete an onboarding checklist. Use [browser onboarding](browser-onboarding.md) when account access is not already available. Authentication and required consent can interrupt the flow; resume the same integration afterwards without requiring a second setup prompt. These documents do not override the agent's tool permissions or confirmation rules.
14
+
15
+ ## Choose the customer experience
16
+
17
+ After inspecting the existing composer, use the agent's AskUserQuestion or equivalent question tool to confirm the product choices that cannot be inferred from the customer's request. Bundle these into one short interaction; reuse choices already supplied, and continue independent implementation while waiting. Explain the supported defaults and record the answers in the integration report.
18
+
19
+ | Choice | Supported default and alternatives |
20
+ |---|---|
21
+ | When to suggest | Both empty-composer next prompts and completion of an existing draft. If the customer wants only one, gate the hook/controller with `enabled` according to the current draft. |
22
+ | How to present | Ask for **instant ghost text (default)** or **typewriter animation**. Map the choice to `presentation: "instant"` or `"typewriter"`. Both use a complete JSON response, not API token streaming. Follow the [rendering contract](agent-integration.md#suggestion-rendering-contract). |
23
+ | Context and personalization | Product conversation/artifact context by default. Ask whether optional Connect Clone belongs in settings, and which product-owned preferences may be supplied. Clone personalization requires each user's explicit consent to selected sources. |
24
+ | Automatic sending (Clone mode) | Ask whether to offer optional Clone mode. Default off. If requested, ask for the send/time limits and use `useCloneMode` or `CloneModeController` with explicit end-user Start, visible preview/Stop, and real host completion signals. Default 3 sends / 5 minutes. Never enable on mount, restore, or Tab. See [Clone mode](clone-mode.md). |
25
+ | Rollout and spending | Use production integration by default; reuse an explicit evaluation choice. For production, include company card setup and explicit PAYG consent in initial onboarding before enabling production predictions. Keep card-free sandbox evaluation as a separate path. Ask about an end-user suggestion switch and rollout cohort. Budget alerts and a hard limit are separate choices; never activate paid use or a hard stop silently. |
26
+
27
+ Do not ask customers to choose details the agent can discover, such as the framework, session store or callback route. Existing send behavior, Undo, IME safety and graceful failure are mandatory integration requirements rather than optional preferences.
28
+
29
+ ## 1. Register your app and settings
30
+
31
+ Discover the customer's backend, session store, deployment origins and routing conventions. For basic integration, register an app with no callbacks (`redirect_uris: []`). Use the [production setup console](https://clone.is/developer/apps?setup=production) or [developer API](developer-apps.md) to register it and issue the app key. Reuse an existing verified Clone session or account token. Otherwise open the console with browser/computer use and follow [browser onboarding](browser-onboarding.md) to establish access. A separate management token is optional when the browser session is sufficient. A Clone operator and infrastructure access are not required for a verified account to register apps.
32
+
33
+ | Setting | Agent action |
34
+ |---|---|
35
+ | `CLONE_API_URL` | Use `https://api.clone.is`, unless Clone supplied another environment. Do not append `/v1`. This is not a secret. |
36
+ | `CLONE_APP_KEY` | Reuse the app's `clnp_...` key from the customer's backend secret store. If no app exists, create it through the console or developer API and save the returned key directly to that store using the supported secret-handling path. The key is shown once. |
37
+ | `CLONE_CALLBACK_URL` | Optional. Only if the product offers Connect Clone, derive, implement and register an exact callback, and pass it to `clone.connect`. Basic predictions need no callback. |
38
+
39
+ The developer account owns the app and billing; it is separate from the product’s end users, who need no Clone signup or login. Read [developer-apps.md](developer-apps.md) before mutation. List existing apps first, reuse their stable IDs, and update callbacks with `PATCH` and the current `config_revision`. On conflict, re-read and reconcile; do not overwrite another session's changes blindly. Keep the old callback registered while deploying its replacement if existing login attempts need to finish. Remove it afterwards: removal invalidates its pending connections, including issued but unexchanged codes. Completed user connections remain active.
40
+
41
+ Production callbacks require HTTPS. A sandbox app can explicitly set `allow_loopback: true` for exact `http://127.0.0.1:<port>/<path>` callbacks. Wildcards, `localhost`, query strings and fragments are not supported. One developer account owns its app collection; all its apps share 1,000 sandbox predictions. Creating, rotating or disabling apps does not replenish that allowance. There is no automatic paid upgrade. Team ownership transfer is not self-service. After SDK billing is enabled for the service, the developer console supports explicit paid activation with a saved card; see [pricing and billing](billing.md). Integration alone never authorizes paid activation.
42
+
43
+ Never print keys into chat, logs, screenshots, source code, callback URLs or `VITE_*`/`NEXT_PUBLIC_*` settings. Establish the secret-transfer path before issuing a key; the console shows it once. Verify secret presence without displaying values. Do not rotate a working key just because its plaintext is unavailable to the agent. Try the documented browser path before treating absent account credentials as a blocker. If login, email verification, repository access or secure storage still requires the customer, show the exact page and ask for only that action while continuing independent implementation and fixture tests. Do not create substitute accounts or fabricate credentials. See the browser guide for email verification and recovery.
44
+
45
+ App registration enables predictions from customer-supplied context. Omit `connection_id` or send null for that mode. Optional Connect Clone adds explicitly selected, synced profile/Goal sources after per-user login/consent. Never discover a Clone identity from a customer email or infer consent from app ownership. Existing operator-provisioned pilot apps continue working but are not automatically claimed by a new developer account.
46
+
47
+ ### Complete company card setup before production installation
48
+
49
+ Production integration is the default path: company account → app registration → pricing and card → SDK installation → verification. Use the separate **Try the free sandbox** path at `/developer/apps?setup=sandbox` only when the customer chooses evaluation. Do not silently fall back to sandbox if production card setup is canceled or unavailable.
50
+
51
+ A newly registered app initially remains sandbox. Save its one-time key directly to the approved backend secret store before leaving the console for Stripe, then choose **I saved the key**. The console opens pricing and card setup for that same app. The customer must personally review the displayed unit price, authorize PAYG, and register the card on Stripe. Explain: **No charge when you register your card. Actual usage is billed monthly.** Reuse an existing company card through **Enable PAYG for this app**, with the customer's explicit consent, instead of creating another company or asking for another card.
52
+
53
+ After the customer returns, the console calls billing sync and reads the app's authoritative billing state. Continue production installation only after the app is active with `plan: "paid"` and `payg: true` under an active company mandate. A success URL, card thumbnail, or `/sync` response alone is not proof. See [billing](billing.md) for readback and recovery. On cancel, pending setup, or processor failure, keep the same app and reconcile with **Refresh billing**. If setup is still incomplete, return through **Add company card and enable PAYG**; do not issue a new app/key or activate paid usage yourself. Inspection and preparation can continue while the customer completes the handoff.
54
+
55
+ For the explicitly chosen sandbox path, save the key and continue without a card. Re-read that the app is actually sandbox; never describe an already-paid app as free merely because the URL contains `setup=sandbox`.
56
+
57
+ ## 2. Obtain and install the package
58
+
59
+ Install the public npm package using the product's existing package manager:
60
+
61
+ ```sh
62
+ npm install --save-exact @clone-ai/prompt-prediction@0.7.0
63
+ ```
64
+
65
+ Commit the appropriate lockfile. Node 22.13+, ESM and React 18/19 are supported. Do not upgrade the entire host application without establishing compatibility. A Git submodule is not required.
66
+
67
+ For a checksum-verified GitHub archive, follow [download and install](releases.md#download-and-install). Consume the built package instead of copying SDK implementation files into the product. See [release access and CI](releases.md#release-access-and-ci) for repeatable installs.
68
+
69
+ ## 3. Integrate with the product
70
+
71
+ Follow [agent-integration.md](agent-integration.md) for the full backend routes, PKCE/session handling, errors, event attribution and acceptance cases. Use `CloneClient` in a JS/TS server. For another backend language, implement the same authenticated HTTP contract from `openapi.json`; do not introduce a second backend solely to use the client.
72
+
73
+ Locate the real composer and successful-send callback. Prefer the headless React hook for an existing native textarea. Use the optional adapter only for an existing compatible assistant-ui runtime. Rich-text/contenteditable inputs require a controller-based insertion/Undo adapter. Keep the customer's editor, attachment/mention support and keyboard behavior.
74
+
75
+ Optionally pass bounded `user_preferences` supplied by the customer product for its authenticated user. Supply current values on each request and update them when the user corrects a preference. Map actual conversation and artifact state using [context-mapping.md](context-mapping.md): video timelines, clip/time selection and known text summaries for video products; decks, selected slides and known slide text for slide products. Invalidate context on conversation/selection changes as well as artifact edits. Keep accepted or edited prediction origins distinct from independently typed text.
76
+
77
+ Tab inserts a visible suggestion without sending. Escape dismisses it. The existing Enter/button sends exactly once. Errors, abstention and quota limits must preserve ordinary input/send. No Clone connection is the normal product-context mode. On logout disable prediction and clear candidates/attribution; on account switch bind a new session/context before re-enabling. On Clone disconnect clear the old candidate and connection, then use product context for subsequent new requests. An explicit invalid/revoked connection fails; never retry it as a new basic request automatically.
78
+
79
+ ## 4. Verify and hand off
80
+
81
+ Run the customer's lint, typecheck, relevant tests and build. Use deterministic fixtures and Playwright in the actual product composer for the cases in [acceptance evidence](agent-integration.md#acceptance-evidence-to-return), including the first character after Tab, Undo, composition, context/account changes and failure recovery. Check event deduplication without additional billing. Synthetic composition checks do not prove native OS IME behavior.
82
+
83
+ Once the chosen path is ready and its key is authorized, run at most four real prediction requests in total for bounded API verification. Sandbox requests use its shared allowance. Production verification requires the customer's explicit PAYG consent and adds usage charges: at $0.02 per valid suggestion, four suggestions cost at most $0.08. Basic mode needs no Clone test account or consent. Test optional personalization only when an authorized, consented test connection is available. Record abstentions and failures honestly. Do not increase the limit, activate paid use on the customer's behalf, or bypass login/consent. Do not require additional human real-use sessions or manual QA to finish the automated integration work. Report unavailable connected checks separately from passed fixture tests.
84
+
85
+ Return a concise integration report with changed files, SDK version/checksum, commands/results, reproduction steps, secret **names** and deployment settings, and any external dependency such as SDK access, app issuance or user consent. Keep installation, fixture behavior, live API behavior and observed usefulness separate. Do not claim suggestion quality or production deployment from passing fixtures. Production deployment requires the customer's own authorization.
86
+
87
+ ## Credentials and optional MCP
88
+
89
+ Prediction calls use the **Clone app key**. App management uses the owner's Clone browser session or an optional manual account API token, as documented in [developer apps](developer-apps.md). `CLONE_DEVELOPER_TOKEN` is only the suggested name for that optional token, not a required setup variable or runtime dependency. No additional service credentials are required for predictions. Ordinary fixtures need no service credentials.
90
+
91
+ This documentation and the versioned package are sufficient inputs for an integration agent; no Clone docs MCP is required or currently supplied. A future read-only MCP could serve these same documents and schema. App/key/callback management uses the authenticated developer HTTP API; no MCP is needed.
@@ -0,0 +1,3 @@
1
+ // The host integration uses the packaged tracker rather than copying its logic.
2
+ export { FeedbackTracker as ComposerEvents } from '../../src/feedback.js';
3
+ export type { FeedbackEvent as ComposerEvent } from '../../src/feedback.js';
@@ -0,0 +1,195 @@
1
+ import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { AssistantRuntimeProvider, ComposerPrimitive, useExternalStoreRuntime } from '@assistant-ui/react';
4
+ import type { ThreadMessageLike } from '@assistant-ui/react';
5
+ import { TabCompletionInput } from '../../src/react.js';
6
+ import { CloneComposerInput } from '../../src/assistant-ui.js';
7
+ import { createPredictionTransport } from '../../src/transport.js';
8
+ import { createEventTransport } from '../../src/feedback.js';
9
+ import type { CompletionRequest, PredictionTransport } from '../../src/types.js';
10
+ import { CloneModeDemo } from './mode-demo.js';
11
+ import { ComposerEvents } from './composer-events.js';
12
+ import type { ComposerEvent } from './composer-events.js';
13
+
14
+ const fixture = new URLSearchParams(location.search).get('connected') !== '1';
15
+ const assistant = new URLSearchParams(location.search).get('assistant') === '1';
16
+ const sessionId = crypto.randomUUID();
17
+
18
+ function measure(body: Record<string, unknown>) {
19
+ if (!fixture) void fetch('/api/clone/metrics', { method: 'POST', headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({ ...body, session_id: sessionId, observed_at: Date.now() }) }).catch(() => {});
21
+ }
22
+ type ConversationTurn = NonNullable<CompletionRequest['messages']>[number] & { role: 'user' | 'assistant' };
23
+
24
+ const mockTransport: PredictionTransport = async (request, { signal }) => {
25
+ await new Promise(resolve => setTimeout(resolve, 50));
26
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
27
+ return { request_id: request.request_id, prediction_id: 'pred_' + request.request_id,
28
+ session_id: request.session_id, connection_id: request.connection_id ?? null,
29
+ draft_revision: request.draft.revision, context_revision: request.context_revision,
30
+ profile_revision: request.connection_id ? 'fixture-profile' : '', grant_revision: request.connection_id ? 1 : 0, status: 'suggested',
31
+ completion: request.artifact?.kind === 'video' ? ' 더 짧게 편집해줘.' : ' 결론부터 정리해줘.',
32
+ expires_at: Math.floor(Date.now() / 1000) + 60, context_truncated: false, usage: { prediction_units: 1 } };
33
+ };
34
+
35
+ function Demo() {
36
+ const [value, setValue] = useState('');
37
+ const [kind, setKind] = useState<'video' | 'slides'>('video');
38
+ const [revision, setRevision] = useState(1);
39
+ const [connection, setConnection] = useState('');
40
+ const [disconnecting, setDisconnecting] = useState(false);
41
+ const [receipts, setReceipts] = useState<string[]>([]);
42
+ const [conversation, setConversation] = useState<ConversationTurn[]>([
43
+ { role: 'assistant', content: '초안이 준비됐어요. 검토해주세요.', origin: 'agent' },
44
+ ]);
45
+ const [conversationRevision, setConversationRevision] = useState(1);
46
+ const [error, setError] = useState('');
47
+ const [faultsEnabled, setFaultsEnabled] = useState(false);
48
+ const [fault, setFault] = useState('none');
49
+ const [collectText, setCollectText] = useState(false);
50
+ const [guidance, setGuidance] = useState('');
51
+ const [lastRequest, setLastRequest] = useState('');
52
+ const [feedbackRevision, setFeedbackRevision] = useState('');
53
+ const composer = useRef<HTMLDivElement>(null);
54
+ const [events, setEvents] = useState<(ComposerEvent & { delivery: 'pending' | 'recorded' | 'failed' | 'cancelled' | 'fixture' })[]>([]);
55
+ const eventTransport = useMemo(() => createEventTransport('/api/clone/events'), []);
56
+ const telemetry = useMemo(() => new ComposerEvents((event, options) => {
57
+ setEvents(items => [...items.slice(-99), { ...event, delivery: fixture ? 'fixture' : 'pending' }]);
58
+ if (!fixture) return eventTransport(event, options);
59
+ }, { collectSubmittedText: collectText, onDelivery: receipt => {
60
+ if (fixture) return;
61
+ const delivery = receipt.status;
62
+ setEvents(items => items.map(item => item.event_id === receipt.event_id ? { ...item, delivery } : item));
63
+ measure({ ...receipt, delivery });
64
+ } }), [eventTransport, collectText]);
65
+ useEffect(() => () => telemetry.reset(), [telemetry]);
66
+ const observe = (event: Parameters<ComposerEvents['observe']>[0]) => {
67
+ if (event.kind === 'presented') setLastRequest(event.request_id);
68
+ telemetry.observe(event, composer.current?.querySelector('textarea')?.value ?? '');
69
+ };
70
+ const predictionTransport = useMemo<PredictionTransport>(() => fixture ? mockTransport : (request, options) =>
71
+ createPredictionTransport('/api/clone/predict', {
72
+ onMetric: metric => measure({ kind: 'metric', request_id: request.request_id,
73
+ duration_ms: metric.durationMs, status: metric.status, outcome: metric.outcome, code: metric.code }),
74
+ })(request, options), []);
75
+ const transport = useMemo<PredictionTransport>(() => async (request, options) => {
76
+ const result = await predictionTransport(request, options);
77
+ if (!options.signal.aborted) setFeedbackRevision(result.feedback_revision ?? '');
78
+ return result;
79
+ }, [predictionTransport]);
80
+ const context: Omit<CompletionRequest, 'draft' | 'mode' | 'request_id'> = {
81
+ connection_id: connection || null, session_id: sessionId, context_revision: `${revision}:${conversationRevision}`, language: 'ko',
82
+ messages: conversation,
83
+ artifact: { kind, id: kind === 'video' ? 'timeline-1' : 'deck-1', revision: String(revision),
84
+ selection: kind === 'video' ? 'clip-1 / 00:00–00:08' : 'slide-2', summary: '짧은 제품 소개 초안' },
85
+ };
86
+ function submitted(text: string) {
87
+ const origin = telemetry.submitted(text);
88
+ setReceipts(items => [...items, text]);
89
+ setConversation(items => [...items.slice(-29), { role: 'user', content: text, origin }]);
90
+ setConversationRevision(n => n + 1);
91
+ }
92
+ const messages: ThreadMessageLike[] = conversation.map(({ role, content }) => ({
93
+ role, content: [{ type: 'text', text: content }],
94
+ }));
95
+ const runtime = useExternalStoreRuntime({ messages, convertMessage: message => message, onNew: async message => {
96
+ submitted(message.content.filter(part => part.type === 'text').map(part => part.text).join(''));
97
+ } });
98
+ async function connect() {
99
+ try {
100
+ const response = await fetch('/api/clone/connect', { method: 'POST' });
101
+ const data = await response.json();
102
+ if (!response.ok) throw new Error(data.error);
103
+ location.assign(data.authorizeUrl);
104
+ } catch (cause) { setError(cause instanceof Error ? cause.message : 'Connection failed'); }
105
+ }
106
+ useEffect(() => {
107
+ if (!fixture) {
108
+ measure({ kind: 'session' });
109
+ void fetch('/api/clone/state').then(r => r.json()).then(data => {
110
+ setConnection(data.connection_id ?? ''); setFaultsEnabled(data.faults_enabled === true);
111
+ }).catch(() => setError('Example backend unavailable. Manual send still works.'));
112
+ }
113
+ }, []);
114
+ async function disconnect() {
115
+ telemetry.reset(); setDisconnecting(true); setError('');
116
+ try {
117
+ const response = await fetch('/api/clone/disconnect', { method: 'POST' });
118
+ if (!response.ok) throw new Error('Could not disconnect; retry or refresh connection.');
119
+ setConnection('');
120
+ } catch (cause) { setError(cause instanceof Error ? cause.message : 'Disconnect failed'); }
121
+ finally { setDisconnecting(false); }
122
+ }
123
+ async function readConnection() {
124
+ const response = await fetch('/api/clone/state');
125
+ const data = await response.json();
126
+ if (data.connection_id !== connection) telemetry.reset();
127
+ setConnection(data.connection_id ?? '');
128
+ }
129
+ return <main style={{ maxWidth: 700, margin: '60px auto', font: '16px/1.6 system-ui', padding: 24 }}>
130
+ <p style={{ color: '#64748b' }}>Clone · Tab Completion integration example</p>
131
+ <h1>Complete your next instruction</h1>
132
+ <p>{fixture ? 'Fixture mode: deterministic suggestions for interaction tests.' : 'API mode: suggestions from your product context. Clone personalization is optional.'}</p>
133
+ {faultsEnabled && <label>Local proxy fault <select aria-label="Local proxy fault" value={fault} onChange={event => {
134
+ const next = event.target.value;
135
+ void fetch('/api/clone/test-fault', { method: 'POST', headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ fault: next }) }).then(response => {
137
+ if (response.ok) { telemetry.reset(); setFault(next); setRevision(n => n + 1); }
138
+ }).catch(() => setError('Could not change the local test fault.'));
139
+ }}>{['none', '503', '429', '402', 'network', 'malformed', 'timeout', 'latency'].map(value =>
140
+ <option key={value} value={value}>{value}</option>)}</select></label>}
141
+ <label>Context <select aria-label="Context" value={kind} onChange={event => {
142
+ telemetry.reset();
143
+ setKind(event.target.value as 'video' | 'slides'); setRevision(n => n + 1);
144
+ }}><option value="video">Video timeline example</option><option value="slides">Slide deck example</option></select></label>
145
+ <p>Selected: {context.artifact?.selection} · revision {revision}</p>
146
+ {!fixture && <details><summary>Optional personalization</summary><p><button onClick={() => void connect()}>Connect Clone</button>{' '}
147
+ <button onClick={() => void readConnection()}>Refresh connection</button>{' '}
148
+ {connection && <button disabled={disconnecting} onClick={() => void disconnect()}>Disconnect</button>}{' '}
149
+ <span data-testid="connection">{connection ? 'Connected' : 'Product context'}</span></p></details>}
150
+ <p>Tab accepts a suggestion. You choose when to send.</p>
151
+ <div ref={composer} onInput={event => {
152
+ if (event.target instanceof HTMLTextAreaElement) telemetry.input(event.target.value);
153
+ }}>{assistant ? <AssistantRuntimeProvider runtime={runtime}><ComposerPrimitive.Root>
154
+ <CloneComposerInput aria-label="Instruction" rows={5} context={context} transport={transport} enabled={!disconnecting} onEvent={observe} />
155
+ <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
156
+ </ComposerPrimitive.Root></AssistantRuntimeProvider> : <form onSubmit={event => {
157
+ event.preventDefault(); if (value.trim()) { submitted(value); setValue(''); }
158
+ }}>
159
+ <TabCompletionInput aria-label="Instruction" rows={5} value={value} onValueChange={next => { telemetry.input(next); setValue(next); }}
160
+ context={context} transport={transport} enabled={!disconnecting} onEvent={observe}
161
+ onKeyDown={event => {
162
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) {
163
+ event.preventDefault(); event.currentTarget.form?.requestSubmit();
164
+ }
165
+ }} />
166
+ <button type="submit">Send</button>
167
+ </form>}</div>
168
+ <p role="alert">{error}</p>
169
+ <details><summary>Feedback loop · test fixture</summary>
170
+ <label><input type="checkbox" checked={collectText} onChange={event => setCollectText(event.target.checked)} />
171
+ Share edited submission text (optional)</label>
172
+ <p><button disabled={!lastRequest} onClick={() => telemetry.feedback(lastRequest, { rating: 'positive' })}>Helpful</button>{' '}
173
+ <button disabled={!lastRequest} onClick={() => telemetry.rejected(lastRequest, { reason: 'too_long' })}>Reject: too long</button></p>
174
+ <label>Feedback guidance <input value={guidance} onChange={event => setGuidance(event.target.value)} /></label>{' '}
175
+ <button disabled={!lastRequest || !guidance.trim()} onClick={() => {
176
+ telemetry.feedback(lastRequest, { rating: 'negative', guidance, content_opt_in: true }); setGuidance('');
177
+ }}>Send feedback</button>{' '}
178
+ <button onClick={() => {
179
+ if (!fixture) void fetch('/api/clone/clear-feedback', { method: 'POST' }).then(async response => {
180
+ if (!response.ok) throw new Error('Feedback clear failed');
181
+ setFeedbackRevision(''); setLastRequest(''); telemetry.reset();
182
+ }).catch(() => setError('Could not clear feedback.'));
183
+ }}>Clear feedback</button>
184
+ <p>Injected feedback revision: <code data-testid="feedback-revision">{feedbackRevision || 'none'}</code></p>
185
+ </details>
186
+ <h2>Host submit receipts · test fixture</h2>
187
+ <p data-testid="receipt-count">{receipts.length}</p>
188
+ <ul>{receipts.map((text, index) => <li key={index}>{text}</li>)}</ul>
189
+ <details><summary>Observation delivery · test fixture</summary>
190
+ <pre data-testid="observation-events">{JSON.stringify(events, null, 2)}</pre>
191
+ </details>
192
+ </main>;
193
+ }
194
+
195
+ createRoot(document.getElementById('root')!).render(<StrictMode>{new URLSearchParams(location.search).get('clone-mode') === '1' ? <CloneModeDemo /> : <Demo />}</StrictMode>);
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Clone Tab Completion | Integration example</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/demo.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,95 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { TabCompletionInput, useCloneMode } from '../../src/react.js';
3
+ import { ComposerEvents } from './composer-events.js';
4
+ import type { CompletionRequest, PredictionTransport } from '../../src/types.js';
5
+
6
+ /** Local fixture only: no network, customer data, payment, or real agent execution. */
7
+ export function CloneModeDemo() {
8
+ const [draft, setDraft] = useState('');
9
+ const attribution = useMemo(() => new ComposerEvents(() => {}), []);
10
+ const [presentation, setPresentation] = useState<'instant' | 'typewriter'>('instant');
11
+ const [fault, setFault] = useState('healthy');
12
+ const [busy, setBusy] = useState(false);
13
+ const [composing, setComposing] = useState(false);
14
+ const [revision, setRevision] = useState(1);
15
+ const [finished, setFinished] = useState(0);
16
+ const [receipts, setReceipts] = useState<{ text: string; origin: 'human' | 'agent' | 'accepted_prediction' | 'edited_prediction' }[]>([]);
17
+ const transport = useMemo<PredictionTransport>(() => async request => {
18
+ if (fault === 'pending') return new Promise(() => {});
19
+ if (fault === 'invalid') return JSON.parse('{invalid');
20
+ if (fault !== 'healthy') throw new Error(`Fixture ${fault}`);
21
+ return {
22
+ request_id: request.request_id, prediction_id: `pred_${request.request_id}`,
23
+ session_id: request.session_id, connection_id: null,
24
+ draft_revision: request.draft.revision, context_revision: request.context_revision,
25
+ profile_revision: '', grant_revision: 0, status: 'suggested',
26
+ completion: '이 영상을 30초로 줄이고 핵심 장면부터 보여줘.',
27
+ expires_at: Math.floor(Date.now() / 1000) + 60, context_truncated: false,
28
+ usage: { prediction_units: 1 },
29
+ };
30
+ }, [fault]);
31
+ const context: Omit<CompletionRequest, 'draft' | 'mode' | 'request_id'> = {
32
+ session_id: 'fixture-thread', context_revision: String(revision), language: 'ko',
33
+ messages: receipts.slice(-28).map(item => ({ role: 'user', content: item.text, origin: item.origin })),
34
+ artifact: { kind: 'video', id: 'fixture-timeline', revision: String(revision), summary: '제품 소개 초안' },
35
+ };
36
+ function submit(text: string, origin: 'human' | 'agent' | 'accepted_prediction' | 'edited_prediction') {
37
+ setReceipts(items => [...items, { text, origin }]);
38
+ setRevision(n => n + 1); setBusy(true);
39
+ }
40
+ const mode = useCloneMode({
41
+ transport, requestTimeoutMs: 1200, reviewMs: 3000,
42
+ input: { scopeId: 'fixture-user:fixture-thread', context, draft, enabled: true, busy, composing },
43
+ onSubmit: async (text, { origin, signal }) => {
44
+ if (signal.aborted) return false;
45
+ submit(text, origin); return true;
46
+ },
47
+ });
48
+ const active = !['off', 'stopped'].includes(mode.state.status);
49
+ useEffect(() => {
50
+ // A real host calls this from its completed-turn event with refreshed context.
51
+ if (finished > 0 && !busy && mode.state.status === 'waiting') mode.turnCompleted();
52
+ }, [finished, busy, mode.state.status, mode.turnCompleted]);
53
+ return <main style={{ maxWidth: 780, margin: '40px auto', padding: 24, font: '16px/1.6 system-ui' }}>
54
+ <p>Clone SDK 0.4.0 candidate · local interaction fixture</p>
55
+ <h1>Your composer stays in control</h1>
56
+ <p>These are deterministic suggestions. No external agent or billable API is called.</p>
57
+ <label>Suggestion display <select aria-label="Suggestion display" value={presentation}
58
+ onChange={event => setPresentation(event.target.value as typeof presentation)}>
59
+ <option value="instant">Instant (default)</option><option value="typewriter">Typewriter</option>
60
+ </select></label>{' '}
61
+ <label>Prediction service <select aria-label="Prediction service" value={fault}
62
+ onChange={event => { mode.stop(); setFault(event.target.value); }}>
63
+ {['healthy', '503', '429', '402', 'network', 'invalid', 'pending'].map(item => <option key={item}>{item}</option>)}
64
+ </select></label>
65
+ <form onSubmit={event => { event.preventDefault(); if (draft.trim()) { mode.stop(); submit(draft, attribution.submitted(draft)); setDraft(''); } }}>
66
+ <p>Tab inserts a fully visible suggestion. Enter or Send uses the normal host send path.</p>
67
+ <TabCompletionInput aria-label="Instruction" rows={4} value={draft} onValueChange={next => { attribution.input(next); setDraft(next); }}
68
+ onEvent={event => attribution.observe(event, draft)}
69
+ context={context} transport={transport} presentation={presentation} requestTimeoutMs={1200}
70
+ enabled={!busy && !active} onCompositionStart={() => setComposing(true)} onCompositionEnd={() => setComposing(false)}
71
+ onKeyDown={event => {
72
+ if (event.key === 'Escape') mode.stop();
73
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) {
74
+ event.preventDefault(); event.currentTarget.form?.requestSubmit();
75
+ }
76
+ }} />
77
+ <button type="submit">Send</button>
78
+ </form>
79
+ <section style={{ border: '1px solid #aaa', borderRadius: 12, padding: 20, marginTop: 24 }} aria-label="Clone mode">
80
+ <h2 style={{ marginTop: 0 }}>Optional Clone mode</h2>
81
+ <p>Start delegates up to 3 sends for 5 minutes. Each suggestion is shown for 3 seconds before sending.
82
+ Typing, Stop, errors or switching tabs stop the run. Already accepted sends cannot be recalled.</p>
83
+ <button disabled={active || busy || !!draft} onClick={() => mode.start()}>Start Clone mode</button>{' '}
84
+ <button disabled={!active} onClick={() => mode.stop()}>Stop</button>
85
+ <p role="status">{mode.state.status} · {mode.state.sent}/{mode.state.maxTurns} automatic sends {mode.state.reason ? `· ${mode.state.reason}` : ''}</p>
86
+ {mode.state.candidate && <blockquote data-testid="clone-preview">{mode.state.candidate.completion}</blockquote>}
87
+ <button disabled={!busy} onClick={() => { setBusy(false); setRevision(n => n + 1); setFinished(n => n + 1); }}>
88
+ Complete fixture agent turn
89
+ </button>
90
+ <p>The next send waits for this explicit host completion signal.</p>
91
+ </section>
92
+ <h2>Host send receipts: {receipts.length}</h2>
93
+ <ol>{receipts.map((item, index) => <li key={index}>{item.origin}: {item.text}</li>)}</ol>
94
+ </main>;
95
+ }
@@ -0,0 +1,57 @@
1
+ import { appendFile } from 'node:fs/promises';
2
+ import { createHmac, randomBytes } from 'node:crypto';
3
+
4
+ const kinds = new Set(['session', 'prediction', 'presented', 'accepted', 'edited', 'dismissed', 'submitted', 'metric',
5
+ 'rejected', 'feedback', 'outcome']);
6
+ const sources = new Set(['fixture', 'live', 'fault']);
7
+
8
+ /** Preserve the source of each prediction across late events and fault changes. */
9
+ export function createPredictionSources(source: 'fixture' | 'live') {
10
+ const requests = new Map<string, 'fixture' | 'live' | 'fault'>();
11
+ return {
12
+ register(request: string, fault: boolean) {
13
+ if (!requests.has(request)) requests.set(request, fault ? 'fault' : source);
14
+ if (requests.size > 1000) requests.delete(requests.keys().next().value!);
15
+ return requests.get(request)!;
16
+ },
17
+ get(request: unknown) {
18
+ return typeof request === 'string' ? requests.get(request) : undefined;
19
+ },
20
+ };
21
+ }
22
+
23
+ /** Opt-in, local-only pilot measurements. Never store prompts, keys or user IDs. */
24
+ export function createPilotRecorder(file?: string, source = 'fixture', pilot?: string) {
25
+ if (pilot && !/^[a-zA-Z0-9_-]{1,64}$/.test(pilot)) throw new Error('Use a non-personal pilot label');
26
+ const salt = randomBytes(32);
27
+ const hash = (id: unknown) => typeof id === 'string' && id.length <= 200
28
+ ? createHmac('sha256', salt).update(id).digest('hex').slice(0, 24) : undefined;
29
+ let pending = Promise.resolve();
30
+ return (input: Record<string, unknown>) => {
31
+ if (!file || !kinds.has(String(input.kind))) return;
32
+ const record: Record<string, unknown> = {
33
+ kind: input.kind, source: sources.has(String(input.source)) ? input.source : source,
34
+ recorded_at: Date.now(),
35
+ };
36
+ if (pilot) record.pilot = pilot;
37
+ if (typeof input.observed_at === 'number' && Math.abs(Date.now() - input.observed_at) < 86_400_000) {
38
+ record.observed_at = input.observed_at;
39
+ }
40
+ for (const name of ['request_id', 'session_id', 'event_id']) {
41
+ const value = hash(input[name]); if (value) record[name] = value;
42
+ }
43
+ if (typeof input.duration_ms === 'number' && Number.isFinite(input.duration_ms) && input.duration_ms >= 0) {
44
+ record.duration_ms = Math.min(120_000, input.duration_ms);
45
+ }
46
+ if (typeof input.status === 'number' && Number.isInteger(input.status) && input.status >= 0 && input.status <= 599) {
47
+ record.status = input.status;
48
+ }
49
+ if (['suggested', 'abstained', 'failed', 'cancelled'].includes(String(input.outcome))) record.outcome = input.outcome;
50
+ if (typeof input.code === 'string' && /^[a-z][a-z0-9_]{0,127}$/.test(input.code)) record.code = input.code;
51
+ if (['recorded', 'failed', 'cancelled'].includes(String(input.delivery))) record.delivery = input.delivery;
52
+ if (input.kind === 'outcome' && ['succeeded', 'failed'].includes(String(input.outcome))) record.task_outcome = input.outcome;
53
+ // Serialize append order; a recording failure is isolated from the composer.
54
+ pending = pending.then(() => appendFile(file, JSON.stringify(record) + '\n', { mode: 0o600 }))
55
+ .catch(() => { console.warn('Pilot observation could not be written'); });
56
+ };
57
+ }