@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,155 @@
1
+ /** Prediction lifecycle only. It has no submit, agent, or execution API. */
2
+ export class CompletionController {
3
+ options;
4
+ input = null;
5
+ key = '';
6
+ generation = 0;
7
+ timer;
8
+ expiry;
9
+ animation;
10
+ deadline;
11
+ abort;
12
+ listeners = new Set();
13
+ requestTimes = [];
14
+ disposed = false;
15
+ state = { status: 'idle', candidate: null, error: null, visibleCompletion: '' };
16
+ constructor(options) {
17
+ this.options = options;
18
+ }
19
+ getSnapshot = () => this.state;
20
+ subscribe = (listener) => {
21
+ this.listeners.add(listener);
22
+ return () => this.listeners.delete(listener);
23
+ };
24
+ emit(state) {
25
+ this.state = { ...state, visibleCompletion: state.visibleCompletion ?? state.candidate?.completion ?? '' };
26
+ for (const listener of this.listeners)
27
+ listener();
28
+ }
29
+ event(request_id, kind) {
30
+ try {
31
+ void Promise.resolve(this.options.onEvent?.({ request_id, kind })).catch(() => { });
32
+ }
33
+ catch { /* telemetry never controls typing */ }
34
+ }
35
+ invalidate(reason) {
36
+ this.generation++;
37
+ clearTimeout(this.timer);
38
+ clearTimeout(this.expiry);
39
+ clearTimeout(this.animation);
40
+ clearTimeout(this.deadline);
41
+ this.abort?.abort(reason);
42
+ this.abort = undefined;
43
+ }
44
+ update(input) {
45
+ if (this.disposed)
46
+ return;
47
+ const nextKey = JSON.stringify(input);
48
+ if (nextKey === this.key)
49
+ return;
50
+ this.key = nextKey;
51
+ this.input = input;
52
+ this.invalidate();
53
+ this.emit({ status: 'idle', candidate: null, error: null });
54
+ if (input.enabled === false || !input.focused || input.composing
55
+ || input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length)
56
+ return;
57
+ const generation = this.generation;
58
+ this.timer = setTimeout(() => void this.predict(generation, input), this.options.debounceMs ?? 350);
59
+ }
60
+ async predict(generation, input) {
61
+ this.requestTimes = this.requestTimes.filter(t => Date.now() - t < 60_000);
62
+ if (this.requestTimes.length >= (this.options.maxRequestsPerMinute ?? 20))
63
+ return;
64
+ this.requestTimes.push(Date.now());
65
+ const abort = new AbortController();
66
+ this.abort = abort;
67
+ const request = {
68
+ ...input.context, connection_id: input.context.connection_id ?? null, request_id: crypto.randomUUID(),
69
+ mode: input.value ? 'complete_draft' : 'next_prompt', draft: { text: input.value, revision: input.revision },
70
+ };
71
+ this.emit({ status: 'loading', candidate: null, error: null });
72
+ if (generation !== this.generation || this.disposed)
73
+ return;
74
+ const timeout = this.options.requestTimeoutMs ?? 15_000;
75
+ this.deadline = setTimeout(() => {
76
+ if (generation !== this.generation || this.disposed)
77
+ return;
78
+ this.invalidate(new DOMException('Prediction timed out', 'TimeoutError'));
79
+ this.emit({ status: 'unavailable', candidate: null, error: 'prediction_timeout' });
80
+ }, Number.isFinite(timeout) ? Math.max(1, Math.min(timeout, 30_000)) : 15_000);
81
+ try {
82
+ const candidate = await this.options.transport(request, { signal: abort.signal });
83
+ if (this.disposed || abort.signal.aborted || generation !== this.generation)
84
+ return;
85
+ clearTimeout(this.deadline);
86
+ if (!candidate || candidate.request_id !== request.request_id
87
+ || candidate.session_id !== request.session_id || candidate.connection_id !== request.connection_id
88
+ || candidate.draft_revision !== input.revision || candidate.context_revision !== request.context_revision
89
+ || !Number.isFinite(candidate.expires_at) || candidate.expires_at * 1000 <= Date.now()) {
90
+ this.emit({ status: 'idle', candidate: null, error: null });
91
+ return;
92
+ }
93
+ if (candidate.status !== 'suggested' || typeof candidate.completion !== 'string' || !candidate.completion.trim()) {
94
+ this.emit({ status: 'idle', candidate: null, error: null });
95
+ return;
96
+ }
97
+ if (this.options.presentation === 'typewriter') {
98
+ const letters = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(candidate.completion), part => part.segment);
99
+ const start = Date.now();
100
+ const duration = Math.min(1500, letters.length * 20);
101
+ const reveal = () => {
102
+ if (generation !== this.generation || this.disposed)
103
+ return;
104
+ const count = Math.min(letters.length, Math.ceil((Date.now() - start) / duration * letters.length));
105
+ this.emit({ status: 'suggested', candidate, error: null, visibleCompletion: letters.slice(0, count).join('') });
106
+ if (generation !== this.generation || this.disposed)
107
+ return;
108
+ if (count === letters.length)
109
+ this.event(candidate.request_id, 'presented');
110
+ else
111
+ this.animation = setTimeout(reveal, 20);
112
+ };
113
+ reveal();
114
+ }
115
+ else {
116
+ this.emit({ status: 'suggested', candidate, error: null });
117
+ this.event(candidate.request_id, 'presented');
118
+ }
119
+ if (generation !== this.generation || this.disposed)
120
+ return;
121
+ this.expiry = setTimeout(() => this.dismiss(false), Math.min(60_000, candidate.expires_at * 1000 - Date.now()));
122
+ }
123
+ catch (error) {
124
+ if (this.disposed || abort.signal.aborted || generation !== this.generation)
125
+ return;
126
+ clearTimeout(this.deadline);
127
+ this.emit({ status: 'unavailable', candidate: null,
128
+ error: error instanceof Error ? error.message : 'prediction_unavailable' });
129
+ }
130
+ }
131
+ accept() {
132
+ const candidate = this.state.candidate;
133
+ const input = this.input;
134
+ if (!candidate || !input || input.composing || !input.focused || input.enabled === false
135
+ || input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length
136
+ || candidate.expires_at * 1000 <= Date.now()
137
+ || this.state.visibleCompletion !== candidate.completion)
138
+ return null;
139
+ this.invalidate();
140
+ this.emit({ status: 'idle', candidate: null, error: null });
141
+ this.event(candidate.request_id, 'accepted');
142
+ return { value: input.value + candidate.completion, suffix: candidate.completion, requestId: candidate.request_id };
143
+ }
144
+ dismiss(report = true) {
145
+ if (report && this.state.candidate)
146
+ this.event(this.state.candidate.request_id, 'dismissed');
147
+ this.invalidate();
148
+ this.emit({ status: 'idle', candidate: null, error: null });
149
+ }
150
+ dispose() {
151
+ this.disposed = true;
152
+ this.invalidate();
153
+ this.listeners.clear();
154
+ }
155
+ }
@@ -0,0 +1,2 @@
1
+ /** Includes response-body reads and resolves even if a custom transport ignores abort. */
2
+ export declare function withDeadline<T>(run: (signal: AbortSignal) => Promise<T>, timeoutMs: number, parent?: AbortSignal): Promise<T>;
@@ -0,0 +1,25 @@
1
+ import { ClonePredictionError } from './http.js';
2
+ /** Includes response-body reads and resolves even if a custom transport ignores abort. */
3
+ export async function withDeadline(run, timeoutMs, parent) {
4
+ const controller = new AbortController();
5
+ let timer;
6
+ let cancel;
7
+ const stopped = new Promise((_, reject) => {
8
+ cancel = () => { controller.abort(); reject(new ClonePredictionError('prediction_cancelled', 499)); };
9
+ timer = setTimeout(() => {
10
+ const error = new ClonePredictionError('prediction_timeout', 504);
11
+ controller.abort(error);
12
+ reject(error);
13
+ }, timeoutMs);
14
+ parent?.addEventListener('abort', cancel, { once: true });
15
+ if (parent?.aborted)
16
+ cancel();
17
+ });
18
+ try {
19
+ return await Promise.race([stopped, controller.signal.aborted ? stopped : run(controller.signal)]);
20
+ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ parent?.removeEventListener('abort', cancel);
24
+ }
25
+ }
@@ -0,0 +1,50 @@
1
+ import type { PredictionEvent } from './types.js';
2
+ export type FeedbackEvent = Omit<PredictionEvent, 'user_id'>;
3
+ export type EventTransport = (event: FeedbackEvent, options: {
4
+ signal: AbortSignal;
5
+ }) => Promise<void>;
6
+ type Observation = {
7
+ request_id: string;
8
+ kind: 'presented' | 'accepted' | 'dismissed';
9
+ };
10
+ export type FeedbackEvaluation = Pick<FeedbackEvent, 'rating' | 'reason' | 'guidance' | 'content_opt_in'>;
11
+ export type FeedbackReceipt = {
12
+ event_id: string;
13
+ request_id: string;
14
+ kind: FeedbackEvent['kind'];
15
+ observed_at: number;
16
+ status: 'recorded' | 'failed' | 'cancelled';
17
+ };
18
+ /** Browser -> authenticated host proxy. No app key belongs in this transport. */
19
+ export declare function createEventTransport(url: string, options?: {
20
+ fetch?: typeof fetch;
21
+ requestTimeoutMs?: number;
22
+ maxConcurrentRequests?: number;
23
+ }): EventTransport;
24
+ /** Attribution is local. Text collection is off unless explicitly enabled. */
25
+ export declare class FeedbackTracker {
26
+ private readonly deliver;
27
+ private readonly options;
28
+ private accepted;
29
+ private scope;
30
+ constructor(deliver: (event: FeedbackEvent, options: {
31
+ signal: AbortSignal;
32
+ }) => unknown, options?: {
33
+ collectSubmittedText?: boolean;
34
+ onDelivery?: (receipt: FeedbackReceipt) => unknown;
35
+ });
36
+ private emit;
37
+ observe(event: Observation, before: string): void;
38
+ input(value: string): void;
39
+ /** Call only AFTER the host successfully accepts the user's explicit send. */
40
+ submitted(value: string): 'human' | 'accepted_prediction' | 'edited_prediction';
41
+ /** An explicit quality decision, separate from Escape, blur and expiry. */
42
+ rejected(requestId: string, evaluation?: FeedbackEvaluation): string;
43
+ feedback(requestId: string, evaluation: FeedbackEvaluation): string;
44
+ private evaluate;
45
+ /** Caller must observe the actual downstream task result; SDK does not infer it. */
46
+ outcome(requestId: string, result: 'succeeded' | 'failed'): string;
47
+ /** Account/thread/connection changes cancel outstanding old-scope delivery. */
48
+ reset(): void;
49
+ }
50
+ export {};
@@ -0,0 +1,118 @@
1
+ import { ClonePredictionError, readResponse } from './http.js';
2
+ import { withDeadline } from './deadline.js';
3
+ /** Browser -> authenticated host proxy. No app key belongs in this transport. */
4
+ export function createEventTransport(url, options = {}) {
5
+ const fetcher = options.fetch ?? globalThis.fetch;
6
+ const timeout = options.requestTimeoutMs ?? 2000;
7
+ const limit = options.maxConcurrentRequests ?? 4;
8
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 30_000 || !Number.isInteger(limit) || limit < 1 || limit > 32) {
9
+ throw new Error('Invalid feedback transport limits');
10
+ }
11
+ let inFlight = 0;
12
+ return async (event, { signal }) => {
13
+ if (inFlight >= limit)
14
+ throw new ClonePredictionError('event_capacity_exceeded', 503);
15
+ // Serialize once: every retry has exactly the same ID and body.
16
+ const body = JSON.stringify(event);
17
+ inFlight++;
18
+ try {
19
+ for (let attempt = 0;; attempt++) {
20
+ try {
21
+ await withDeadline(async (boundedSignal) => {
22
+ const response = await fetcher(url, { method: 'POST', credentials: 'same-origin', signal: boundedSignal,
23
+ headers: { 'Content-Type': 'application/json' }, body });
24
+ const result = await readResponse(response);
25
+ if (result.status !== 'recorded')
26
+ throw new ClonePredictionError('invalid_event_response', 502);
27
+ }, timeout, signal);
28
+ return;
29
+ }
30
+ catch (error) {
31
+ const transient = error instanceof TypeError || error instanceof ClonePredictionError
32
+ && (error.status === 429 || error.status >= 500) && !error.code.startsWith('invalid_');
33
+ if (signal.aborted || !transient || attempt >= 2)
34
+ throw error;
35
+ await withDeadline(() => new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1))), 1000, signal);
36
+ }
37
+ }
38
+ }
39
+ finally {
40
+ inFlight--;
41
+ }
42
+ };
43
+ }
44
+ /** Attribution is local. Text collection is off unless explicitly enabled. */
45
+ export class FeedbackTracker {
46
+ deliver;
47
+ options;
48
+ accepted = null;
49
+ scope = new AbortController();
50
+ constructor(deliver, options = {}) {
51
+ this.deliver = deliver;
52
+ this.options = options;
53
+ }
54
+ emit(request_id, kind, values = {}) {
55
+ const event = { ...values, event_id: crypto.randomUUID(), request_id, kind };
56
+ const signal = this.scope.signal;
57
+ const observed_at = Date.now();
58
+ const receipt = (status) => {
59
+ try {
60
+ void Promise.resolve(this.options.onDelivery?.({ event_id: event.event_id, request_id, kind, observed_at, status })).catch(() => { });
61
+ }
62
+ catch { /* diagnostics never control typing */ }
63
+ };
64
+ try {
65
+ void Promise.resolve(this.deliver(event, { signal })).then(() => receipt(signal.aborted ? 'cancelled' : 'recorded'), () => receipt(signal.aborted ? 'cancelled' : 'failed'));
66
+ }
67
+ catch {
68
+ receipt(signal.aborted ? 'cancelled' : 'failed');
69
+ }
70
+ return event.event_id;
71
+ }
72
+ observe(event, before) {
73
+ if (event.kind === 'accepted')
74
+ this.accepted = { requestId: event.request_id, before, inserted: null, edited: false };
75
+ this.emit(event.request_id, event.kind);
76
+ }
77
+ input(value) {
78
+ const accepted = this.accepted;
79
+ if (!accepted)
80
+ return;
81
+ if (accepted.inserted === null) {
82
+ accepted.inserted = value;
83
+ return;
84
+ }
85
+ if (!value.trim() || value === accepted.before) {
86
+ this.accepted = null;
87
+ return;
88
+ }
89
+ if (value !== accepted.inserted && !accepted.edited) {
90
+ accepted.edited = true;
91
+ this.emit(accepted.requestId, 'edited');
92
+ }
93
+ }
94
+ /** Call only AFTER the host successfully accepts the user's explicit send. */
95
+ submitted(value) {
96
+ const accepted = this.accepted;
97
+ this.accepted = null;
98
+ if (!accepted || accepted.inserted === null || !value.trim() || value === accepted.before)
99
+ return 'human';
100
+ const origin = value === accepted.inserted ? 'accepted_prediction' : 'edited_prediction';
101
+ this.emit(accepted.requestId, 'submitted', { submission_origin: origin,
102
+ ...(origin === 'edited_prediction' && this.options.collectSubmittedText && Array.from(value).length <= 4000
103
+ ? { final_text: value, content_opt_in: true } : {}) });
104
+ return origin;
105
+ }
106
+ /** An explicit quality decision, separate from Escape, blur and expiry. */
107
+ rejected(requestId, evaluation = {}) { return this.evaluate(requestId, 'rejected', evaluation); }
108
+ feedback(requestId, evaluation) { return this.evaluate(requestId, 'feedback', evaluation); }
109
+ evaluate(requestId, kind, evaluation) {
110
+ if (evaluation.guidance && !evaluation.content_opt_in)
111
+ throw new Error('Feedback guidance requires content_opt_in');
112
+ return this.emit(requestId, kind, evaluation);
113
+ }
114
+ /** Caller must observe the actual downstream task result; SDK does not infer it. */
115
+ outcome(requestId, result) { return this.emit(requestId, 'outcome', { outcome: result }); }
116
+ /** Account/thread/connection changes cancel outstanding old-scope delivery. */
117
+ reset() { this.accepted = null; this.scope.abort(); this.scope = new AbortController(); }
118
+ }