@usecontextlayer/pi-durable-assistant-ui 0.0.0-stage → 2.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the pi-durable-assistant-ui authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ # Pi Durable for Assistant UI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Render native Pi Durable conversations through Assistant UI. Pi owns durable history, execution, submissions and configuration; Assistant UI owns thread navigation, drafts, initialization and directory pagination.
4
+
5
+ ## Build and consume
6
+
7
+ From the repository root:
8
+
9
+ ```sh
10
+ pnpm install --frozen-lockfile
11
+ pnpm exec turbo run build --filter=@usecontextlayer/pi-durable-assistant-ui
12
+ pnpm --dir packages/adapter pack
13
+ ```
14
+
15
+ Install the resulting tarball into your application, together with React 19, `@assistant-ui/react` compatible with `^0.15.32`, and Pi Durable compatible with `^1.5.0`. Pi Durable is a peer because native branded identifiers cross the host boundary. The checkout pins its verification dependencies in pnpm-lock.yaml. The adapter builds as ESM with TypeScript declarations and does not depend on the demo's Node/SQLite host.
16
+
17
+ ```tsx
18
+ import { AssistantRuntimeProvider } from "@assistant-ui/react";
19
+ import {
20
+ createHttpPiDurableClient,
21
+ usePiDurableRuntime,
22
+ } from "@usecontextlayer/pi-durable-assistant-ui";
23
+
24
+ const client = createHttpPiDurableClient({ baseUrl: "/api" });
25
+
26
+ export function PiChat({ children }: { children: React.ReactNode }) {
27
+ const runtime = usePiDurableRuntime({ client });
28
+ return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
29
+ }
30
+ ```
31
+
32
+ Keep the client stable. `usePiDurableRuntime` returns the standard Assistant UI runtime, so thread selection and directory actions use Assistant UI's APIs and primitives. Pass a controlled native `threadId` and `onThreadIdChange` to connect navigation to a URL. New threads are framework drafts until their first send initializes a native conversation. Assistant UI's temporary local identifiers remain internal; the canonical remote ID is the string representation of the native Pi conversation ID. Forks select their own native conversation as another thread.
33
+
34
+ The remote runtime retains visited thread runtimes until its lifecycle releases them. Each initialized thread owns a `PiDurableConversation`, its native `ConversationTranscript`, conversation observation and optional metadata observation. Closing an observation never cancels agent execution. Directory pages load on demand; the adapter reads metadata only for that page and does not rescan the entire directory after every answer.
35
+
36
+ ## Native state and actions
37
+
38
+ Inside `AssistantRuntimeProvider`, `usePiDurable()` reads the current thread's `{ conversation, state }` extras. `conversation` is undefined for a draft; otherwise its ID and all its actions are bound to one native conversation. `state` contains the merged native view, metadata, transport connection/loading state and an error when one is reported. Native controls can use submission, continuation, steering, withdrawal, cancellation, configuration, forks, reset, compaction and earlier-history operations where the host supplies them.
39
+
40
+ For direct non-React observation, construct `new PiDurableConversation(client, id, pageSize)`, subscribe with `subscribe`/`getSnapshot`, then call `start` and `stop`. It has no selection method or global directory. Edit and regeneration return a native fork and target both the context write and continuation explicitly at that fork; the React integration then asks Assistant UI to select it.
41
+
42
+ ## Host contract and observation
43
+
44
+ Implement the structural `PiDurableClient` or use `createHttpPiDurableClient` with the demo protocol. Installing the adapter does not install server routes. Optional client methods describe host extensions; titles, feedback, suggestions, uploads and human input require the corresponding host implementation.
45
+
46
+ `watch` returns Pi/Chord's standard asynchronous watch handle: current immutable `value`, `start`, `stop` and terminal `closed` outcome. HTTP carries an initial view, native Chord operations, recoverable source failures and terminal outcomes over one shared SSE connection per client. Logical subscriptions are tagged independently so retained thread runtimes do not exhaust browser HTTP connection limits. It reconstructs the same watch contract in the browser using Chord. Transport reconnection status remains separate from source termination. Native acquisition failures and terminal outcomes close the source rather than retrying indefinitely. Resume windows come from the thread's native transcript.
47
+
48
+ `watchMetadata`, when supplied, observes the host's native metadata document. The demo uses a conversation document for title, feedback keyed by native entry ID and static suggestions, with checkpoints on updates. The native creation hook initializes the document for each conversation, including forks. Native conversation views do not include arbitrary custom documents. Metadata changes update mounted conversations live; an observed title change refreshes Assistant UI's directory through its reload API. Newly created conversations from another client appear on explicit directory refresh. The optional batch metadata operation accepts only the IDs in the requested directory page.
49
+
50
+ `SerializedAgentChange` carries JSON tool, extension and model-tool names; the host resolves those names into registered native definitions. Omitted fields remain unchanged and null clears a field. Host-specific registry and execution-environment choices remain outside the adapter.
51
+
52
+ ## Message semantics
53
+
54
+ The projection uses native entry IDs, joins tool results by native call ID and marks transient live messages optimistic so Assistant UI can evict them when committed entries arrive. Tool output is distinct from its UI-only artifact; native image output remains available as model content. The exported `PiDurableHumanInputDetails` artifact contract (native task ID, prompt, pending/answered status and answered response) becomes a text tool-approval request, and `onRespondToToolApproval` calls the host response operation for the native task. A response does not fabricate a browser-side tool result.
55
+
56
+ Reset and compaction notices are named `pi-context` data parts. Consumers register their own data renderer; the demo supplies one. Native entry guards classify those notices. Committed-history projection is memoized separately from live execution so unchanged message objects survive streaming updates. The pure `projectConversation` export remains available for non-React consumers.
57
+
58
+ ## Queue and unsupported operations
59
+
60
+ The standard composer can send during a run through a queue adapter backed by Pi's native inbox. Assistant UI's normal busy send steers; `aui.composer().send({ steer: false })` enqueues a follow-up. Queue items retain native submission IDs, and removing one withdraws that submission. Pi owns processing and cancellation behavior; the adapter does not drain a second queue.
61
+
62
+ Pi Durable 1.5.0 has no native queue move/edit, permanent conversation deletion, archive or unarchive operation. Required delete, archive and queue move/edit methods reject explicitly; optional unarchive is omitted so Assistant UI reports that capability as unavailable. The demo exposes none of those controls. Archive is not simulated with a custom document. Withdrawal followed by another submission remains two distinct native operations with a new ID. Unsupported operations never report success.
63
+
64
+ ## Attachments, feedback and voice
65
+
66
+ `createPiDurableAttachmentAdapter(client.uploadFile)` uses Assistant UI's attachment lifecycle and returns `CompleteAttachment`. The uploader supplies a name and native Pi input blocks; host storage paths, download URLs and content types remain host concerns. Removing a draft attachment does not delete an uploaded file from the host workspace.
67
+
68
+ Pass Assistant UI's `speech`, `dictation`, `attachments` and optional `feedback` adapters directly to the runtime hook. Without a feedback override, the native host feedback extension is used when available. Browser speech and dictation use Assistant UI's official adapters and support checks; actual microphone capture and audible playback remain device-dependent and unverified in this project.
@@ -0,0 +1,5 @@
1
+ import type { AttachmentAdapter } from "@assistant-ui/react";
2
+ import type { PiDurableUploadResult } from "./client.js";
3
+ /** Uploads at send time; the host decides which native Pi input represents the file. */
4
+ export declare function createPiDurableAttachmentAdapter(upload: (file: File, signal?: AbortSignal) => Promise<PiDurableUploadResult>): AttachmentAdapter;
5
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAsB,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,wFAAwF;AACxF,wBAAgB,gCAAgC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,qBAAqB,CAAC,GAAG,iBAAiB,CAoBhJ"}
@@ -0,0 +1,22 @@
1
+ /** Uploads at send time; the host decides which native Pi input represents the file. */
2
+ export function createPiDurableAttachmentAdapter(upload) {
3
+ return {
4
+ accept: "*",
5
+ async add({ file }) {
6
+ return { id: crypto.randomUUID(), type: file.type.startsWith("image/") ? "image" : "file", name: file.name, contentType: file.type, file, status: { type: "requires-action", reason: "composer-send" } };
7
+ },
8
+ async send(attachment, options) {
9
+ const uploadResult = await upload(attachment.file, options?.signal);
10
+ return {
11
+ ...attachment,
12
+ status: { type: "complete" },
13
+ content: uploadResult.content.map((part) => part.type === "text"
14
+ ? { type: "text", text: part.text }
15
+ : { type: "image", image: `data:${part.mimeType};base64,${part.data}`, filename: uploadResult.name }),
16
+ };
17
+ },
18
+ async remove() {
19
+ // Removing a draft attachment does not delete an ordinary file from the host's workspace.
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,88 @@
1
+ import { type Op } from "@earendil-works/chord/delta";
2
+ import { type WatchHandle, type WatchEnd } from "@earendil-works/chord";
3
+ import type { ConversationCreateOptions, ConversationId, ConversationQuery, ConversationRecord, ConversationView, ConversationWindow, ConversationAbortOptions, ContextView, AgentState, CompactionResult, Cursor, EntryQuery, EntryRecord, EntryId, Page, SubmissionDraft, SubmissionId, SubmissionRecord, TaskId, UserInput, Harness, HarnessInspection } from "@earendil-works/pi-durable";
4
+ import type { ConversationMetadata, ConversationMetadataPatch } from "./metadata.js";
5
+ /** JSON form of Pi's stored configuration; the host resolves registry names. */
6
+ export type SerializedAgentChange = {
7
+ [K in keyof AgentState]?: AgentState[K] | null;
8
+ };
9
+ export type SerializedConversationCreateOptions = Omit<ConversationCreateOptions, "agent" | "init"> & {
10
+ agent?: SerializedAgentChange;
11
+ };
12
+ export type HttpPiDurableWatchFrame<T = ConversationView> = {
13
+ type: "view";
14
+ view: T;
15
+ } | {
16
+ type: "ops";
17
+ ops: readonly Op[];
18
+ } | {
19
+ type: "failed";
20
+ error: string;
21
+ } | {
22
+ type: "end";
23
+ kind: WatchEnd["kind"];
24
+ error?: string;
25
+ };
26
+ export type HttpPiDurableWatchSubscription = {
27
+ subscriptionId: number;
28
+ id: ConversationId;
29
+ kind: "conversation" | "metadata";
30
+ window?: ConversationWindow;
31
+ };
32
+ export type HttpPiDurableWatchEnvelope = {
33
+ subscriptionId: number;
34
+ frame: HttpPiDurableWatchFrame<unknown>;
35
+ };
36
+ export type PiDurableConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
37
+ export interface PiDurableWatchOptions {
38
+ window?: () => ConversationWindow | undefined;
39
+ onConnection?: (state: PiDurableConnectionState) => void;
40
+ signal?: AbortSignal;
41
+ }
42
+ export type PiDurableHumanInputDetails = {
43
+ type: "human-input";
44
+ taskId: TaskId;
45
+ prompt: string;
46
+ } & ({
47
+ status: "pending";
48
+ } | {
49
+ status: "answered";
50
+ response: string;
51
+ });
52
+ export interface PiDurableUploadResult {
53
+ name: string;
54
+ content: Exclude<UserInput, string>;
55
+ }
56
+ /** The native operations a browser needs, with server execution context already bound. */
57
+ export interface PiDurableClient {
58
+ listConversations(query?: ConversationQuery, limit?: number, cursor?: Cursor): Promise<Page<ConversationRecord, Cursor>>;
59
+ createConversation(options?: SerializedConversationCreateOptions): Promise<ConversationRecord>;
60
+ getConversationView(id: ConversationId): Promise<ConversationView>;
61
+ entries(id: ConversationId, query: Omit<EntryQuery, "conversationId">, limit: number, cursor?: Cursor, signal?: AbortSignal): Promise<Page<EntryRecord, Cursor>>;
62
+ submit(id: ConversationId, submission: SubmissionDraft): Promise<SubmissionRecord>;
63
+ getSubmission(id: SubmissionId): Promise<SubmissionRecord>;
64
+ watch(id: ConversationId, options?: PiDurableWatchOptions): Promise<WatchHandle<ConversationView>>;
65
+ watchMetadata?(id: ConversationId, signal?: AbortSignal): Promise<WatchHandle<ConversationMetadata>>;
66
+ configure?(id: ConversationId, change: SerializedAgentChange): Promise<void>;
67
+ abort?(id: ConversationId, options?: ConversationAbortOptions): Promise<void>;
68
+ abortSubmission?(id: SubmissionId): ReturnType<Harness["abortSubmission"]>;
69
+ reset?(id: ConversationId, handoff?: string): Promise<void>;
70
+ compact?(id: ConversationId, instructions?: string): Promise<TaskId<CompactionResult>>;
71
+ fork?(id: ConversationId, at: EntryId, options?: SerializedConversationCreateOptions): Promise<ConversationRecord>;
72
+ context?(id: ConversationId, at?: EntryId): Promise<ContextView>;
73
+ uploadFile?(file: File, signal?: AbortSignal): Promise<PiDurableUploadResult>;
74
+ respondToHumanInput?(taskId: TaskId, response: string): Promise<void>;
75
+ inspect?(): Promise<HarnessInspection>;
76
+ listMetadata?(ids: readonly ConversationId[]): Promise<Record<string, ConversationMetadata>>;
77
+ getMetadata?(id: ConversationId): Promise<ConversationMetadata>;
78
+ updateMetadata?(id: ConversationId, patch: ConversationMetadataPatch): Promise<ConversationMetadata>;
79
+ setFeedback?(id: ConversationId, entryId: EntryId, rating: "positive" | "negative" | null): Promise<ConversationMetadata>;
80
+ }
81
+ export interface HttpPiDurableClientOptions {
82
+ baseUrl?: string;
83
+ fetch?: typeof globalThis.fetch;
84
+ eventSource?: (url: string) => EventSource;
85
+ reconnectDelayMs?: number;
86
+ }
87
+ export declare function createHttpPiDurableClient(options?: HttpPiDurableClientOptions): Required<PiDurableClient>;
88
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js ADDED
@@ -0,0 +1,165 @@
1
+ import { applyImmutable } from "@earendil-works/chord/delta";
2
+ import { Watch } from "@earendil-works/chord";
3
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
+ export function createHttpPiDurableClient(options = {}) {
5
+ const baseUrl = (options.baseUrl ?? "/api").replace(/\/$/, "");
6
+ const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
7
+ const openEvents = options.eventSource ?? ((url) => new EventSource(url));
8
+ const subscriptions = new Map();
9
+ let nextSubscriptionId = 0;
10
+ let source;
11
+ let retry;
12
+ let connectQueued = false;
13
+ function scheduleConnect() {
14
+ clearTimeout(retry);
15
+ if (connectQueued)
16
+ return;
17
+ connectQueued = true;
18
+ queueMicrotask(() => { connectQueued = false; connect(); });
19
+ }
20
+ function connect() {
21
+ source?.close();
22
+ source = undefined;
23
+ if (subscriptions.size === 0)
24
+ return;
25
+ for (const subscription of subscriptions.values())
26
+ subscription.connection("connecting");
27
+ const connection = openEvents(url("/watch", { subscriptions: [...subscriptions.values()].map((subscription) => subscription.request()) }));
28
+ source = connection;
29
+ connection.onmessage = (event) => {
30
+ try {
31
+ const { subscriptionId, frame } = JSON.parse(event.data);
32
+ subscriptions.get(subscriptionId)?.receive(frame);
33
+ }
34
+ catch (error) {
35
+ connection.close();
36
+ for (const subscription of subscriptions.values())
37
+ subscription.receive({ type: "end", kind: "failed", error: error instanceof Error ? error.message : String(error) });
38
+ }
39
+ };
40
+ connection.onerror = () => {
41
+ connection.close();
42
+ for (const subscription of subscriptions.values())
43
+ subscription.connection("reconnecting");
44
+ if (subscriptions.size > 0)
45
+ retry = setTimeout(connect, options.reconnectDelayMs ?? 500);
46
+ };
47
+ }
48
+ function url(path, query = {}) {
49
+ const params = new URLSearchParams();
50
+ for (const [key, value] of Object.entries(query)) {
51
+ if (value !== undefined)
52
+ params.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
53
+ }
54
+ const search = params.toString();
55
+ return `${baseUrl}${path}${search ? `?${search}` : ""}`;
56
+ }
57
+ async function request(path, init, query) {
58
+ const response = await fetcher(url(path, query), init);
59
+ if (!response.ok) {
60
+ const body = await response.text();
61
+ throw new Error(body || `Pi request failed: ${response.status} ${response.statusText}`);
62
+ }
63
+ return response.json();
64
+ }
65
+ function post(path, value) {
66
+ return request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(value) });
67
+ }
68
+ return {
69
+ listConversations: (query = {}, limit = 100, cursor) => request("/conversations", undefined, { query, limit, cursor }),
70
+ createConversation: (creation = { ownership: { kind: "ownerless" } }) => post("/conversations", creation),
71
+ getConversationView: (id) => request(`/conversations/${id}`),
72
+ entries: (id, query, limit, cursor, signal) => request(`/conversations/${id}/entries`, { signal }, { ...query, limit, cursor }),
73
+ submit: (id, submission) => post(`/conversations/${id}/submit`, submission),
74
+ getSubmission: (id) => request(`/submissions/${id}`),
75
+ configure: async (id, change) => { await post(`/conversations/${id}/configure`, change); },
76
+ abort: async (id, abortOptions = {}) => { await post(`/conversations/${id}/abort`, abortOptions); },
77
+ abortSubmission: (id) => post(`/submissions/${id}/abort`, {}),
78
+ reset: async (id, handoff) => { await post(`/conversations/${id}/reset`, { handoff }); },
79
+ compact: async (id, instructions) => (await post(`/conversations/${id}/compact`, { instructions })).taskId,
80
+ fork: (id, at, forkOptions = { ownership: { kind: "ownerless" } }) => post(`/conversations/${id}/fork`, { at, ...forkOptions }),
81
+ context: (id, at) => request(`/conversations/${id}/context`, undefined, { at }),
82
+ uploadFile: (file, signal) => request("/files", { method: "POST", headers: { "content-type": file.type || "application/octet-stream" }, body: file, signal }, { name: file.name }),
83
+ respondToHumanInput: async (taskId, response) => { await post(`/human-input/${taskId}`, { response }); },
84
+ inspect: () => request("/inspect"),
85
+ listMetadata: (ids) => post("/conversations/metadata", { ids }),
86
+ getMetadata: (id) => request(`/conversations/${id}/metadata`),
87
+ updateMetadata: (id, patch) => request(`/conversations/${id}/metadata`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) }),
88
+ setFeedback: (id, entryId, rating) => post(`/conversations/${id}/feedback`, { entryId, rating }),
89
+ watch: (id, watchOptions = {}) => openWatch(id, "conversation", watchOptions),
90
+ watchMetadata: (id, signal) => openWatch(id, "metadata", { signal }),
91
+ };
92
+ function openWatch(id, kind, watchOptions) {
93
+ return new Promise((resolve, reject) => {
94
+ const subscriptionId = ++nextSubscriptionId;
95
+ let observation;
96
+ let value;
97
+ function dispose() {
98
+ subscriptions.delete(subscriptionId);
99
+ watchOptions.signal?.removeEventListener("abort", abort);
100
+ scheduleConnect();
101
+ }
102
+ function abort() {
103
+ dispose();
104
+ if (observation)
105
+ observation.abort();
106
+ else
107
+ reject(watchOptions.signal?.reason ?? new DOMException("Observation aborted", "AbortError"));
108
+ }
109
+ subscriptions.set(subscriptionId, {
110
+ request: () => ({ subscriptionId, id, kind, window: watchOptions.window?.() }),
111
+ connection: (state) => watchOptions.onConnection?.(state === "connecting" && observation ? "reconnecting" : state),
112
+ receive(raw) {
113
+ try {
114
+ const frame = raw;
115
+ if (frame.type === "end") {
116
+ dispose();
117
+ watchOptions.onConnection?.("closed");
118
+ const error = new Error(frame.error ?? `Pi observation ended: ${frame.kind}`);
119
+ if (!observation)
120
+ reject(error);
121
+ else
122
+ observation.end(frame.kind === "failed" || frame.kind === "listener_error" ? { kind: frame.kind, error } : { kind: frame.kind });
123
+ return;
124
+ }
125
+ if (frame.type === "failed") {
126
+ if (!observation)
127
+ throw new Error(frame.error);
128
+ observation.fail(new Error(frame.error), BACKGROUND_CONTEXT);
129
+ return;
130
+ }
131
+ if (frame.type === "view")
132
+ value = frame.view;
133
+ else {
134
+ if (!observation)
135
+ throw new Error("Pi watch sent changes before its initial view");
136
+ value = applyImmutable(value, frame.ops);
137
+ }
138
+ if (!observation) {
139
+ observation = new Watch(value, dispose, console.error);
140
+ resolve(observation);
141
+ }
142
+ else {
143
+ observation.advance({ kind: "value", value, ops: frame.type === "ops" ? frame.ops : [["r", value]], retired: false }, BACKGROUND_CONTEXT);
144
+ }
145
+ watchOptions.onConnection?.("connected");
146
+ }
147
+ catch (error) {
148
+ dispose();
149
+ watchOptions.onConnection?.("closed");
150
+ if (observation)
151
+ observation.end({ kind: "failed", error });
152
+ else
153
+ reject(error);
154
+ }
155
+ },
156
+ });
157
+ if (watchOptions.signal?.aborted)
158
+ abort();
159
+ else {
160
+ watchOptions.signal?.addEventListener("abort", abort, { once: true });
161
+ scheduleConnect();
162
+ }
163
+ });
164
+ }
165
+ }
@@ -0,0 +1,44 @@
1
+ import { ConversationTranscript, type ConversationId, type EntryId, type SubmissionDraft, type TranscriptView } from "@earendil-works/pi-durable";
2
+ import type { AppendMessage } from "@assistant-ui/react";
3
+ import type { PiDurableClient, PiDurableConnectionState, SerializedAgentChange } from "./client.js";
4
+ import { type ConversationMetadata } from "./metadata.js";
5
+ export interface PiDurableConversationState {
6
+ readonly view: TranscriptView | undefined;
7
+ readonly metadata: ConversationMetadata;
8
+ readonly connection: PiDurableConnectionState;
9
+ readonly isLoading: boolean;
10
+ readonly error: Error | undefined;
11
+ }
12
+ /** Disposable observation and actions permanently bound to one native conversation. */
13
+ export declare class PiDurableConversation {
14
+ #private;
15
+ readonly client: PiDurableClient;
16
+ readonly id: ConversationId;
17
+ readonly transcript: ConversationTranscript;
18
+ constructor(client: PiDurableClient, id: ConversationId, pageSize?: number);
19
+ getSnapshot: () => PiDurableConversationState;
20
+ subscribe: (listener: () => void) => (() => void);
21
+ reportError: (error: unknown) => void;
22
+ start: () => void;
23
+ stop: () => void;
24
+ reload: () => void;
25
+ submit: (draft: SubmissionDraft) => Promise<import("@earendil-works/pi-durable").SubmissionRecord>;
26
+ continue: () => Promise<import("@earendil-works/pi-durable").SubmissionRecord>;
27
+ steer: (content: Extract<SubmissionDraft, {
28
+ type: "input";
29
+ }>["content"]) => Promise<import("@earendil-works/pi-durable").SubmissionRecord>;
30
+ followUp: (content: Extract<SubmissionDraft, {
31
+ type: "input";
32
+ }>["content"]) => Promise<import("@earendil-works/pi-durable").SubmissionRecord>;
33
+ cancel: () => Promise<void>;
34
+ abortSubmission: (id: Parameters<NonNullable<PiDurableClient["abortSubmission"]>>[0]) => Promise<"aborted" | "already_placed" | "not_found" | "settled">;
35
+ configure: (change: SerializedAgentChange) => Promise<void>;
36
+ reset: (handoff?: string) => Promise<void>;
37
+ compact: (instructions?: string) => Promise<import("@earendil-works/pi-durable").TaskId<import("@earendil-works/pi-durable").CompactionResult>>;
38
+ forkAt: (at: EntryId) => Promise<import("@earendil-works/pi-durable").ConversationRecord>;
39
+ edit: (message: AppendMessage) => Promise<import("@earendil-works/pi-durable").ConversationRecord>;
40
+ regenerate: (parentId: string | null) => Promise<import("@earendil-works/pi-durable").ConversationRecord>;
41
+ loadEarlier: () => Promise<void>;
42
+ setFeedback: (messageId: string, rating: "positive" | "negative") => Promise<void>;
43
+ }
44
+ //# sourceMappingURL=conversation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../src/conversation.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,sBAAsB,EAAE,KAAK,cAAc,EAAyB,KAAK,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACzK,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpG,OAAO,EAA+B,KAAK,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAGvF,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC;IACxC,QAAQ,CAAC,UAAU,EAAE,wBAAwB,CAAC;IAC9C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,CAAC;CACnC;AAED,uFAAuF;AACvF,qBAAa,qBAAqB;;IAMpB,QAAQ,CAAC,MAAM,EAAE,eAAe;IAAE,QAAQ,CAAC,EAAE,EAAE,cAAc;IALzE,QAAQ,CAAC,UAAU,EAAE,sBAAsB,CAAC;IAK5C,YAAqB,MAAM,EAAE,eAAe,EAAW,EAAE,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,MAAM,EAE3F;IAED,WAAW,QAAO,0BAA0B,CAAgB;IAC5D,SAAS,aAAc,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CAG9C;IAOF,WAAW,UAAW,OAAO,UAE3B;IAEF,KAAK,QAAO,IAAI,CAkCd;IAOF,IAAI,QAAO,IAAI,CAGb;IAEF,MAAM,QAAO,IAAI,CAAmC;IACpD,MAAM,UAAW,eAAe,oEAAwC;IACxE,QAAQ,uEAA2C;IACnD,KAAK,YAAa,OAAO,CAAC,eAAe,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC,SAAS,CAAC,oEAAgE;IACxI,QAAQ,YAAa,OAAO,CAAC,eAAe,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC,SAAS,CAAC,oEAAmE;IAE9I,MAAM,sBAGJ;IACF,eAAe,OAAQ,UAAU,CAAC,WAAW,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,qEAGnF;IACF,SAAS,WAAY,qBAAqB,mBAGxC;IACF,KAAK,aAAc,MAAM,mBAGvB;IACF,OAAO,kBAAmB,MAAM,iHAG9B;IACF,MAAM,OAAQ,OAAO,sEAGnB;IAEF,IAAI,YAAmB,aAAa,sEAOlC;IAEF,UAAU,aAAoB,MAAM,GAAG,IAAI,sEAUzC;IAEF,WAAW,sBAGT;IAEF,WAAW,cAAqB,MAAM,UAAU,UAAU,GAAG,UAAU,mBAGrE;CACH"}
@@ -0,0 +1,149 @@
1
+ import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
2
+ import { ConversationTranscript } from "@earendil-works/pi-durable";
3
+ import { EMPTY_CONVERSATION_METADATA } from "./metadata.js";
4
+ import { entryIdFromMessageId, projectConversation, toPiUserInput } from "./projection.js";
5
+ /** Disposable observation and actions permanently bound to one native conversation. */
6
+ export class PiDurableConversation {
7
+ client;
8
+ id;
9
+ transcript;
10
+ #listeners = new Set();
11
+ #controller;
12
+ #state = { view: undefined, metadata: EMPTY_CONVERSATION_METADATA, connection: "idle", isLoading: true, error: undefined };
13
+ constructor(client, id, pageSize) {
14
+ this.client = client;
15
+ this.id = id;
16
+ this.transcript = new ConversationTranscript((query, limit, context) => client.entries(id, query, limit, undefined, context.abortSignal), { pageSize });
17
+ }
18
+ getSnapshot = () => this.#state;
19
+ subscribe = (listener) => {
20
+ this.#listeners.add(listener);
21
+ return () => this.#listeners.delete(listener);
22
+ };
23
+ #set(patch) {
24
+ this.#state = { ...this.#state, ...patch };
25
+ for (const listener of this.#listeners)
26
+ listener();
27
+ }
28
+ reportError = (error) => {
29
+ this.#set({ error: error instanceof Error ? error : new Error(String(error)), isLoading: false });
30
+ };
31
+ start = () => {
32
+ if (this.#controller)
33
+ return;
34
+ const controller = new AbortController();
35
+ this.#controller = controller;
36
+ this.#set({ isLoading: !this.#state.view, error: undefined });
37
+ const observe = async (acquire, accept) => {
38
+ const watch = await acquire();
39
+ await accept(watch.value);
40
+ watch.start(async (change) => {
41
+ if (change.kind === "value")
42
+ await accept(change.value);
43
+ else
44
+ this.reportError(change.error);
45
+ });
46
+ const end = await watch.closed;
47
+ if (!controller.signal.aborted)
48
+ this.#ended(end);
49
+ };
50
+ void observe(() => this.client.watch(this.id, {
51
+ signal: controller.signal,
52
+ window: () => this.transcript.resume,
53
+ onConnection: (connection) => { if (!controller.signal.aborted)
54
+ this.#set({ connection }); },
55
+ }), async (view) => {
56
+ if (controller.signal.aborted)
57
+ return;
58
+ this.#set({ view: this.transcript.merge(view), isLoading: false, error: undefined });
59
+ const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT);
60
+ let gap = this.#state.view?.items.find((item) => item.kind === "gap");
61
+ while (gap?.kind === "gap" && !controller.signal.aborted) {
62
+ this.#set({ view: await this.transcript.fillGap(gap, context) });
63
+ gap = this.#state.view?.items.find((item) => item.kind === "gap");
64
+ }
65
+ }).catch((error) => { if (!controller.signal.aborted)
66
+ this.reportError(error); });
67
+ if (this.client.watchMetadata) {
68
+ void observe(() => this.client.watchMetadata(this.id, controller.signal), (metadata) => {
69
+ if (!controller.signal.aborted)
70
+ this.#set({ metadata });
71
+ }).catch((error) => { if (!controller.signal.aborted)
72
+ this.reportError(error); });
73
+ }
74
+ };
75
+ #ended(end) {
76
+ this.#set({ connection: "closed", isLoading: false });
77
+ if ("error" in end)
78
+ this.reportError(end.error);
79
+ }
80
+ stop = () => {
81
+ this.#controller?.abort();
82
+ this.#controller = undefined;
83
+ };
84
+ reload = () => { this.stop(); this.start(); };
85
+ submit = (draft) => this.client.submit(this.id, draft);
86
+ continue = () => this.submit({ type: "continue" });
87
+ steer = (content) => this.submit({ type: "input", content, whenBusy: "steer" });
88
+ followUp = (content) => this.submit({ type: "input", content, whenBusy: "followUp" });
89
+ cancel = () => {
90
+ if (!this.client.abort)
91
+ throw new Error("This Pi host does not expose cancellation");
92
+ return this.client.abort(this.id);
93
+ };
94
+ abortSubmission = (id) => {
95
+ if (!this.client.abortSubmission)
96
+ throw new Error("This Pi host does not expose submission withdrawal");
97
+ return this.client.abortSubmission(id);
98
+ };
99
+ configure = (change) => {
100
+ if (!this.client.configure)
101
+ throw new Error("This Pi host does not expose configuration");
102
+ return this.client.configure(this.id, change);
103
+ };
104
+ reset = (handoff) => {
105
+ if (!this.client.reset)
106
+ throw new Error("This Pi host does not expose reset");
107
+ return this.client.reset(this.id, handoff);
108
+ };
109
+ compact = (instructions) => {
110
+ if (!this.client.compact)
111
+ throw new Error("This Pi host does not expose compaction");
112
+ return this.client.compact(this.id, instructions);
113
+ };
114
+ forkAt = (at) => {
115
+ if (!this.client.fork)
116
+ throw new Error("This Pi host does not expose forks");
117
+ return this.client.fork(this.id, at);
118
+ };
119
+ edit = async (message) => {
120
+ if (!message.sourceId)
121
+ throw new Error("An edit must identify its original message");
122
+ const target = entryIdFromMessageId(message.sourceId);
123
+ const fork = await this.forkAt(target);
124
+ await this.client.submit(fork.id, { type: "write", entry: { kind: "assistant-ui.context-edit", edits: [{ target, action: "replace", messages: [{ role: "user", content: toPiUserInput(message), timestamp: Date.now() }] }] } });
125
+ await this.client.submit(fork.id, { type: "continue" });
126
+ return fork;
127
+ };
128
+ regenerate = async (parentId) => {
129
+ const messages = this.#state.view ? projectConversation(this.#state.view, this.#state.metadata) : [];
130
+ const index = parentId === null ? -1 : messages.findIndex((message) => message.id === parentId);
131
+ const answer = messages[index + 1];
132
+ if (!answer?.id || answer.role !== "assistant")
133
+ throw new Error("No committed assistant response follows this message");
134
+ const target = entryIdFromMessageId(answer.id);
135
+ const fork = await this.forkAt(target);
136
+ await this.client.submit(fork.id, { type: "write", entry: { kind: "assistant-ui.context-edit", edits: [{ target, action: "omit" }] } });
137
+ await this.client.submit(fork.id, { type: "continue" });
138
+ return fork;
139
+ };
140
+ loadEarlier = async () => {
141
+ const context = this.#controller ? withAbortSignal(this.#controller.signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT;
142
+ this.#set({ view: await this.transcript.loadEarlier(context) });
143
+ };
144
+ setFeedback = async (messageId, rating) => {
145
+ if (!this.client.setFeedback)
146
+ throw new Error("This Pi host does not expose feedback");
147
+ this.#set({ metadata: await this.client.setFeedback(this.id, entryIdFromMessageId(messageId), rating) });
148
+ };
149
+ }
@@ -0,0 +1,12 @@
1
+ export { createHttpPiDurableClient } from "./client.js";
2
+ export type { PiDurableClient, HttpPiDurableWatchFrame, HttpPiDurableWatchEnvelope, HttpPiDurableWatchSubscription, PiDurableWatchOptions, PiDurableConnectionState, PiDurableUploadResult, PiDurableHumanInputDetails, HttpPiDurableClientOptions, SerializedAgentChange, SerializedConversationCreateOptions } from "./client.js";
3
+ export { createPiDurableAttachmentAdapter } from "./attachments.js";
4
+ export type { ConversationMetadata, ConversationMetadataPatch } from "./metadata.js";
5
+ export { conversationInbox, conversationLiveState, entryIdFromMessageId, isForegroundRunning, projectConversation, toPiUserInput } from "./projection.js";
6
+ export { PiDurableConversation } from "./conversation.js";
7
+ export type { PiDurableConversationState } from "./conversation.js";
8
+ export { createPiDurableThreadListAdapter } from "./thread-list.js";
9
+ export { usePiDurableRuntime, usePiDurable } from "./use-pi-durable-runtime.js";
10
+ export type { UsePiDurableRuntimeOptions, PiDurableRuntimeExtras } from "./use-pi-durable-runtime.js";
11
+ export type { ConversationId, ConversationRecord, ConversationView, TranscriptView, SubmissionDraft, SubmissionId, SubmissionRecord, EntryId, AgentChange, AgentState, InboxState, HarnessInspection } from "@earendil-works/pi-durable";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,YAAY,EAAE,eAAe,EAAE,uBAAuB,EAAE,0BAA0B,EAAE,8BAA8B,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,0BAA0B,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,mCAAmC,EAAE,MAAM,aAAa,CAAC;AACpU,OAAO,EAAE,gCAAgC,EAAE,MAAM,kBAAkB,CAAC;AACpE,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AACrF,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC1J,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,YAAY,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,EAAE,gCAAgC,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChF,YAAY,EAAE,0BAA0B,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACtG,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createHttpPiDurableClient } from "./client.js";
2
+ export { createPiDurableAttachmentAdapter } from "./attachments.js";
3
+ export { conversationInbox, conversationLiveState, entryIdFromMessageId, isForegroundRunning, projectConversation, toPiUserInput } from "./projection.js";
4
+ export { PiDurableConversation } from "./conversation.js";
5
+ export { createPiDurableThreadListAdapter } from "./thread-list.js";
6
+ export { usePiDurableRuntime, usePiDurable } from "./use-pi-durable-runtime.js";
@@ -0,0 +1,10 @@
1
+ import type { EntryId } from "@earendil-works/pi-durable";
2
+ /** Presentation data persisted by the host in a native conversation document. */
3
+ export type ConversationMetadata = {
4
+ title: string | null;
5
+ feedback: Record<EntryId, "positive" | "negative">;
6
+ suggestions: string[];
7
+ };
8
+ export type ConversationMetadataPatch = Partial<Pick<ConversationMetadata, "title" | "suggestions">>;
9
+ export declare const EMPTY_CONVERSATION_METADATA: ConversationMetadata;
10
+ //# sourceMappingURL=metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAC;AAE1D,iFAAiF;AACjF,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,UAAU,CAAC,CAAC;IACnD,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,aAAa,CAAC,CAAC,CAAC;AAErG,eAAO,MAAM,2BAA2B,EAAE,oBAIzC,CAAC"}
@@ -0,0 +1,5 @@
1
+ export const EMPTY_CONVERSATION_METADATA = {
2
+ title: null,
3
+ feedback: {},
4
+ suggestions: [],
5
+ };
@@ -0,0 +1,27 @@
1
+ import type { AppendMessage, ThreadMessageLike } from "@assistant-ui/react";
2
+ import type { EntryId, InboxState, LiveState, TranscriptView, UserInput } from "@earendil-works/pi-durable";
3
+ import type { ConversationMetadata } from "./metadata.js";
4
+ type ContentPart = Exclude<ThreadMessageLike["content"], string>[number];
5
+ type ToolPart = Extract<ContentPart, {
6
+ type: "tool-call";
7
+ }>;
8
+ type MutableMessage = Omit<ThreadMessageLike, "content" | "status"> & {
9
+ content: ContentPart[];
10
+ status?: ThreadMessageLike["status"];
11
+ };
12
+ export declare function conversationLiveState(view: Pick<TranscriptView, "docs">): LiveState;
13
+ export declare function conversationInbox(view: Pick<TranscriptView, "docs">): InboxState;
14
+ export declare function entryIdFromMessageId(id: string): EntryId;
15
+ /** Foreground generation is distinct from a run's after-run/background work. */
16
+ export declare function isForegroundRunning(view: Pick<TranscriptView, "docs">): boolean;
17
+ export declare function projectConversationHistory(items: TranscriptView["items"], conversationMetadata?: ConversationMetadata): {
18
+ messages: MutableMessage[];
19
+ calls: Map<string, ToolPart>;
20
+ };
21
+ export declare function projectConversationLive(history: ReturnType<typeof projectConversationHistory>, live: LiveState, conversationId: TranscriptView["conversation"]["id"]): readonly ThreadMessageLike[];
22
+ /** Pure projection of Pi history and current execution; Pi retains the durable originals. */
23
+ export declare function projectConversation(view: TranscriptView, metadata?: ConversationMetadata): readonly ThreadMessageLike[];
24
+ /** Convert only the user's new input; never send the rendered transcript back to Pi. */
25
+ export declare function toPiUserInput(message: AppendMessage): UserInput;
26
+ export {};
27
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAG5E,OAAO,KAAK,EAAe,OAAO,EAAe,UAAU,EAAE,SAAS,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACtI,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAE1D,KAAK,WAAW,GAAG,OAAO,CAAC,iBAAiB,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AACzE,KAAK,QAAQ,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC,CAAC;AAC5D,KAAK,cAAc,GAAG,IAAI,CAAC,iBAAiB,EAAE,SAAS,GAAG,QAAQ,CAAC,GAAG;IAAE,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAA;CAAE,CAAC;AAEvI,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,GAAG,SAAS,CAEnF;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,GAAG,UAAU,CAEhF;AAED,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,GAAG,OAAO,CAG/E;AAuCD,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,cAAc,CAAC,OAAO,CAAC,EAAE,oBAAoB,CAAC,EAAE,oBAAoB;;;EA+CrH;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,0BAA0B,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,cAAc,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC,IAAI,CAAC,GAAG,SAAS,iBAAiB,EAAE,CAmCnM;AAED,6FAA6F;AAC7F,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,oBAAoB,GAAG,SAAS,iBAAiB,EAAE,CAEvH;AASD,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,CAY/D"}
@@ -0,0 +1,174 @@
1
+ import { CompactionEntry, ResetEntry, SystemEntry } from "@earendil-works/pi-durable";
2
+ export function conversationLiveState(view) {
3
+ return (view.docs["pi.live"] ?? {});
4
+ }
5
+ export function conversationInbox(view) {
6
+ return (view.docs["pi.inbox"] ?? { items: [] });
7
+ }
8
+ export function entryIdFromMessageId(id) {
9
+ const match = /^entry:(\d+):(?:\d+|notice)$/.exec(id);
10
+ if (!match || !Number.isSafeInteger(Number(match[1])))
11
+ throw new Error(`Message ${id} is not a committed Pi entry`);
12
+ return Number(match[1]);
13
+ }
14
+ /** Foreground generation is distinct from a run's after-run/background work. */
15
+ export function isForegroundRunning(view) {
16
+ const live = conversationLiveState(view);
17
+ return live.run !== undefined && live.run.afterRun === undefined;
18
+ }
19
+ function userContent(content) {
20
+ if (typeof content === "string")
21
+ return [{ type: "text", text: content }];
22
+ return content.map((part) => part.type === "text"
23
+ ? { type: "text", text: part.text }
24
+ : { type: "image", image: `data:${part.mimeType};base64,${part.data}` });
25
+ }
26
+ function assistantContent(message, calls) {
27
+ return message.content.map((part) => {
28
+ if (part.type === "text")
29
+ return { type: "text", text: part.text };
30
+ if (part.type === "thinking")
31
+ return { type: "reasoning", text: part.thinking };
32
+ const tool = { type: "tool-call", toolCallId: part.id, toolName: part.name, args: part.arguments };
33
+ calls.set(part.id, tool);
34
+ return tool;
35
+ });
36
+ }
37
+ function applyToolResult(tool, message) {
38
+ Object.assign(tool, {
39
+ artifact: message.details,
40
+ result: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
41
+ isError: message.isError,
42
+ isPreliminary: false,
43
+ approval: humanInputApproval(message.details),
44
+ modelContent: message.content.map((part) => part.type === "text"
45
+ ? { type: "text", text: part.text }
46
+ : { type: "file", mediaType: part.mimeType, data: part.data }),
47
+ });
48
+ }
49
+ function messageStatus(message) {
50
+ if (message.stopReason === "error")
51
+ return { type: "incomplete", reason: "error", error: message.errorMessage ?? "Model request failed" };
52
+ if (message.stopReason === "aborted")
53
+ return { type: "incomplete", reason: "cancelled" };
54
+ if (message.stopReason === "length")
55
+ return { type: "incomplete", reason: "length" };
56
+ return { type: "complete", reason: "stop" };
57
+ }
58
+ export function projectConversationHistory(items, conversationMetadata) {
59
+ const messages = [];
60
+ const calls = new Map();
61
+ const edits = new Map();
62
+ for (const item of items) {
63
+ if (item.kind === "entry") {
64
+ for (const change of item.entry.edits ?? [])
65
+ edits.set(change.target, { change, by: item.entry.id });
66
+ }
67
+ }
68
+ function append(entry, message, index) {
69
+ const metadata = { custom: { conversationId: entry.conversationId, entryId: entry.id, entryKind: entry.kind, editEntryId: edits.get(entry.id)?.by } };
70
+ const id = `entry:${entry.id}:${index}`;
71
+ if (message.role === "user") {
72
+ messages.push({ id, role: "user", content: userContent(message.content), createdAt: new Date(message.timestamp), metadata });
73
+ }
74
+ else if (message.role === "assistant") {
75
+ const feedback = conversationMetadata?.feedback[entry.id];
76
+ messages.push({ id, role: "assistant", content: assistantContent(message, calls), status: messageStatus(message), createdAt: new Date(message.timestamp), metadata: { ...metadata, ...(feedback ? { submittedFeedback: { type: feedback } } : {}) } });
77
+ }
78
+ else if (message.role === "toolResult") {
79
+ let tool = calls.get(message.toolCallId);
80
+ if (!tool) {
81
+ tool = { type: "tool-call", toolCallId: message.toolCallId, toolName: message.toolName, args: {} };
82
+ calls.set(message.toolCallId, tool);
83
+ messages.push({ id, role: "assistant", content: [tool], metadata });
84
+ }
85
+ applyToolResult(tool, message);
86
+ }
87
+ }
88
+ for (const item of items) {
89
+ if (item.kind !== "entry")
90
+ continue;
91
+ const entry = item.entry;
92
+ if (SystemEntry.is(entry))
93
+ continue;
94
+ if (CompactionEntry.is(entry) || ResetEntry.is(entry)) {
95
+ messages.push({
96
+ id: `entry:${entry.id}:notice`,
97
+ role: "assistant",
98
+ content: [{ type: "data", name: "pi-context", data: { kind: entry.kind, data: entry.data } }],
99
+ metadata: { custom: { conversationId: entry.conversationId, entryId: entry.id, entryKind: entry.kind, entryData: entry.data } },
100
+ });
101
+ }
102
+ const edit = edits.get(entry.id)?.change;
103
+ const model = edit ? (edit.action === "replace" ? edit.messages : []) : (entry.model ?? []);
104
+ for (const [index, message] of model.entries())
105
+ append(entry, message, index);
106
+ }
107
+ return { messages, calls };
108
+ }
109
+ export function projectConversationLive(history, live, conversationId) {
110
+ const messages = [...history.messages];
111
+ const calls = new Map(history.calls);
112
+ if (live.generation?.message) {
113
+ const message = live.generation.message;
114
+ messages.push({
115
+ id: `generation:${live.run?.taskId ?? conversationId}`,
116
+ role: "assistant",
117
+ content: assistantContent(message, calls),
118
+ status: { type: "running" },
119
+ createdAt: new Date(message.timestamp),
120
+ metadata: { isOptimistic: true, custom: { conversationId, taskId: live.run?.taskId } },
121
+ });
122
+ }
123
+ for (const slot of live.tools ?? []) {
124
+ let tool = calls.get(slot.callId);
125
+ if (!tool) {
126
+ tool = { type: "tool-call", toolCallId: slot.callId, toolName: slot.name, args: {} };
127
+ calls.set(slot.callId, tool);
128
+ messages.push({ id: `tool:${slot.callId}`, role: "assistant", content: [tool], status: { type: "running" }, metadata: { isOptimistic: true } });
129
+ }
130
+ if (slot.status !== "done") {
131
+ const ownerIndex = messages.findIndex((message) => message.content.includes(tool));
132
+ const owner = messages[ownerIndex];
133
+ const updated = { ...tool, result: slot.output, artifact: slot.details, isPreliminary: true, approval: humanInputApproval(slot.details) };
134
+ calls.set(slot.callId, updated);
135
+ messages[ownerIndex] = { ...owner, content: owner.content.map((part) => part === tool ? updated : part), status: { type: "running" } };
136
+ }
137
+ }
138
+ const failure = live.runFailure;
139
+ const lastStatus = messages.at(-1)?.status;
140
+ if (failure && failure.reason !== "aborted" && failure.reason !== "reset" && failure.reason !== "nothing_to_answer" && !(lastStatus?.type === "incomplete" && lastStatus.reason === "error")) {
141
+ messages.push({ id: `failure:${failure.taskId}`, role: "assistant", content: [], status: { type: "incomplete", reason: "error", error: failure.detail ?? failure.reason }, metadata: { isOptimistic: true, custom: { conversationId, taskId: failure.taskId } } });
142
+ }
143
+ return messages;
144
+ }
145
+ /** Pure projection of Pi history and current execution; Pi retains the durable originals. */
146
+ export function projectConversation(view, metadata) {
147
+ return projectConversationLive(projectConversationHistory(view.items, metadata), conversationLiveState(view), view.conversation.id);
148
+ }
149
+ function humanInputApproval(value) {
150
+ if (value === null || typeof value !== "object")
151
+ return undefined;
152
+ const details = value;
153
+ if (details.type !== "human-input" || typeof details.taskId !== "number" || typeof details.prompt !== "string" || (details.status !== "pending" && details.status !== "answered"))
154
+ return undefined;
155
+ return { id: String(details.taskId), prompt: details.prompt, display: "text", ...(details.status === "answered" ? { approved: true, text: details.response } : {}) };
156
+ }
157
+ /** Convert only the user's new input; never send the rendered transcript back to Pi. */
158
+ export function toPiUserInput(message) {
159
+ const content = [];
160
+ const parts = [...message.content, ...(message.attachments ?? []).flatMap((attachment) => attachment.content)];
161
+ for (const part of parts) {
162
+ if (part.type === "text")
163
+ content.push({ type: "text", text: part.text });
164
+ else if (part.type === "image") {
165
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(part.image);
166
+ if (!match)
167
+ throw new Error("Image input must be a base64 data URL");
168
+ content.push({ type: "image", mimeType: match[1], data: match[2] });
169
+ }
170
+ else
171
+ throw new Error(`Unsupported Pi input part: ${part.type}`);
172
+ }
173
+ return content;
174
+ }
@@ -0,0 +1,6 @@
1
+ import type { RemoteThreadListAdapter } from "@assistant-ui/react";
2
+ import type { ConversationId } from "@earendil-works/pi-durable";
3
+ import type { PiDurableClient } from "./client.js";
4
+ export declare function nativeConversationId(value: string): ConversationId;
5
+ export declare function createPiDurableThreadListAdapter(client: PiDurableClient, pageSize?: number): RemoteThreadListAdapter;
6
+ //# sourceMappingURL=thread-list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread-list.d.ts","sourceRoot":"","sources":["../src/thread-list.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAU,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAKnD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAIlE;AAED,wBAAgB,gCAAgC,CAAC,MAAM,EAAE,eAAe,EAAE,QAAQ,SAAK,GAAG,uBAAuB,CA8BhH"}
@@ -0,0 +1,39 @@
1
+ export function nativeConversationId(value) {
2
+ const id = Number(value);
3
+ if (!Number.isSafeInteger(id) || id <= 0)
4
+ throw new Error(`Invalid Pi conversation ID: ${value}`);
5
+ return id;
6
+ }
7
+ export function createPiDurableThreadListAdapter(client, pageSize = 50) {
8
+ const metadata = (id, details) => ({
9
+ remoteId: String(id), status: "regular", title: details?.title ?? `Conversation ${id}`,
10
+ });
11
+ return {
12
+ async list({ after } = {}) {
13
+ const page = await client.listConversations({}, pageSize, after ? JSON.parse(after) : undefined);
14
+ const details = client.listMetadata ? await client.listMetadata(page.items.map((item) => item.id)) : undefined;
15
+ const threads = await Promise.all(page.items.map(async ({ id }) => metadata(id, details?.[String(id)] ?? await client.getMetadata?.(id))));
16
+ return { threads, ...(page.next ? { nextCursor: JSON.stringify(page.next) } : {}) };
17
+ },
18
+ async initialize() {
19
+ return { remoteId: String((await client.createConversation()).id) };
20
+ },
21
+ async fetch(remoteId) {
22
+ const id = nativeConversationId(remoteId);
23
+ if (client.getMetadata)
24
+ return metadata(id, await client.getMetadata(id));
25
+ await client.getConversationView(id);
26
+ return metadata(id);
27
+ },
28
+ async rename(remoteId, title) {
29
+ if (!client.updateMetadata)
30
+ throw new Error("This Pi host does not expose conversation titles");
31
+ await client.updateMetadata(nativeConversationId(remoteId), { title });
32
+ },
33
+ async archive() { throw new Error("Pi Durable does not support archiving conversations."); },
34
+ async delete() { throw new Error("Pi Durable does not support deleting conversations."); },
35
+ async generateTitle() {
36
+ return new ReadableStream({ start(controller) { controller.close(); } });
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,22 @@
1
+ import { type AssistantRuntime, type AttachmentAdapter, type DictationAdapter, type FeedbackAdapter, type SpeechSynthesisAdapter } from "@assistant-ui/react";
2
+ import type { ConversationId } from "@earendil-works/pi-durable";
3
+ import type { PiDurableClient } from "./client.js";
4
+ import { PiDurableConversation, type PiDurableConversationState } from "./conversation.js";
5
+ export interface UsePiDurableRuntimeOptions {
6
+ client: PiDurableClient;
7
+ threadId?: ConversationId;
8
+ onThreadIdChange?: (id: ConversationId | undefined) => void;
9
+ pageSize?: number;
10
+ attachments?: AttachmentAdapter;
11
+ speech?: SpeechSynthesisAdapter;
12
+ dictation?: DictationAdapter;
13
+ feedback?: FeedbackAdapter;
14
+ }
15
+ export interface PiDurableRuntimeExtras {
16
+ readonly conversation: PiDurableConversation | undefined;
17
+ readonly state: PiDurableConversationState;
18
+ }
19
+ /** Read Pi-specific state/actions from the current Assistant UI thread scope. */
20
+ export declare function usePiDurable(): PiDurableRuntimeExtras;
21
+ export declare function usePiDurableRuntime(options: UsePiDurableRuntimeOptions): AssistantRuntime;
22
+ //# sourceMappingURL=use-pi-durable-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-pi-durable-runtime.d.ts","sourceRoot":"","sources":["../src/use-pi-durable-runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAAgG,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAmC,KAAK,eAAe,EAAuB,KAAK,sBAAsB,EAA0B,MAAM,qBAAqB,CAAC;AAC1U,OAAO,KAAK,EAAE,cAAc,EAAU,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,KAAK,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAK3F,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,eAAe,CAAC;IACxB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,cAAc,GAAG,SAAS,KAAK,IAAI,CAAC;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAChC,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,YAAY,EAAE,qBAAqB,GAAG,SAAS,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,0BAA0B,CAAC;CAC5C;AASD,iFAAiF;AACjF,wBAAgB,YAAY,IAAI,sBAAsB,CAErD;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,gBAAgB,CASzF"}
@@ -0,0 +1,95 @@
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
+ import { useAui, useAuiState, useExternalStoreRuntime, useRemoteThreadListRuntime } from "@assistant-ui/react";
3
+ import { PiDurableConversation } from "./conversation.js";
4
+ import { EMPTY_CONVERSATION_METADATA } from "./metadata.js";
5
+ import { conversationInbox, conversationLiveState, isForegroundRunning, projectConversationHistory, projectConversationLive, toPiUserInput } from "./projection.js";
6
+ import { createPiDurableThreadListAdapter, nativeConversationId } from "./thread-list.js";
7
+ const DRAFT_STATE = { view: undefined, metadata: EMPTY_CONVERSATION_METADATA, connection: "idle", isLoading: false, error: undefined };
8
+ const EMPTY_SUBSCRIPTION = () => () => { };
9
+ const DRAFT_SNAPSHOT = () => DRAFT_STATE;
10
+ const DRAFT_EXTRAS = { conversation: undefined, state: DRAFT_STATE };
11
+ const identity = (message) => message;
12
+ const EMPTY_HISTORY = projectConversationHistory([]);
13
+ /** Read Pi-specific state/actions from the current Assistant UI thread scope. */
14
+ export function usePiDurable() {
15
+ return useAuiState((state) => state.thread.extras ?? DRAFT_EXTRAS);
16
+ }
17
+ export function usePiDurableRuntime(options) {
18
+ const { client, pageSize, threadId, onThreadIdChange } = options;
19
+ const adapter = useMemo(() => createPiDurableThreadListAdapter(client, pageSize), [client, pageSize]);
20
+ return useRemoteThreadListRuntime({
21
+ adapter,
22
+ threadId: threadId === undefined ? undefined : String(threadId),
23
+ onThreadIdChange: (id) => onThreadIdChange?.(id === undefined ? undefined : nativeConversationId(id)),
24
+ runtimeHook: () => usePiDurableThreadRuntime(options),
25
+ });
26
+ }
27
+ function usePiDurableThreadRuntime({ client, pageSize, attachments, speech, dictation, feedback }) {
28
+ const aui = useAui();
29
+ const remoteId = useAuiState((state) => state.threadListItem.remoteId);
30
+ const conversation = useMemo(() => remoteId === undefined ? undefined : new PiDurableConversation(client, nativeConversationId(remoteId), pageSize), [client, remoteId, pageSize]);
31
+ const state = useSyncExternalStore(conversation?.subscribe ?? EMPTY_SUBSCRIPTION, conversation?.getSnapshot ?? DRAFT_SNAPSHOT, conversation?.getSnapshot ?? DRAFT_SNAPSHOT);
32
+ const [sendError, setSendError] = useState();
33
+ useEffect(() => { conversation?.start(); return () => conversation?.stop(); }, [conversation]);
34
+ const history = useMemo(() => state.view ? projectConversationHistory(state.view.items, state.metadata) : EMPTY_HISTORY, [state.view?.items, state.metadata.feedback]);
35
+ const live = state.view ? conversationLiveState(state.view) : undefined;
36
+ const messages = useMemo(() => state.view && live ? projectConversationLive(history, live, state.view.conversation.id) : [], [history, live, state.view?.conversation.id]);
37
+ const isRunning = state.view ? isForegroundRunning(state.view) : false;
38
+ useEffect(() => {
39
+ if (!remoteId || state.metadata.title === null)
40
+ return;
41
+ if (aui.threadListItem().getState().title !== state.metadata.title) {
42
+ void aui.threads().reload().catch((error) => setSendError(error instanceof Error ? error : new Error(String(error))));
43
+ }
44
+ }, [aui, remoteId, state.metadata.title]);
45
+ const send = async (message, whenBusy) => {
46
+ setSendError(undefined);
47
+ const { remoteId: id } = await aui.threadListItem().initialize();
48
+ await client.submit(nativeConversationId(id), { type: "input", content: toPiUserInput(message), whenBusy });
49
+ };
50
+ const reportSendError = (error) => setSendError(error instanceof Error ? error : new Error(String(error)));
51
+ const queue = useMemo(() => {
52
+ if (!client.abortSubmission)
53
+ return undefined;
54
+ const items = state.view ? conversationInbox(state.view).items : [];
55
+ const convert = (mode) => items.flatMap((item) => {
56
+ if (item.mode !== mode || !("content" in item))
57
+ return [];
58
+ const content = typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content;
59
+ const parts = content.map((part) => part.type === "text" ? part : { type: "file", mimeType: part.mimeType, data: part.data });
60
+ return [{ id: String(item.id), prompt: content.filter((part) => part.type === "text").map((part) => part.text).join("\n"), parts }];
61
+ });
62
+ return {
63
+ items: convert("followUp"), steerItems: convert("steer"),
64
+ enqueue: (message) => { void send(message, "followUp").catch(reportSendError); },
65
+ steer: (message) => { void send(message, "steer").catch(reportSendError); },
66
+ remove: (id) => { void client.abortSubmission(Number(id)).catch(reportSendError); },
67
+ move: () => { throw new Error("Pi Durable does not support moving queued submissions."); },
68
+ edit: () => { throw new Error("Pi Durable does not support editing queued submissions."); },
69
+ };
70
+ }, [client, remoteId, state.view?.docs["pi.inbox"]]);
71
+ const nativeFeedback = useMemo(() => conversation && client.setFeedback ? { submit: ({ message, type }) => conversation.setFeedback(message.id, type) } : undefined, [conversation, client]);
72
+ const extras = useMemo(() => ({ conversation, state: sendError ? { ...state, error: sendError } : state }), [conversation, state, sendError]);
73
+ return useExternalStoreRuntime({
74
+ messages,
75
+ convertMessage: identity,
76
+ isRunning,
77
+ isLoading: state.isLoading,
78
+ hasEarlier: state.view?.items[0]?.kind === "earlier",
79
+ onLoadEarlier: conversation?.loadEarlier,
80
+ onRefetchThread: conversation ? async () => { conversation.reload(); } : undefined,
81
+ onNew: send,
82
+ onCancel: conversation && client.abort ? conversation.cancel : undefined,
83
+ onEdit: conversation && client.fork ? async (message) => { const fork = await conversation.edit(message); await aui.threads().switchToThread(String(fork.id)); } : undefined,
84
+ onReload: conversation && client.fork ? async (parentId) => { const fork = await conversation.regenerate(parentId); await aui.threads().switchToThread(String(fork.id)); } : undefined,
85
+ onRespondToToolApproval: client.respondToHumanInput ? async ({ approvalId, text }) => {
86
+ if (text === undefined)
87
+ throw new Error("This human-input request requires a text response");
88
+ await client.respondToHumanInput(Number(approvalId), text);
89
+ } : undefined,
90
+ suggestions: state.metadata.suggestions.map((prompt) => ({ prompt })),
91
+ queue,
92
+ extras,
93
+ adapters: { attachments, speech, dictation, feedback: feedback ?? nativeFeedback },
94
+ });
95
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@usecontextlayer/pi-durable-assistant-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "license": "MIT",
5
+ "description": "Assistant UI runtime adapter for Pi Durable conversations",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/usecontextlayer/pi-durable-assistant-ui.git",
9
+ "directory": "packages/adapter"
10
+ },
11
+ "homepage": "https://github.com/usecontextlayer/pi-durable-assistant-ui#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/usecontextlayer/pi-durable-assistant-ui/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "scripts": {
29
+ "build": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\" && tsc -p tsconfig.json",
30
+ "check": "tsc -p tsconfig.json --noEmit",
31
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
32
+ },
33
+ "peerDependencies": {
34
+ "@assistant-ui/react": "^0.15.32",
35
+ "react": "^19.0.0",
36
+ "@earendil-works/pi-durable": "^1.5.0"
37
+ },
38
+ "dependencies": {
39
+ "@earendil-works/chord": "1.5.0",
40
+ "@earendil-works/pi-ai": "1.5.0"
41
+ },
42
+ "devDependencies": {
43
+ "@assistant-ui/react": "0.15.32",
44
+ "react": "19.3.1",
45
+ "@earendil-works/pi-durable": "1.5.0"
46
+ }
47
+ }