@usecontextlayer/pi-durable-assistant-ui 0.0.0-stage → 1.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,83 @@
1
- # Temporary Holding Version
1
+ <div align="center">
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
+ # Pi Durable for Assistant UI
4
+
5
+ Render [Pi Durable](https://github.com/earendil-works/pi/tree/v1.5.0/packages/durable) conversations with [Assistant UI](https://www.assistant-ui.com) while Pi owns history and execution.
6
+
7
+ </div>
8
+
9
+ ```tsx
10
+ import type { ReactNode } from "react";
11
+ import { AssistantRuntimeProvider } from "@assistant-ui/react";
12
+ import { createHttpPiClient, usePiRuntime } from "@usecontextlayer/pi-durable-assistant-ui";
13
+ const client = createHttpPiClient({ baseUrl: "/api" });
14
+ ```
15
+
16
+ ```tsx
17
+ export function PiChat({ children }: { children: ReactNode }) {
18
+ const { runtime } = usePiRuntime({ client });
19
+ return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
20
+ }
21
+ ```
22
+
23
+ - **Use Assistant UI components.** The hook supplies an `ExternalStoreRuntime` for your thread, composer, and conversation list.
24
+ - **Keep Pi as the source of truth.** One thread maps to one native conversation. The browser stores no second durable transcript and runs no model.
25
+ - **Reconnect to ongoing work.** Streaming, earlier history, and reconnect gaps use native Pi views and `ConversationTranscript`. Closing a subscription does not cancel execution.
26
+ - **Choose your host.** Implement `PiClient` or use the included HTTP/SSE client with the demo's protocol. The adapter has no dependency on the demo's Node/SQLite host.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pnpm add @usecontextlayer/pi-durable-assistant-ui @assistant-ui/react@0.15.32 react@19 react-dom@19
32
+ ```
33
+
34
+ The package builds as ESM with TypeScript declarations. It requires React 19 and `@assistant-ui/react` 0.15.32; Pi Durable, Pi AI, and Chord are pinned to 1.5.0. Keep the client instance stable, as in the example above, and render your Assistant UI components inside `PiChat`. This package supplies the runtime integration, not the demo's components or server.
35
+
36
+ ## Build from source
37
+
38
+ From the workspace root, build and pack it:
39
+
40
+ ```sh
41
+ pnpm exec turbo run build --filter=@usecontextlayer/pi-durable-assistant-ui
42
+ pnpm --dir packages/adapter pack
43
+ ```
44
+
45
+ Install the resulting tarball in your application and supply the React and Assistant UI peers.
46
+
47
+ ## Runtime and actions
48
+
49
+ `usePiRuntime` returns `{ runtime, state, actions }`. `state` exposes the native conversation directory, selected ID, merged transcript view, rendered messages, running/connection/loading state, errors, and metadata. `actions` exposes conversation selection and creation, earlier-history loading, submissions, continuation, steering, withdrawal, configuration, cancellation, forks, reset, and compaction where the client supports them.
50
+
51
+ Pass `initialConversationId` to reopen a native conversation, for example an ID saved in your URL. It applies when the store is constructed; later navigation uses `actions.selectConversation(id)`. `whenBusy` selects Pi's `followUp`, `steer`, or `reject` submission behavior.
52
+
53
+ For use outside React, `PiRuntimeStore` exposes the same actions with `start`, `stop`, `subscribe`, and `getSnapshot`. `projectConversation` supplies the pure message projection.
54
+
55
+ ## Host contract
56
+
57
+ `PiClient` is a structural interface whose optional methods describe host capabilities. Assistant UI callbacks are enabled only when the corresponding capability exists. The included `createHttpPiClient` returns `Required<PiClient>` and expects the demo's native-operation routes: an ordinary Pi host does not acquire those routes simply by installing this package.
58
+
59
+ The HTTP watch carries an initial `ConversationView` and subsequent Chord operations over SSE. Pi's `ConversationTranscript` merges the bounded live view with earlier history and supplies reconnect windows. The host owns execution independently of each browser's watch.
60
+
61
+ Conversation and entry IDs remain Pi's IDs; their string representations are UI keys. Live generation uses a native task-derived key until its immutable entry arrives. Projection matches tool results by native call ID, retains partial output, separates foreground generation from after-run activity, and leaves internal instruction entries out of chat. Raw history remains available through `state.view`.
62
+
63
+ `SerializedAgentChange` carries JSON configuration with registered tool, extension, and model-tool names. The host resolves those names before native configuration; omitted fields are unchanged and `null` clears a field.
64
+
65
+ ## Edits and forks
66
+
67
+ Editing forks at the original input, records a native context replacement, and continues the new conversation. Regeneration forks at the selected answer, omits it from the fork's model context through a native context edit, and continues. The original immutable history stays available.
68
+
69
+ Each native fork is a separate thread. No hidden in-thread branch store or `setMessages` callback overwrites Pi history.
70
+
71
+ ## Attachments and extensions
72
+
73
+ Pass `createPiAttachmentAdapter(client.uploadFile)` as the hook's `attachments` option when the host supports uploads. Uploads happen on send. `PiUploadResult` requires only `name` and `content`, an array of native Pi input blocks: images can remain image blocks; other files can be paths readable by native tools. The adapter returns Assistant UI's standard `CompleteAttachment`. The host owns storage; paths, download URLs, sizes and content types are not required by the adapter. Removing a draft attachment does not delete an uploaded workspace file.
74
+
75
+ The hook accepts Assistant UI's `speech`, `dictation`, and `feedback` adapters. For browser voice controls, use `WebSpeechSynthesisAdapter` and support-gated `WebSpeechDictationAdapter` from Assistant UI. No speech provider is built into this package; browser support and permissions remain the consumer's responsibility.
76
+
77
+ The optional `respondToHumanInput` client operation sends a response to a host's durable human-interaction extension. It answers the native task rather than fabricating a browser-side tool result.
78
+
79
+ ## Titles, archive, and feedback
80
+
81
+ Optional metadata methods support titles, archive state, feedback keyed by native entry ID, and suggestions. The demo persists these in a native Pi conversation document. Such custom metadata is not included in `ConversationView.docs`, so the adapter reads it explicitly. Archiving preserves history; no permanent-delete operation is invented.
82
+
83
+ The directory follows native pagination and loads metadata in bulk. Starting observation and completing observed work refresh the directory; connecting or reconnecting the selected conversation refreshes its metadata. Mutations publish the host's returned metadata. There is no cross-browser subscription for the directory or metadata: changes made elsewhere appear on the next applicable read or explicit refresh. Messages and execution remain live through the conversation watch. `state.metadata` uses stringified native conversation IDs as keys.
@@ -0,0 +1,5 @@
1
+ import type { AttachmentAdapter } from "@assistant-ui/react";
2
+ import type { PiUploadResult } from "./client.js";
3
+ /** Uploads at send time; the host decides which native Pi input represents the file. */
4
+ export declare function createPiAttachmentAdapter(upload: (file: File, signal?: AbortSignal) => Promise<PiUploadResult>): 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,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,wFAAwF;AACxF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,GAAG,iBAAiB,CAoBlI"}
@@ -0,0 +1,22 @@
1
+ /** Uploads at send time; the host decides which native Pi input represents the file. */
2
+ export function createPiAttachmentAdapter(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,59 @@
1
+ import { type Op } from "@earendil-works/chord/delta";
2
+ 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";
3
+ import type { ConversationMetadata, ConversationMetadataPatch } from "./metadata.js";
4
+ /** JSON form of Pi's stored configuration; the host resolves registry names. */
5
+ export type SerializedAgentChange = {
6
+ [K in keyof AgentState]?: AgentState[K] | null;
7
+ };
8
+ export type SerializedConversationCreateOptions = Omit<ConversationCreateOptions, "agent" | "init"> & {
9
+ agent?: SerializedAgentChange;
10
+ };
11
+ export type HttpPiWatchFrame = {
12
+ type: "view";
13
+ view: ConversationView;
14
+ } | {
15
+ type: "ops";
16
+ ops: readonly Op[];
17
+ };
18
+ export type PiConnectionState = "idle" | "connecting" | "connected" | "reconnecting";
19
+ export interface PiWatchOptions {
20
+ window?: () => ConversationWindow | undefined;
21
+ onConnection?: (state: PiConnectionState) => void;
22
+ onError?: (error: Error) => void;
23
+ }
24
+ export interface PiUploadResult {
25
+ name: string;
26
+ content: Exclude<UserInput, string>;
27
+ }
28
+ /** The native operations a browser needs, with server execution context already bound. */
29
+ export interface PiClient {
30
+ listConversations(query?: ConversationQuery, limit?: number, cursor?: Cursor): Promise<Page<ConversationRecord, Cursor>>;
31
+ createConversation(options?: SerializedConversationCreateOptions): Promise<ConversationRecord>;
32
+ getConversationView(id: ConversationId): Promise<ConversationView>;
33
+ entries(id: ConversationId, query: Omit<EntryQuery, "conversationId">, limit: number, cursor?: Cursor, signal?: AbortSignal): Promise<Page<EntryRecord, Cursor>>;
34
+ submit(id: ConversationId, submission: SubmissionDraft): Promise<SubmissionRecord>;
35
+ getSubmission(id: SubmissionId): Promise<SubmissionRecord>;
36
+ watch(id: ConversationId, onView: (view: ConversationView) => void, options?: PiWatchOptions): () => void;
37
+ configure?(id: ConversationId, change: SerializedAgentChange): Promise<void>;
38
+ abort?(id: ConversationId, options?: ConversationAbortOptions): Promise<void>;
39
+ abortSubmission?(id: SubmissionId): ReturnType<Harness["abortSubmission"]>;
40
+ reset?(id: ConversationId, handoff?: string): Promise<void>;
41
+ compact?(id: ConversationId, instructions?: string): Promise<TaskId<CompactionResult>>;
42
+ fork?(id: ConversationId, at: EntryId, options?: SerializedConversationCreateOptions): Promise<ConversationRecord>;
43
+ context?(id: ConversationId, at?: EntryId): Promise<ContextView>;
44
+ uploadFile?(file: File, signal?: AbortSignal): Promise<PiUploadResult>;
45
+ respondToHumanInput?(taskId: TaskId, response: string): Promise<void>;
46
+ inspect?(): Promise<HarnessInspection>;
47
+ listMetadata?(): Promise<Record<string, ConversationMetadata>>;
48
+ getMetadata?(id: ConversationId): Promise<ConversationMetadata>;
49
+ updateMetadata?(id: ConversationId, patch: ConversationMetadataPatch): Promise<ConversationMetadata>;
50
+ setFeedback?(id: ConversationId, entryId: EntryId, rating: "positive" | "negative" | null): Promise<ConversationMetadata>;
51
+ }
52
+ export interface HttpPiClientOptions {
53
+ baseUrl?: string;
54
+ fetch?: typeof globalThis.fetch;
55
+ eventSource?: (url: string) => EventSource;
56
+ reconnectDelayMs?: number;
57
+ }
58
+ export declare function createHttpPiClient(options?: HttpPiClientOptions): Required<PiClient>;
59
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,EAAE,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,KAAK,EACV,yBAAyB,EACzB,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,wBAAwB,EACxB,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,UAAU,EACV,WAAW,EACX,OAAO,EACP,IAAI,EACJ,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,MAAM,EACN,SAAS,EACT,OAAO,EACP,iBAAiB,EAClB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AAErF,gFAAgF;AAChF,MAAM,MAAM,qBAAqB,GAAG;KAAG,CAAC,IAAI,MAAM,UAAU,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI;CAAE,CAAC;AACvF,MAAM,MAAM,mCAAmC,GAAG,IAAI,CAAC,yBAAyB,EAAE,OAAO,GAAG,MAAM,CAAC,GAAG;IAAE,KAAK,CAAC,EAAE,qBAAqB,CAAA;CAAE,CAAC;AAExI,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,gBAAgB,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,SAAS,EAAE,EAAE,CAAA;CAAE,CAAC;AAExC,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,CAAC;AAErF,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,MAAM,kBAAkB,GAAG,SAAS,CAAC;IAC9C,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,0FAA0F;AAC1F,MAAM,WAAW,QAAQ;IACvB,iBAAiB,CAAC,KAAK,CAAC,EAAE,iBAAiB,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC,CAAC;IACzH,kBAAkB,CAAC,OAAO,CAAC,EAAE,mCAAmC,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC/F,mBAAmB,CAAC,EAAE,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnE,OAAO,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IACjK,MAAM,CAAC,EAAE,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnF,aAAa,CAAC,EAAE,EAAE,YAAY,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC3D,KAAK,CAAC,EAAE,EAAE,cAAc,EAAE,MAAM,EAAE,CAAC,IAAI,EAAE,gBAAgB,KAAK,IAAI,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,MAAM,IAAI,CAAC;IAC1G,SAAS,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,KAAK,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E,eAAe,CAAC,CAAC,EAAE,EAAE,YAAY,GAAG,UAAU,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;IAC3E,KAAK,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,OAAO,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACvF,IAAI,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,mCAAmC,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACnH,OAAO,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACjE,UAAU,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACvE,mBAAmB,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,OAAO,CAAC,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACvC,YAAY,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAC;IAC/D,WAAW,CAAC,CAAC,EAAE,EAAE,cAAc,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAChE,cAAc,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,yBAAyB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACrG,WAAW,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,UAAU,GAAG,IAAI,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CAC3H;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,WAAW,CAAC;IAC3C,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,QAAQ,CAAC,QAAQ,CAAC,CA4FxF"}
package/dist/client.js ADDED
@@ -0,0 +1,94 @@
1
+ import { applyImmutable } from "@earendil-works/chord/delta";
2
+ export function createHttpPiClient(options = {}) {
3
+ const baseUrl = (options.baseUrl ?? "/api").replace(/\/$/, "");
4
+ const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
5
+ const openEvents = options.eventSource ?? ((url) => new EventSource(url));
6
+ function url(path, query = {}) {
7
+ const params = new URLSearchParams();
8
+ for (const [key, value] of Object.entries(query)) {
9
+ if (value !== undefined)
10
+ params.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
11
+ }
12
+ const search = params.toString();
13
+ return `${baseUrl}${path}${search ? `?${search}` : ""}`;
14
+ }
15
+ async function request(path, init, query) {
16
+ const response = await fetcher(url(path, query), init);
17
+ if (!response.ok) {
18
+ const body = await response.text();
19
+ throw new Error(body || `Pi request failed: ${response.status} ${response.statusText}`);
20
+ }
21
+ return response.json();
22
+ }
23
+ function post(path, value) {
24
+ return request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(value) });
25
+ }
26
+ return {
27
+ listConversations: (query = {}, limit = 100, cursor) => request("/conversations", undefined, { query, limit, cursor }),
28
+ createConversation: (creation = { ownership: { kind: "ownerless" } }) => post("/conversations", creation),
29
+ getConversationView: (id) => request(`/conversations/${id}`),
30
+ entries: (id, query, limit, cursor, signal) => request(`/conversations/${id}/entries`, { signal }, { ...query, limit, cursor }),
31
+ submit: (id, submission) => post(`/conversations/${id}/submit`, submission),
32
+ getSubmission: (id) => request(`/submissions/${id}`),
33
+ configure: async (id, change) => { await post(`/conversations/${id}/configure`, change); },
34
+ abort: async (id, abortOptions = {}) => { await post(`/conversations/${id}/abort`, abortOptions); },
35
+ abortSubmission: (id) => post(`/submissions/${id}/abort`, {}),
36
+ reset: async (id, handoff) => { await post(`/conversations/${id}/reset`, { handoff }); },
37
+ compact: async (id, instructions) => (await post(`/conversations/${id}/compact`, { instructions })).taskId,
38
+ fork: (id, at, forkOptions = { ownership: { kind: "ownerless" } }) => post(`/conversations/${id}/fork`, { at, ...forkOptions }),
39
+ context: (id, at) => request(`/conversations/${id}/context`, undefined, { at }),
40
+ uploadFile: (file, signal) => request("/files", { method: "POST", headers: { "content-type": file.type || "application/octet-stream" }, body: file, signal }, { name: file.name }),
41
+ respondToHumanInput: async (taskId, response) => { await post(`/interactions/${taskId}`, { response }); },
42
+ inspect: () => request("/inspect"),
43
+ listMetadata: () => request("/conversations/metadata"),
44
+ getMetadata: (id) => request(`/conversations/${id}/metadata`),
45
+ updateMetadata: (id, patch) => request(`/conversations/${id}/metadata`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) }),
46
+ setFeedback: (id, entryId, rating) => post(`/conversations/${id}/feedback`, { entryId, rating }),
47
+ watch(id, onView, watchOptions = {}) {
48
+ let source;
49
+ let retry;
50
+ let stopped = false;
51
+ let connectedBefore = false;
52
+ function connect() {
53
+ if (stopped)
54
+ return;
55
+ watchOptions.onConnection?.(connectedBefore ? "reconnecting" : "connecting");
56
+ const connection = openEvents(url(`/conversations/${id}/watch`, { window: watchOptions.window?.() }));
57
+ source = connection;
58
+ let view;
59
+ connection.onmessage = (event) => {
60
+ try {
61
+ const frame = JSON.parse(event.data);
62
+ if (frame.type === "view")
63
+ view = frame.view;
64
+ else {
65
+ if (!view)
66
+ throw new Error("Pi watch sent changes before its initial view");
67
+ view = applyImmutable(view, frame.ops);
68
+ }
69
+ connectedBefore = true;
70
+ watchOptions.onConnection?.("connected");
71
+ onView(view);
72
+ }
73
+ catch (cause) {
74
+ watchOptions.onError?.(cause instanceof Error ? cause : new Error(String(cause)));
75
+ connection.close();
76
+ }
77
+ };
78
+ connection.onerror = () => {
79
+ connection.close();
80
+ if (stopped)
81
+ return;
82
+ watchOptions.onConnection?.("reconnecting");
83
+ retry = setTimeout(connect, options.reconnectDelayMs ?? 500);
84
+ };
85
+ }
86
+ connect();
87
+ return () => {
88
+ stopped = true;
89
+ clearTimeout(retry);
90
+ source?.close();
91
+ };
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,11 @@
1
+ export { createHttpPiClient } from "./client.js";
2
+ export type { PiClient, HttpPiWatchFrame, PiWatchOptions, PiConnectionState, PiUploadResult, HttpPiClientOptions, SerializedAgentChange, SerializedConversationCreateOptions } from "./client.js";
3
+ export { createPiAttachmentAdapter } from "./attachments.js";
4
+ export type { ConversationMetadata, ConversationMetadataPatch } from "./metadata.js";
5
+ export { conversationInbox, conversationLiveState, entryIdFromMessageId, isForegroundRunning, projectConversation, toPiUserInput } from "./projection.js";
6
+ export { PiRuntimeStore } from "./store.js";
7
+ export type { PiRuntimeState, PiRuntimeStoreOptions } from "./store.js";
8
+ export { usePiRuntime } from "./use-pi-runtime.js";
9
+ export type { UsePiRuntimeOptions } from "./use-pi-runtime.js";
10
+ export type { ConversationId, ConversationRecord, ConversationView, TranscriptView, SubmissionDraft, SubmissionId, SubmissionRecord, EntryId, AgentChange, AgentState, InboxState, HarnessInspection } from "@earendil-works/pi-durable";
11
+ //# 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,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,YAAY,EAAE,QAAQ,EAAE,gBAAgB,EAAE,cAAc,EAAE,iBAAiB,EAAE,cAAc,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,mCAAmC,EAAE,MAAM,aAAa,CAAC;AAClM,OAAO,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAC7D,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,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,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,5 @@
1
+ export { createHttpPiClient } from "./client.js";
2
+ export { createPiAttachmentAdapter } from "./attachments.js";
3
+ export { conversationInbox, conversationLiveState, entryIdFromMessageId, isForegroundRunning, projectConversation, toPiUserInput } from "./projection.js";
4
+ export { PiRuntimeStore } from "./store.js";
5
+ export { usePiRuntime } from "./use-pi-runtime.js";
@@ -0,0 +1,11 @@
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
+ archived: boolean;
6
+ feedback: Record<EntryId, "positive" | "negative">;
7
+ suggestions: string[];
8
+ };
9
+ export type ConversationMetadataPatch = Partial<Pick<ConversationMetadata, "title" | "archived" | "suggestions">>;
10
+ export declare const EMPTY_CONVERSATION_METADATA: ConversationMetadata;
11
+ //# 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,OAAO,CAAC;IAClB,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,UAAU,GAAG,aAAa,CAAC,CAAC,CAAC;AAElH,eAAO,MAAM,2BAA2B,EAAE,oBAKzC,CAAC"}
@@ -0,0 +1,6 @@
1
+ export const EMPTY_CONVERSATION_METADATA = {
2
+ title: null,
3
+ archived: false,
4
+ feedback: {},
5
+ suggestions: [],
6
+ };
@@ -0,0 +1,13 @@
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
+ export declare function conversationLiveState(view: Pick<TranscriptView, "docs">): LiveState;
5
+ export declare function conversationInbox(view: Pick<TranscriptView, "docs">): InboxState;
6
+ export declare function entryIdFromMessageId(id: string): EntryId;
7
+ /** Foreground generation is distinct from a run's after-run/background work. */
8
+ export declare function isForegroundRunning(view: Pick<TranscriptView, "docs">): boolean;
9
+ /** Pure projection: native entries keep their IDs; tool results join their calls by call ID. */
10
+ export declare function projectConversation(view: TranscriptView, presentation?: ConversationMetadata): readonly ThreadMessageLike[];
11
+ /** Convert only the user's new input; never send the rendered transcript back to Pi. */
12
+ export declare function toPiUserInput(message: AppendMessage): UserInput;
13
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE5E,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;AAM1D,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;AAqCD,gGAAgG;AAChG,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,cAAc,EAAE,YAAY,CAAC,EAAE,oBAAoB,GAAG,SAAS,iBAAiB,EAAE,CAwE3H;AAED,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,CAY/D"}
@@ -0,0 +1,150 @@
1
+ export function conversationLiveState(view) {
2
+ return (view.docs["pi.live"] ?? {});
3
+ }
4
+ export function conversationInbox(view) {
5
+ return (view.docs["pi.inbox"] ?? { items: [] });
6
+ }
7
+ export function entryIdFromMessageId(id) {
8
+ const match = /^entry:(\d+):(?:\d+|notice)$/.exec(id);
9
+ if (!match || !Number.isSafeInteger(Number(match[1])))
10
+ throw new Error(`Message ${id} is not a committed Pi entry`);
11
+ return Number(match[1]);
12
+ }
13
+ /** Foreground generation is distinct from a run's after-run/background work. */
14
+ export function isForegroundRunning(view) {
15
+ const live = conversationLiveState(view);
16
+ return live.run !== undefined && live.run.afterRun === undefined;
17
+ }
18
+ function userContent(content) {
19
+ if (typeof content === "string")
20
+ return [{ type: "text", text: content }];
21
+ return content.map((part) => part.type === "text"
22
+ ? { type: "text", text: part.text }
23
+ : { type: "image", image: `data:${part.mimeType};base64,${part.data}` });
24
+ }
25
+ function assistantContent(message, calls) {
26
+ return message.content.map((part) => {
27
+ if (part.type === "text")
28
+ return { type: "text", text: part.text };
29
+ if (part.type === "thinking")
30
+ return { type: "reasoning", text: part.thinking };
31
+ const tool = { type: "tool-call", toolCallId: part.id, toolName: part.name, args: part.arguments };
32
+ calls.set(part.id, tool);
33
+ return tool;
34
+ });
35
+ }
36
+ function applyToolResult(tool, message) {
37
+ Object.assign(tool, {
38
+ result: message.details ?? message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
39
+ isError: message.isError,
40
+ isPreliminary: false,
41
+ modelContent: message.content.map((part) => part.type === "text"
42
+ ? { type: "text", text: part.text }
43
+ : { type: "file", mediaType: part.mimeType, data: part.data }),
44
+ });
45
+ }
46
+ function messageStatus(message) {
47
+ if (message.stopReason === "error")
48
+ return { type: "incomplete", reason: "error", error: message.errorMessage ?? "Model request failed" };
49
+ if (message.stopReason === "aborted")
50
+ return { type: "incomplete", reason: "cancelled" };
51
+ if (message.stopReason === "length")
52
+ return { type: "incomplete", reason: "length" };
53
+ return { type: "complete", reason: "stop" };
54
+ }
55
+ /** Pure projection: native entries keep their IDs; tool results join their calls by call ID. */
56
+ export function projectConversation(view, presentation) {
57
+ const messages = [];
58
+ const calls = new Map();
59
+ const edits = new Map();
60
+ for (const item of view.items) {
61
+ if (item.kind === "entry") {
62
+ for (const change of item.entry.edits ?? [])
63
+ edits.set(change.target, { change, by: item.entry.id });
64
+ }
65
+ }
66
+ function append(entry, message, index) {
67
+ const metadata = { custom: { conversationId: entry.conversationId, entryId: entry.id, entryKind: entry.kind, editEntryId: edits.get(entry.id)?.by } };
68
+ const id = `entry:${entry.id}:${index}`;
69
+ if (message.role === "user") {
70
+ messages.push({ id, role: "user", content: userContent(message.content), createdAt: new Date(message.timestamp), metadata });
71
+ }
72
+ else if (message.role === "assistant") {
73
+ const feedback = presentation?.feedback[entry.id];
74
+ messages.push({ id, role: "assistant", content: assistantContent(message, calls), status: messageStatus(message), createdAt: new Date(message.timestamp), metadata: { ...metadata, ...(feedback ? { submittedFeedback: { type: feedback } } : {}) } });
75
+ }
76
+ else if (message.role === "toolResult") {
77
+ let tool = calls.get(message.toolCallId);
78
+ if (!tool) {
79
+ tool = { type: "tool-call", toolCallId: message.toolCallId, toolName: message.toolName, args: {} };
80
+ calls.set(message.toolCallId, tool);
81
+ messages.push({ id, role: "assistant", content: [tool], metadata });
82
+ }
83
+ applyToolResult(tool, message);
84
+ }
85
+ }
86
+ for (const item of view.items) {
87
+ if (item.kind !== "entry")
88
+ continue;
89
+ const entry = item.entry;
90
+ if (entry.kind === "pi.system")
91
+ continue;
92
+ if (entry.kind === "pi.compaction" || entry.kind === "pi.reset") {
93
+ messages.push({
94
+ id: `entry:${entry.id}:notice`,
95
+ role: "assistant",
96
+ content: [{ type: "text", text: entry.kind === "pi.reset" ? "Conversation context reset" : "Conversation context compacted" }],
97
+ metadata: { custom: { conversationId: entry.conversationId, entryId: entry.id, entryKind: entry.kind, entryData: entry.data } },
98
+ });
99
+ }
100
+ const edit = edits.get(entry.id)?.change;
101
+ const model = edit ? (edit.action === "replace" ? edit.messages : []) : (entry.model ?? []);
102
+ for (const [index, message] of model.entries())
103
+ append(entry, message, index);
104
+ }
105
+ const live = conversationLiveState(view);
106
+ if (live.generation?.message) {
107
+ const message = live.generation.message;
108
+ messages.push({
109
+ id: `generation:${live.run?.taskId ?? view.conversation.id}`,
110
+ role: "assistant",
111
+ content: assistantContent(message, calls),
112
+ status: { type: "running" },
113
+ createdAt: new Date(message.timestamp),
114
+ metadata: { custom: { conversationId: view.conversation.id, taskId: live.run?.taskId } },
115
+ });
116
+ }
117
+ for (const slot of live.tools ?? []) {
118
+ let tool = calls.get(slot.callId);
119
+ if (!tool) {
120
+ tool = { type: "tool-call", toolCallId: slot.callId, toolName: slot.name, args: {} };
121
+ calls.set(slot.callId, tool);
122
+ messages.push({ id: `tool:${slot.callId}`, role: "assistant", content: [tool], status: { type: "running" } });
123
+ }
124
+ if (slot.status !== "done") {
125
+ Object.assign(tool, { result: slot.details ?? slot.output, isPreliminary: true });
126
+ const owner = messages.find((message) => message.content.includes(tool));
127
+ if (owner)
128
+ owner.status = { type: "running" };
129
+ }
130
+ }
131
+ return messages;
132
+ }
133
+ /** Convert only the user's new input; never send the rendered transcript back to Pi. */
134
+ export function toPiUserInput(message) {
135
+ const content = [];
136
+ const parts = [...message.content, ...(message.attachments ?? []).flatMap((attachment) => attachment.content)];
137
+ for (const part of parts) {
138
+ if (part.type === "text")
139
+ content.push({ type: "text", text: part.text });
140
+ else if (part.type === "image") {
141
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(part.image);
142
+ if (!match)
143
+ throw new Error("Image input must be a base64 data URL");
144
+ content.push({ type: "image", mimeType: match[1], data: match[2] });
145
+ }
146
+ else
147
+ throw new Error(`Unsupported Pi input part: ${part.type}`);
148
+ }
149
+ return content;
150
+ }
@@ -0,0 +1,57 @@
1
+ import type { ConversationId, ConversationRecord, EntryId, SubmissionDraft, SubmissionRecord, TranscriptView } from "@earendil-works/pi-durable";
2
+ import type { AppendMessage, ThreadMessageLike } from "@assistant-ui/react";
3
+ import type { PiClient, PiConnectionState, SerializedConversationCreateOptions, SerializedAgentChange } from "./client.js";
4
+ import type { ConversationMetadata, ConversationMetadataPatch } from "./metadata.js";
5
+ export interface PiRuntimeState {
6
+ readonly conversations: readonly ConversationRecord[];
7
+ readonly selectedId: ConversationId | undefined;
8
+ readonly view: TranscriptView | undefined;
9
+ readonly messages: readonly ThreadMessageLike[];
10
+ readonly isLoading: boolean;
11
+ readonly isRunning: boolean;
12
+ readonly connection: PiConnectionState;
13
+ readonly error: Error | undefined;
14
+ readonly hasEarlier: boolean;
15
+ readonly metadata: Readonly<Record<string, ConversationMetadata>>;
16
+ }
17
+ export interface PiRuntimeStoreOptions {
18
+ initialConversationId?: ConversationId;
19
+ pageSize?: number;
20
+ }
21
+ /** A disposable client view of Pi, not a second durable conversation store. */
22
+ export declare class PiRuntimeStore {
23
+ #private;
24
+ readonly client: PiClient;
25
+ constructor(client: PiClient, options?: PiRuntimeStoreOptions);
26
+ getSnapshot: () => PiRuntimeState;
27
+ subscribe: (listener: () => void) => (() => void);
28
+ start: () => void;
29
+ stop: () => void;
30
+ refreshConversations: () => Promise<void>;
31
+ selectConversation: (id: ConversationId) => void;
32
+ createConversation: (options?: SerializedConversationCreateOptions) => Promise<ConversationRecord>;
33
+ refreshMetadata: (id?: ConversationId | undefined) => Promise<void>;
34
+ updateMetadata: (id: ConversationId, patch: ConversationMetadataPatch) => Promise<void>;
35
+ renameConversation: (id: ConversationId, title: string) => Promise<void>;
36
+ archiveConversation: (id: ConversationId) => Promise<void>;
37
+ unarchiveConversation: (id: ConversationId) => Promise<void>;
38
+ setFeedback: (entryId: EntryId, rating: "positive" | "negative" | null) => Promise<void>;
39
+ submitFeedback: (messageId: string, rating: "positive" | "negative") => void;
40
+ submit: (submission: SubmissionDraft) => Promise<SubmissionRecord>;
41
+ continue: () => Promise<SubmissionRecord>;
42
+ steer: (content: Extract<SubmissionDraft, {
43
+ type: "input";
44
+ }>["content"]) => Promise<SubmissionRecord>;
45
+ abortSubmission: (id: SubmissionRecord["id"]) => Promise<"aborted" | "already_placed" | "not_found" | "settled">;
46
+ /** Retrying an edit creates a native fork and records an immutable context replacement there. */
47
+ edit: (message: AppendMessage) => Promise<void>;
48
+ /** Keep the answer in original history; the fork omits it from its next model context. */
49
+ regenerate: (parentId: string | null) => Promise<void>;
50
+ cancel: () => Promise<void>;
51
+ configure: (change: SerializedAgentChange) => Promise<void>;
52
+ reset: (handoff?: string) => Promise<void>;
53
+ compact: (instructions?: string) => Promise<import("@earendil-works/pi-durable").TaskId<import("@earendil-works/pi-durable").CompactionResult>>;
54
+ forkAt: (at: EntryId) => Promise<ConversationRecord>;
55
+ loadEarlier: () => Promise<void>;
56
+ }
57
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
package/dist/store.js ADDED
@@ -0,0 +1,261 @@
1
+ import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
2
+ import { ConversationTranscript } from "@earendil-works/pi-durable";
3
+ import { conversationLiveState, entryIdFromMessageId, isForegroundRunning, projectConversation, toPiUserInput } from "./projection.js";
4
+ function runtimeSnapshot(source) {
5
+ const { view } = source;
6
+ return {
7
+ ...source,
8
+ messages: view ? projectConversation(view, source.metadata[String(view.conversation.id)]) : [],
9
+ isRunning: view ? isForegroundRunning(view) : false,
10
+ hasEarlier: view?.items[0]?.kind === "earlier",
11
+ };
12
+ }
13
+ /** A disposable client view of Pi, not a second durable conversation store. */
14
+ export class PiRuntimeStore {
15
+ client;
16
+ #options;
17
+ #listeners = new Set();
18
+ #transcripts = new Map();
19
+ #views = new Map();
20
+ #filling = new Map();
21
+ #disconnect;
22
+ #selection = new AbortController();
23
+ #started = false;
24
+ #state;
25
+ constructor(client, options = {}) {
26
+ this.client = client;
27
+ this.#options = options;
28
+ this.#state = runtimeSnapshot({ conversations: [], selectedId: options.initialConversationId, view: undefined, isLoading: false, connection: "idle", error: undefined, metadata: {} });
29
+ }
30
+ getSnapshot = () => this.#state;
31
+ subscribe = (listener) => {
32
+ this.#listeners.add(listener);
33
+ return () => this.#listeners.delete(listener);
34
+ };
35
+ #set(patch) {
36
+ const next = { ...this.#state, ...patch };
37
+ this.#state = next.view === this.#state.view && next.metadata === this.#state.metadata ? next : runtimeSnapshot(next);
38
+ for (const listener of this.#listeners)
39
+ listener();
40
+ }
41
+ #report(cause) {
42
+ this.#set({ error: cause instanceof Error ? cause : new Error(String(cause)), isLoading: false });
43
+ }
44
+ #transcript(id) {
45
+ let transcript = this.#transcripts.get(id);
46
+ if (!transcript) {
47
+ transcript = new ConversationTranscript((query, limit, context) => this.client.entries(id, query, limit, undefined, context.abortSignal), { pageSize: this.#options.pageSize });
48
+ this.#transcripts.set(id, transcript);
49
+ }
50
+ return transcript;
51
+ }
52
+ #show(id, view) {
53
+ this.#views.set(id, view);
54
+ if (this.#state.selectedId !== id)
55
+ return;
56
+ this.#set({ view, isLoading: false, error: undefined });
57
+ }
58
+ start = () => {
59
+ if (this.#started)
60
+ return;
61
+ this.#started = true;
62
+ if (this.#state.selectedId !== undefined)
63
+ this.selectConversation(this.#state.selectedId);
64
+ void this.refreshConversations().catch((error) => this.#report(error));
65
+ };
66
+ stop = () => {
67
+ this.#started = false;
68
+ this.#disconnect?.();
69
+ this.#disconnect = undefined;
70
+ this.#selection.abort();
71
+ };
72
+ refreshConversations = async () => {
73
+ const load = async () => {
74
+ const conversations = [];
75
+ let cursor;
76
+ do {
77
+ const page = await this.client.listConversations({}, 100, cursor);
78
+ conversations.push(...page.items);
79
+ cursor = page.next;
80
+ } while (cursor);
81
+ return conversations;
82
+ };
83
+ const [conversations, loaded] = await Promise.all([load(), this.client.listMetadata?.()]);
84
+ this.#set({ conversations, ...(loaded ? { metadata: loaded } : {}) });
85
+ if (this.#started && this.#state.selectedId === undefined && conversations[0])
86
+ this.selectConversation(conversations[0].id);
87
+ };
88
+ selectConversation = (id) => {
89
+ this.#disconnect?.();
90
+ this.#selection.abort();
91
+ this.#selection = new AbortController();
92
+ const cached = this.#views.get(id);
93
+ this.#set({ selectedId: id, view: cached, isLoading: !cached, error: undefined, connection: "connecting" });
94
+ if (!this.#started)
95
+ return;
96
+ const transcript = this.#transcript(id);
97
+ this.#disconnect = this.client.watch(id, (view) => {
98
+ const previous = this.#views.get(id);
99
+ this.#show(id, transcript.merge(view));
100
+ if (previous && conversationLiveState(previous).run && !conversationLiveState(view).run)
101
+ void this.refreshConversations().catch((cause) => this.#report(cause));
102
+ const signal = this.#selection.signal;
103
+ void this.#fillGaps(id).catch((cause) => { if (!signal.aborted && this.#state.selectedId === id)
104
+ this.#report(cause); });
105
+ }, {
106
+ window: () => transcript.resume,
107
+ onConnection: (connection) => {
108
+ if (this.#state.selectedId !== id)
109
+ return;
110
+ const becameConnected = connection === "connected" && this.#state.connection !== "connected";
111
+ this.#set({ connection });
112
+ if (becameConnected)
113
+ void this.refreshMetadata(id).catch((cause) => this.#report(cause));
114
+ },
115
+ onError: (error) => { if (this.#state.selectedId === id)
116
+ this.#report(error); },
117
+ });
118
+ };
119
+ createConversation = async (options) => {
120
+ this.#set({ isLoading: true, error: undefined });
121
+ try {
122
+ const conversation = await this.client.createConversation(options);
123
+ this.#set({ conversations: [conversation, ...this.#state.conversations.filter((item) => item.id !== conversation.id)] });
124
+ this.selectConversation(conversation.id);
125
+ return conversation;
126
+ }
127
+ catch (cause) {
128
+ this.#report(cause);
129
+ throw cause;
130
+ }
131
+ };
132
+ #setMetadata(id, metadata) {
133
+ this.#set({ metadata: { ...this.#state.metadata, [String(id)]: metadata } });
134
+ }
135
+ refreshMetadata = async (id = this.#state.selectedId) => {
136
+ if (id === undefined || !this.client.getMetadata)
137
+ return;
138
+ this.#setMetadata(id, await this.client.getMetadata(id));
139
+ };
140
+ updateMetadata = async (id, patch) => {
141
+ if (!this.client.updateMetadata)
142
+ throw new Error("This Pi host does not expose conversation metadata");
143
+ this.#setMetadata(id, await this.client.updateMetadata(id, patch));
144
+ };
145
+ renameConversation = (id, title) => this.updateMetadata(id, { title });
146
+ archiveConversation = (id) => this.updateMetadata(id, { archived: true });
147
+ unarchiveConversation = (id) => this.updateMetadata(id, { archived: false });
148
+ setFeedback = async (entryId, rating) => {
149
+ const id = this.#state.selectedId;
150
+ if (id === undefined)
151
+ throw new Error("Select a conversation before leaving feedback");
152
+ if (!this.client.setFeedback)
153
+ throw new Error("This Pi host does not expose feedback");
154
+ this.#setMetadata(id, await this.client.setFeedback(id, entryId, rating));
155
+ };
156
+ submitFeedback = (messageId, rating) => {
157
+ void this.setFeedback(entryIdFromMessageId(messageId), rating).catch((cause) => this.#report(cause));
158
+ };
159
+ submit = async (submission) => {
160
+ const id = this.#state.selectedId ?? (await this.createConversation()).id;
161
+ return this.client.submit(id, submission);
162
+ };
163
+ continue = () => this.submit({ type: "continue" });
164
+ steer = (content) => this.submit({ type: "input", content, whenBusy: "steer" });
165
+ abortSubmission = async (id) => {
166
+ if (!this.client.abortSubmission)
167
+ throw new Error("This Pi host does not expose submission withdrawal");
168
+ return this.client.abortSubmission(id);
169
+ };
170
+ /** Retrying an edit creates a native fork and records an immutable context replacement there. */
171
+ edit = async (message) => {
172
+ if (!message.sourceId)
173
+ throw new Error("An edit must identify its original message");
174
+ const target = entryIdFromMessageId(message.sourceId);
175
+ const content = toPiUserInput(message);
176
+ await this.forkAt(target);
177
+ await this.submit({ type: "write", entry: { kind: "assistant-ui.context-edit", edits: [{ target, action: "replace", messages: [{ role: "user", content, timestamp: Date.now() }] }] } });
178
+ await this.continue();
179
+ };
180
+ /** Keep the answer in original history; the fork omits it from its next model context. */
181
+ regenerate = async (parentId) => {
182
+ const index = parentId === null ? -1 : this.#state.messages.findIndex((message) => message.id === parentId);
183
+ if (parentId !== null && index === -1)
184
+ throw new Error(`Parent message ${parentId} is not loaded`);
185
+ const answer = this.#state.messages[index + 1];
186
+ if (!answer?.id || answer.role !== "assistant")
187
+ throw new Error("No committed assistant response follows this message");
188
+ const target = entryIdFromMessageId(answer.id);
189
+ await this.forkAt(target);
190
+ await this.submit({ type: "write", entry: { kind: "assistant-ui.context-edit", edits: [{ target, action: "omit" }] } });
191
+ await this.continue();
192
+ };
193
+ cancel = async () => {
194
+ const id = this.#state.selectedId;
195
+ if (id === undefined)
196
+ return;
197
+ if (!this.client.abort)
198
+ throw new Error("This Pi host does not expose conversation cancellation");
199
+ await this.client.abort(id);
200
+ };
201
+ configure = async (change) => {
202
+ const id = this.#state.selectedId;
203
+ if (id === undefined)
204
+ throw new Error("Select a conversation before configuring it");
205
+ if (!this.client.configure)
206
+ throw new Error("This Pi host does not expose configuration");
207
+ await this.client.configure(id, change);
208
+ };
209
+ reset = async (handoff) => {
210
+ const id = this.#state.selectedId;
211
+ if (id === undefined)
212
+ return;
213
+ if (!this.client.reset)
214
+ throw new Error("This Pi host does not expose reset");
215
+ await this.client.reset(id, handoff);
216
+ };
217
+ compact = async (instructions) => {
218
+ const id = this.#state.selectedId;
219
+ if (id === undefined)
220
+ throw new Error("Select a conversation before compacting it");
221
+ if (!this.client.compact)
222
+ throw new Error("This Pi host does not expose compaction");
223
+ return this.client.compact(id, instructions);
224
+ };
225
+ forkAt = async (at) => {
226
+ const id = this.#state.selectedId;
227
+ if (id === undefined)
228
+ throw new Error("Select a conversation before forking it");
229
+ if (!this.client.fork)
230
+ throw new Error("This Pi host does not expose forks");
231
+ const conversation = await this.client.fork(id, at);
232
+ this.#set({ conversations: [conversation, ...this.#state.conversations] });
233
+ this.selectConversation(conversation.id);
234
+ return conversation;
235
+ };
236
+ loadEarlier = async () => {
237
+ const id = this.#state.selectedId;
238
+ if (id === undefined)
239
+ return;
240
+ const view = await this.#transcript(id).loadEarlier(withAbortSignal(this.#selection.signal, BACKGROUND_CONTEXT));
241
+ this.#show(id, view);
242
+ };
243
+ #fillGaps(id) {
244
+ const previous = this.#filling.get(id);
245
+ if (previous)
246
+ return previous;
247
+ const signal = this.#selection.signal;
248
+ const run = async () => {
249
+ while (!signal.aborted) {
250
+ const gap = this.#views.get(id)?.items.find((item) => item.kind === "gap");
251
+ if (!gap || gap.kind !== "gap")
252
+ return;
253
+ const view = await this.#transcript(id).fillGap(gap, withAbortSignal(signal, BACKGROUND_CONTEXT));
254
+ this.#show(id, view);
255
+ }
256
+ };
257
+ const pending = run().finally(() => this.#filling.delete(id));
258
+ this.#filling.set(id, pending);
259
+ return pending;
260
+ }
261
+ }
@@ -0,0 +1,17 @@
1
+ import { type AttachmentAdapter, type DictationAdapter, type FeedbackAdapter, type SpeechSynthesisAdapter } from "@assistant-ui/react";
2
+ import type { PiClient } from "./client.js";
3
+ import { PiRuntimeStore, type PiRuntimeStoreOptions } from "./store.js";
4
+ export interface UsePiRuntimeOptions extends PiRuntimeStoreOptions {
5
+ client: PiClient;
6
+ whenBusy?: "followUp" | "steer" | "reject";
7
+ attachments?: AttachmentAdapter;
8
+ speech?: SpeechSynthesisAdapter;
9
+ dictation?: DictationAdapter;
10
+ feedback?: FeedbackAdapter;
11
+ }
12
+ export declare function usePiRuntime({ client, initialConversationId, pageSize, whenBusy, attachments, speech, dictation, feedback }: UsePiRuntimeOptions): {
13
+ runtime: import("@assistant-ui/react").AssistantRuntime;
14
+ state: import("./store.js").PiRuntimeState;
15
+ actions: PiRuntimeStore;
16
+ };
17
+ //# sourceMappingURL=use-pi-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-pi-runtime.d.ts","sourceRoot":"","sources":["../src/use-pi-runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAA2B,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAEhK,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAIxE,MAAM,WAAW,mBAAoB,SAAQ,qBAAqB;IAChE,MAAM,EAAE,QAAQ,CAAC;IACjB,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC3C,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,wBAAgB,YAAY,CAAC,EAAE,MAAM,EAAE,qBAAqB,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,mBAAmB;;;;EA2ChJ"}
@@ -0,0 +1,49 @@
1
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
2
+ import { useExternalStoreRuntime } from "@assistant-ui/react";
3
+ import { PiRuntimeStore } from "./store.js";
4
+ import { toPiUserInput } from "./projection.js";
5
+ import { EMPTY_CONVERSATION_METADATA } from "./metadata.js";
6
+ export function usePiRuntime({ client, initialConversationId, pageSize, whenBusy, attachments, speech, dictation, feedback }) {
7
+ const store = useMemo(() => new PiRuntimeStore(client, { initialConversationId, pageSize }), [client, pageSize]);
8
+ useEffect(() => { store.start(); return store.stop; }, [store]);
9
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
10
+ const metadata = state.metadata[String(state.selectedId)] ?? EMPTY_CONVERSATION_METADATA;
11
+ const nativeFeedback = useMemo(() => client.setFeedback ? {
12
+ submit: ({ message, type }) => store.submitFeedback(message.id, type),
13
+ } : undefined, [client, store]);
14
+ const threads = state.conversations.map((conversation) => {
15
+ const details = state.metadata[String(conversation.id)] ?? EMPTY_CONVERSATION_METADATA;
16
+ const item = { id: String(conversation.id), title: details.title ?? `Conversation ${conversation.id}` };
17
+ return details.archived ? { ...item, status: "archived" } : { ...item, status: "regular" };
18
+ });
19
+ const runtime = useExternalStoreRuntime({
20
+ messages: state.messages,
21
+ convertMessage: (message) => message,
22
+ isRunning: state.isRunning,
23
+ isLoading: state.isLoading,
24
+ hasEarlier: state.hasEarlier,
25
+ onLoadEarlier: store.loadEarlier,
26
+ onNew: async (message) => { await store.submit({ type: "input", content: toPiUserInput(message), whenBusy }); },
27
+ onCancel: client.abort ? store.cancel : undefined,
28
+ onEdit: client.fork ? store.edit : undefined,
29
+ onReload: client.fork ? store.regenerate : undefined,
30
+ suggestions: metadata.suggestions.map((prompt) => ({ prompt })),
31
+ adapters: {
32
+ attachments,
33
+ speech,
34
+ dictation,
35
+ feedback: feedback ?? nativeFeedback,
36
+ threadList: {
37
+ threadId: state.selectedId === undefined ? undefined : String(state.selectedId),
38
+ threads: threads.filter((thread) => thread.status === "regular"),
39
+ archivedThreads: threads.filter((thread) => thread.status === "archived"),
40
+ onSwitchToThread: (id) => store.selectConversation(Number(id)),
41
+ onSwitchToNewThread: async () => { await store.createConversation(); },
42
+ onRename: client.updateMetadata ? (id, title) => store.renameConversation(Number(id), title) : undefined,
43
+ onArchive: client.updateMetadata ? (id) => store.archiveConversation(Number(id)) : undefined,
44
+ onUnarchive: client.updateMetadata ? (id) => store.unarchiveConversation(Number(id)) : undefined,
45
+ },
46
+ },
47
+ });
48
+ return { runtime, state, actions: store };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
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": "1.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": "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
+ },
37
+ "dependencies": {
38
+ "@earendil-works/pi-durable": "1.5.0",
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
+ }
46
+ }