@fungi.computer/parakeet 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +186 -2
- package/dist/account-commands.d.ts +102 -0
- package/dist/account-commands.js +239 -0
- package/dist/agent-head.d.ts +4 -0
- package/dist/agent-head.js +2 -0
- package/dist/client-contributions.d.ts +20 -0
- package/dist/client-contributions.js +41 -0
- package/dist/commands/advertised.d.ts +23 -0
- package/dist/commands/advertised.js +72 -0
- package/dist/commands/builtins.d.ts +14 -0
- package/dist/commands/builtins.js +91 -0
- package/dist/commands/hints.d.ts +36 -0
- package/dist/commands/hints.js +64 -0
- package/dist/commands/interactions.d.ts +55 -0
- package/dist/commands/interactions.js +101 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +64 -0
- package/dist/commands/slash.d.ts +126 -0
- package/dist/commands/slash.js +190 -0
- package/dist/commands/use-slash-commands.d.ts +57 -0
- package/dist/commands/use-slash-commands.js +207 -0
- package/dist/components/AgentHead.d.ts +22 -0
- package/dist/components/AgentHead.js +20 -0
- package/dist/components/ChatComposer.d.ts +43 -0
- package/dist/components/ChatComposer.js +209 -0
- package/dist/components/ChatMessage.d.ts +8 -0
- package/dist/components/ChatMessage.js +16 -0
- package/dist/components/ClientRows.d.ts +11 -0
- package/dist/components/ClientRows.js +46 -0
- package/dist/components/CodeBlock.d.ts +11 -0
- package/dist/components/CodeBlock.js +38 -0
- package/dist/components/InteractionPanel.d.ts +15 -0
- package/dist/components/InteractionPanel.js +42 -0
- package/dist/components/MarkdownRenderer.d.ts +6 -0
- package/dist/components/MarkdownRenderer.js +48 -0
- package/dist/components/RunFailureNotice.d.ts +7 -0
- package/dist/components/RunFailureNotice.js +13 -0
- package/dist/components/ShiitakeAvatar.d.ts +21 -0
- package/dist/components/ShiitakeAvatar.js +22 -0
- package/dist/components/SlashMenu.d.ts +8 -0
- package/dist/components/SlashMenu.js +50 -0
- package/dist/components/ThinkingBlock.d.ts +3 -0
- package/dist/components/ThinkingBlock.js +18 -0
- package/dist/components/TimelineItem.d.ts +15 -0
- package/dist/components/TimelineItem.js +35 -0
- package/dist/components/ToolGroup.d.ts +12 -0
- package/dist/components/ToolGroup.js +90 -0
- package/dist/components/WaitingNotice.d.ts +7 -0
- package/dist/components/WaitingNotice.js +8 -0
- package/dist/components/avatar-presentation.d.ts +16 -0
- package/dist/components/avatar-presentation.js +9 -0
- package/dist/components/sprite-service.d.ts +2 -0
- package/dist/components/sprite-service.js +9 -0
- package/dist/conversation.d.ts +165 -0
- package/dist/conversation.js +762 -0
- package/dist/core/assistant-visibility.d.ts +3 -0
- package/dist/core/assistant-visibility.js +4 -0
- package/dist/core/author-labels.d.ts +16 -0
- package/dist/core/author-labels.js +16 -0
- package/dist/core/build-timeline.d.ts +36 -0
- package/dist/core/build-timeline.js +328 -0
- package/dist/core/client-actions.d.ts +35 -0
- package/dist/core/client-actions.js +128 -0
- package/dist/core/client-contributions.d.ts +24 -0
- package/dist/core/client-contributions.js +115 -0
- package/dist/core/client-timeline.d.ts +33 -0
- package/dist/core/client-timeline.js +72 -0
- package/dist/core/format-context.d.ts +11 -0
- package/dist/core/format-context.js +20 -0
- package/dist/core/format-tokens.d.ts +2 -0
- package/dist/core/format-tokens.js +6 -0
- package/dist/core/model-identity.d.ts +3 -0
- package/dist/core/model-identity.js +7 -0
- package/dist/core/session-projection.d.ts +11 -0
- package/dist/core/session-projection.js +191 -0
- package/dist/core/session-query.d.ts +118 -0
- package/dist/core/session-query.js +187 -0
- package/dist/core/session-view-state.d.ts +66 -0
- package/dist/core/session-view-state.js +114 -0
- package/dist/core/tool-contract.d.ts +15 -0
- package/dist/core/tool-contract.js +3 -0
- package/dist/core/tool-groups.d.ts +36 -0
- package/dist/core/tool-groups.js +151 -0
- package/dist/core/tool-view.d.ts +182 -0
- package/dist/core/tool-view.js +618 -0
- package/dist/headless.d.ts +28 -0
- package/dist/headless.js +24 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/lib/failure.d.ts +2 -0
- package/dist/lib/failure.js +9 -0
- package/dist/markdown.d.ts +3 -0
- package/dist/markdown.js +6 -0
- package/dist/model-commands.d.ts +44 -0
- package/dist/model-commands.js +232 -0
- package/dist/parakeet.d.ts +77 -0
- package/dist/parakeet.js +276 -0
- package/dist/provider-choices.d.ts +8 -0
- package/dist/provider-choices.js +46 -0
- package/dist/reload-command.d.ts +43 -0
- package/dist/reload-command.js +72 -0
- package/dist/session-commands.d.ts +53 -0
- package/dist/session-commands.js +259 -0
- package/dist/session.d.ts +24 -0
- package/dist/session.js +1 -0
- package/dist/store/types.d.ts +163 -0
- package/dist/store/types.js +1 -0
- package/dist/styles.css +19129 -0
- package/docs/api/README.md +15 -0
- package/docs/api/commands/slash.md +321 -0
- package/docs/api/headless.md +2510 -0
- package/docs/api/index.md +1249 -0
- package/package.json +79 -3
- package/src/styles.css +272 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
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,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Parakeet (`@fungi.computer/parakeet`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Parakeet renders an Agent conversation from a typed Shiitake Session. Browser
|
|
4
|
+
hosts use its React view; terminal hosts use its headless hooks to render the
|
|
5
|
+
same history, composer and status through their own UI.
|
|
6
|
+
|
|
7
|
+
Website hosts mount the same `Parakeet` component inside an isolated iframe. A
|
|
8
|
+
visitor-session adapter supplies the visitor's scoped Session read, watch and
|
|
9
|
+
prompt commands; credentials and admission remain with the host.
|
|
10
|
+
`markdownPolicy="visitor"` renders Markdown links without loading images or
|
|
11
|
+
unsafe destinations. A prompt recorded for a human returns `status: "recorded"`,
|
|
12
|
+
not a fabricated Agent-work acceptance. Closing the view stops observation, not
|
|
13
|
+
the Agent's work. Pass `visitor={{ agentName: "Front Desk" }}` to hide model,
|
|
14
|
+
usage and command controls. Slash-prefixed text remains a normal visitor
|
|
15
|
+
message; the visitor mode does not register or execute slash commands.
|
|
16
|
+
|
|
17
|
+
## Set up
|
|
18
|
+
|
|
19
|
+
Install `@fungi.computer/parakeet` from its package tarball. It requires a
|
|
20
|
+
compatible React and React DOM pair, TanStack Query and an authorized Session
|
|
21
|
+
supplied by the host. The manifest supports React 18.2 or 19.2 and later within
|
|
22
|
+
those major versions. Build the dependency closure from the repository root:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pnpm turbo:run run build --filter=@fungi.computer/parakeet
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Browser hosts import `@fungi.computer/parakeet/styles.css` once. It includes the
|
|
29
|
+
Caps/Stipe foundation and conversation utilities. Do not also load
|
|
30
|
+
`@fungi.computer/caps/styles.css` in that document. Import Caps' `fonts.css`
|
|
31
|
+
separately when using its bundled fonts, with font URLs handled by your bundler.
|
|
32
|
+
|
|
33
|
+
A host compiling its own Tailwind utilities uses the `/tailwind.css` source
|
|
34
|
+
instead and supplies its own `@source`. The built stylesheet cannot scan your
|
|
35
|
+
host's additional classes.
|
|
36
|
+
|
|
37
|
+
## Quick start in a browser
|
|
38
|
+
|
|
39
|
+
Illustrative: your host supplies the authorized `ParakeetSession` and a browser
|
|
40
|
+
bundler that handles CSS. This component owns its query cache and clears it on
|
|
41
|
+
unmount. It does not create an Agent, sign in or grant tools.
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { useEffect, useState } from "react";
|
|
45
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
46
|
+
import { Parakeet, type ParakeetSession } from "@fungi.computer/parakeet";
|
|
47
|
+
import "@fungi.computer/parakeet/styles.css";
|
|
48
|
+
|
|
49
|
+
export function Conversation({ session }: { session: ParakeetSession }) {
|
|
50
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
51
|
+
useEffect(() => () => queryClient.clear(), [queryClient]);
|
|
52
|
+
return (
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<Parakeet session={session} />
|
|
55
|
+
</QueryClientProvider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Parakeet never reads a message's host metadata itself. To show who wrote a line,
|
|
61
|
+
pass `authorOf`: your reading of that metadata into a `ParakeetAuthor`
|
|
62
|
+
(`{ id, name }`). A Fungi app passes `messageAuthor` from
|
|
63
|
+
`@fungi.computer/hub-client/message-meta`; without it, lines show no author.
|
|
64
|
+
|
|
65
|
+
The view presents the Session's observed history, live work, composer and
|
|
66
|
+
errors. Screen readers receive the same Session status as the composer footer
|
|
67
|
+
through a hidden polite live region; streamed transcript tokens are outside that
|
|
68
|
+
region. The Session transport stays owned by the caller. Releasing observation
|
|
69
|
+
does not cancel accepted work or close a borrowed Agent. When replacing a
|
|
70
|
+
Session, provide its new handle rather than copying a snapshot into another
|
|
71
|
+
writable store.
|
|
72
|
+
|
|
73
|
+
The [standalone composer tests](test/standalone-composer.test.tsx) exercise the
|
|
74
|
+
provider, query cache, observations and prompt admission with typed fixtures.
|
|
75
|
+
The browser contract uses the real component with a fixture Session. A fake
|
|
76
|
+
Session checks the component, not hosted authentication or a model provider.
|
|
77
|
+
|
|
78
|
+
## Core concepts
|
|
79
|
+
|
|
80
|
+
| Surface | What it owns |
|
|
81
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `Parakeet` | The browser conversation, including provider and view. |
|
|
83
|
+
| `ParakeetProvider` / `ParakeetView` | Separate composition when the host needs custom controls in the same conversation scope. |
|
|
84
|
+
| `useTimeline()` | Durable history and the replaceable live tail, bounded history paging and model/usage presentation. |
|
|
85
|
+
| `useComposer()` | Draft, submission, pending work, cancellation and retry controls. |
|
|
86
|
+
| `useStatus()` | Observed work and connection state, including goal controls. |
|
|
87
|
+
| `useMarkdown(content)` | Parsed chat Markdown for custom and terminal renderers. |
|
|
88
|
+
|
|
89
|
+
Both composition forms require the host's `QueryClientProvider`. `Parakeet`
|
|
90
|
+
takes the `session` directly. With separate composition, pass `session` to
|
|
91
|
+
`ParakeetProvider` and put `ParakeetView` and custom hook consumers beneath it.
|
|
92
|
+
Pass `commands` and presentation props to the view, not the provider.
|
|
93
|
+
|
|
94
|
+
`Parakeet` and `ParakeetView` share these optional presentation props:
|
|
95
|
+
|
|
96
|
+
| Prop | Contract |
|
|
97
|
+
| --------------------------- | ----------------------------------------------------------------------- |
|
|
98
|
+
| `composerActionVariant` | `"primary"` or `"outline"`. |
|
|
99
|
+
| `renderToolActions` | Renders host actions for a completed `ToolItem`. |
|
|
100
|
+
| `renderAssistantAttachment` | Receives the original settled reply text. |
|
|
101
|
+
| `formatAssistantText` | Changes displayed reply text without changing the attachment input. |
|
|
102
|
+
| `onLink` | Receives a transcript URL; return `true` to prevent browser navigation. |
|
|
103
|
+
| `empty` | Replaces the empty conversation content. |
|
|
104
|
+
|
|
105
|
+
Import renderer-neutral hooks from `/headless` in the terminal. That entry
|
|
106
|
+
avoids loading the DOM presentation, Caps and Sprite closure. The root exports
|
|
107
|
+
those same hooks for browser callers. They share the Session's TanStack Query
|
|
108
|
+
cache. Derived atoms select observed values rather than duplicating Session
|
|
109
|
+
truth. History pages share the host's cache by Session address and survive
|
|
110
|
+
provider remounts until that cache expires. Paging retains four 100-message
|
|
111
|
+
pages, with bounded return cursors for the Session's before-only read API.
|
|
112
|
+
Jumping to latest cancels the outstanding history fetch and clears that shared
|
|
113
|
+
history window; the live observation remains separate.
|
|
114
|
+
|
|
115
|
+
Tool calls are grouped in the shared projection. Browser and terminal renderers
|
|
116
|
+
receive the same typed summaries, error-first sections and edit diffs.
|
|
117
|
+
Disclosure state belongs to the view. A goal row displays the committed goal;
|
|
118
|
+
pausing it leaves current accepted work running. Idle conversation state does
|
|
119
|
+
not mean the goal is complete.
|
|
120
|
+
|
|
121
|
+
## Commands and errors
|
|
122
|
+
|
|
123
|
+
A host may supply its Whistle runtime through `commands`. Parakeet builds the
|
|
124
|
+
slash registry from that host, its built-ins and advertised extension catalogs.
|
|
125
|
+
It owns parsing, completion, stable request identities and interaction state.
|
|
126
|
+
The browser and terminal render the same questions, choices and notices.
|
|
127
|
+
`/model` and `/thinking` use that interaction path.
|
|
128
|
+
|
|
129
|
+
Account commands are provider-neutral. The host's account owner and policy
|
|
130
|
+
decide which login methods are available. The presentation layer does not gain a
|
|
131
|
+
credential store or make an advertised command authorized.
|
|
132
|
+
|
|
133
|
+
The composer reconciles optimistic prompts with owner facts by request identity.
|
|
134
|
+
Retrying a failed admission preserves its request identity. Retrying failed or
|
|
135
|
+
interrupted durable work submits a new request identity. Failed observation
|
|
136
|
+
shows connection failure; reconnecting does not prove pending work failed or
|
|
137
|
+
resubmit it automatically. Typed command errors remain visible and retryable.
|
|
138
|
+
The host remains responsible for the Session transport, current permissions and
|
|
139
|
+
operator diagnostics.
|
|
140
|
+
|
|
141
|
+
## API and examples
|
|
142
|
+
|
|
143
|
+
Use the [generated API reference](docs/api/README.md) and
|
|
144
|
+
[exports](package.json). The root owns browser components, `/headless` owns
|
|
145
|
+
shared hooks, `/slash` owns command grammar, and `/agent-head` owns Agent
|
|
146
|
+
appearance contracts.
|
|
147
|
+
|
|
148
|
+
[Transcript stories](workbench/transcript.stories.tsx) show settled, streaming
|
|
149
|
+
and failed replies. [Tool group stories](workbench/tool-groups.stories.tsx) show
|
|
150
|
+
shared projections. [Slash parity tests](test/slash-parity.test.tsx) check the
|
|
151
|
+
common interaction contract.
|
|
152
|
+
[Headless surface tests](test/headless-surface.test.ts) check that non-browser
|
|
153
|
+
imports stay independent of the DOM view.
|
|
154
|
+
|
|
155
|
+
## Contribute and license
|
|
156
|
+
|
|
157
|
+
Add a fixture case to the nearest conversation, command or rendering test. From
|
|
158
|
+
the repository root:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
pnpm --dir packages/parakeet run test
|
|
162
|
+
pnpm --dir packages/parakeet run browser:contract
|
|
163
|
+
pnpm --dir packages/parakeet run docs
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The browser contract needs the repository's configured browser tooling. Unit
|
|
167
|
+
checks do not replace that proof. Parakeet is MIT licensed; see
|
|
168
|
+
[LICENSE](LICENSE).
|
|
169
|
+
|
|
170
|
+
## TypeScript consumer dependencies
|
|
171
|
+
|
|
172
|
+
Install `@modelcontextprotocol/sdk@1.25.2` alongside this package when compiling
|
|
173
|
+
its complete public declarations. The shared provider SDK imports those types
|
|
174
|
+
through an optional peer; the packed consumer supplies it explicitly. This does
|
|
175
|
+
not require a provider account or make a provider call.
|
|
176
|
+
|
|
177
|
+
## Packed consumer readiness
|
|
178
|
+
|
|
179
|
+
Run `pnpm run build`, `pnpm run pack` and `pnpm run stranger` in this package.
|
|
180
|
+
The maintained stranger proof installs only package tarballs into a fresh npm
|
|
181
|
+
consumer, checks the exact shipped files, runs the public runtime fixture and
|
|
182
|
+
compiles every JavaScript entrypoint with strict NodeNext and no library-check
|
|
183
|
+
skip. This qualification does not publish npm releases or push a repository
|
|
184
|
+
split.
|
|
185
|
+
|
|
186
|
+
See [LICENSE](LICENSE) for the license and the package manifest for its public
|
|
187
|
+
repository destination.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type WhistleContribution, type WhistleContributionLease, type WhistleInteraction, type WhistleRuntime } from "@fungi.computer/whistle";
|
|
2
|
+
/** How a person proves they may use a provider: an API key or their subscription sign-in. */
|
|
3
|
+
export type LoginMethodKind = "api_key" | "subscription";
|
|
4
|
+
/** One sign-in method a provider offers, labelled by its owner. */
|
|
5
|
+
export type LoginMethod = Readonly<{
|
|
6
|
+
/** API key or subscription sign-in. */
|
|
7
|
+
kind: LoginMethodKind;
|
|
8
|
+
/** Provider-supplied label, e.g. "Anthropic API key". */
|
|
9
|
+
label: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** One model provider in the registry. Names come from data, never code. */
|
|
12
|
+
export type LoginProvider = Readonly<{
|
|
13
|
+
/** Stable provider identity, e.g. a Pi provider id. */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Provider-supplied display name. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Sign-in methods this provider offers. */
|
|
18
|
+
methods: readonly LoginMethod[];
|
|
19
|
+
}>;
|
|
20
|
+
/** Where the credential will be used: the person's machine or Fungi's servers. */
|
|
21
|
+
export type LoginHost = "local" | "hosted";
|
|
22
|
+
/** Whose credential it is: one person, or a Team. */
|
|
23
|
+
export type LoginScope = "user" | "team";
|
|
24
|
+
/**
|
|
25
|
+
* The credential policy owner's decision, the same shape as
|
|
26
|
+
* `@fungi.computer/veil/model-credentials` `loginAllowed`. Login offers only
|
|
27
|
+
* what it permits.
|
|
28
|
+
*/
|
|
29
|
+
export type LoginPolicy = Readonly<{
|
|
30
|
+
/** Whether this method may be offered for this provider, host and scope. */
|
|
31
|
+
loginAllowed: (request: Readonly<{
|
|
32
|
+
providerId: string;
|
|
33
|
+
method: LoginMethodKind;
|
|
34
|
+
host: LoginHost;
|
|
35
|
+
scope: LoginScope;
|
|
36
|
+
}>) => boolean;
|
|
37
|
+
}>;
|
|
38
|
+
/** The providers and methods one surface may offer, in display order. */
|
|
39
|
+
export declare function loginRegistry(providers: readonly LoginProvider[], options: Readonly<{
|
|
40
|
+
policy: LoginPolicy;
|
|
41
|
+
host: LoginHost;
|
|
42
|
+
scope: LoginScope;
|
|
43
|
+
}>): readonly LoginProvider[];
|
|
44
|
+
/** A configured provider reported by the credential owner, without secrets. */
|
|
45
|
+
export type ProviderAccess = Readonly<{
|
|
46
|
+
/** Stable registry identity. */
|
|
47
|
+
providerId: string;
|
|
48
|
+
/** Provider-owned display label. */
|
|
49
|
+
providerName: string;
|
|
50
|
+
/** The configured credential kind. */
|
|
51
|
+
type: "oauth" | "api_key";
|
|
52
|
+
}>;
|
|
53
|
+
/** The credential owner behind the account commands. */
|
|
54
|
+
export type AccountOwner = Readonly<{
|
|
55
|
+
/** Host-owned popular order, shared by account and model choices. */
|
|
56
|
+
popularProviderIds: readonly string[];
|
|
57
|
+
/** Configured provider access from the credential owner's parsed status. */
|
|
58
|
+
access: (signal: AbortSignal) => Promise<readonly ProviderAccess[]>;
|
|
59
|
+
/** Every provider the owner can sign in to, before policy. */
|
|
60
|
+
providers: (signal: AbortSignal) => Promise<readonly LoginProvider[]>;
|
|
61
|
+
/** Run the provider's own sign-in; it may prompt and notify. */
|
|
62
|
+
login: (request: Readonly<{
|
|
63
|
+
providerId: string;
|
|
64
|
+
method: LoginMethodKind;
|
|
65
|
+
/** Provider-specific choice, e.g. a device-code flow. */
|
|
66
|
+
option?: string;
|
|
67
|
+
}>, interaction: WhistleInteraction) => Promise<void>;
|
|
68
|
+
/** Remove the provider's stored credential. */
|
|
69
|
+
logout: (providerId: string, interaction: WhistleInteraction) => Promise<void>;
|
|
70
|
+
/** A short human summary of configured providers. */
|
|
71
|
+
status: (signal: AbortSignal) => Promise<string>;
|
|
72
|
+
}>;
|
|
73
|
+
/** Contribution identity and the policy context for account commands. */
|
|
74
|
+
export type AccountCommandOptions = Readonly<{
|
|
75
|
+
/** Host source that owns the contribution lease. */
|
|
76
|
+
sourceId: string;
|
|
77
|
+
/** Namespace for the account commands. */
|
|
78
|
+
namespace: string;
|
|
79
|
+
/** Where credentials signed in here will be used. */
|
|
80
|
+
host: LoginHost;
|
|
81
|
+
/** Whose credentials they are. */
|
|
82
|
+
scope: LoginScope;
|
|
83
|
+
/** The credential policy owner's decision. */
|
|
84
|
+
policy: LoginPolicy;
|
|
85
|
+
}>;
|
|
86
|
+
/** One account owner and its credential policy context for an interactive model picker. */
|
|
87
|
+
export type ModelAccountContext = Pick<AccountCommandOptions, "policy" | "host" | "scope"> & Readonly<{
|
|
88
|
+
/** Existing credential owner, also used by the account commands. */
|
|
89
|
+
owner: AccountOwner;
|
|
90
|
+
}>;
|
|
91
|
+
/** Run the existing provider-owned sign-in after the caller applies credential policy. */
|
|
92
|
+
export declare function signInProvider(owner: AccountOwner, provider: LoginProvider, interaction: WhistleInteraction, request?: Readonly<{
|
|
93
|
+
method?: LoginMethodKind | undefined;
|
|
94
|
+
option?: string | undefined;
|
|
95
|
+
}>): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Provider-agnostic account commands. Every question and status update is a
|
|
98
|
+
* Whistle interaction, so any client that renders interactions renders login.
|
|
99
|
+
*/
|
|
100
|
+
export declare function accountCommandDefinitions(owner: AccountOwner, options: AccountCommandOptions): WhistleContribution["commands"];
|
|
101
|
+
/** Contribute the account commands as one lease. */
|
|
102
|
+
export declare function contributeAccountCommands(runtime: Pick<WhistleRuntime, "contribute">, owner: AccountOwner, options: AccountCommandOptions): WhistleContributionLease;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { WhistleActionError, } from "@fungi.computer/whistle";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { providerChoices } from "./provider-choices.js";
|
|
4
|
+
/** The providers and methods one surface may offer, in display order. */
|
|
5
|
+
export function loginRegistry(providers, options) {
|
|
6
|
+
return providers
|
|
7
|
+
.map((provider) => ({
|
|
8
|
+
...provider,
|
|
9
|
+
methods: provider.methods.filter((method) => options.policy.loginAllowed({
|
|
10
|
+
providerId: provider.id,
|
|
11
|
+
method: method.kind,
|
|
12
|
+
host: options.host,
|
|
13
|
+
scope: options.scope,
|
|
14
|
+
})),
|
|
15
|
+
}))
|
|
16
|
+
.filter((provider) => provider.methods.length > 0)
|
|
17
|
+
.sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
|
|
18
|
+
}
|
|
19
|
+
const loginArguments = z.strictObject({
|
|
20
|
+
providerId: z.string().min(1).max(200).optional(),
|
|
21
|
+
method: z.enum(["api_key", "subscription"]).optional(),
|
|
22
|
+
option: z.string().min(1).max(200).optional(),
|
|
23
|
+
});
|
|
24
|
+
const loginArgumentsSchema = z
|
|
25
|
+
.record(z.string(), z.json())
|
|
26
|
+
.parse(structuredClone(z.toJSONSchema(loginArguments)));
|
|
27
|
+
const logoutArguments = z.strictObject({
|
|
28
|
+
providerId: z.string().min(1).max(200).optional(),
|
|
29
|
+
});
|
|
30
|
+
const logoutArgumentsSchema = z
|
|
31
|
+
.record(z.string(), z.json())
|
|
32
|
+
.parse(structuredClone(z.toJSONSchema(logoutArguments)));
|
|
33
|
+
function methodDescription(method, provider) {
|
|
34
|
+
return method.kind === "api_key"
|
|
35
|
+
? "Paste an API key you created"
|
|
36
|
+
: `Sign in with your ${provider.name} account`;
|
|
37
|
+
}
|
|
38
|
+
function requireInteraction(input) {
|
|
39
|
+
if (input.interaction === undefined)
|
|
40
|
+
throw new WhistleActionError("interaction_required", "Signing in needs an interactive client.");
|
|
41
|
+
return input.interaction;
|
|
42
|
+
}
|
|
43
|
+
async function chooseProvider(providers, requested, interaction, message, choices) {
|
|
44
|
+
if (requested !== undefined) {
|
|
45
|
+
const provider = providers.find((candidate) => candidate.id === requested);
|
|
46
|
+
if (provider === undefined)
|
|
47
|
+
throw new WhistleActionError("unknown_provider", `${requested} is not available here.`);
|
|
48
|
+
return provider;
|
|
49
|
+
}
|
|
50
|
+
if (providers.length === 0)
|
|
51
|
+
throw new WhistleActionError("no_providers", "No model providers are available here.");
|
|
52
|
+
const id = await interaction.prompt({
|
|
53
|
+
type: "choice",
|
|
54
|
+
message,
|
|
55
|
+
collapsedGroup: "More providers",
|
|
56
|
+
options: choices,
|
|
57
|
+
});
|
|
58
|
+
const provider = providers.find((candidate) => candidate.id === id);
|
|
59
|
+
if (provider === undefined)
|
|
60
|
+
throw new WhistleActionError("unknown_provider", `${id} is unavailable.`);
|
|
61
|
+
return provider;
|
|
62
|
+
}
|
|
63
|
+
async function chooseMethod(provider, requested, interaction) {
|
|
64
|
+
if (requested !== undefined) {
|
|
65
|
+
const method = provider.methods.find((one) => one.kind === requested);
|
|
66
|
+
if (method === undefined)
|
|
67
|
+
throw new WhistleActionError("method_unavailable", `${provider.name} does not offer that sign-in method here.`);
|
|
68
|
+
return method;
|
|
69
|
+
}
|
|
70
|
+
const [only, ...rest] = provider.methods;
|
|
71
|
+
if (only !== undefined && rest.length === 0)
|
|
72
|
+
return only;
|
|
73
|
+
const kind = await interaction.prompt({
|
|
74
|
+
type: "choice",
|
|
75
|
+
message: `How do you want to sign in to ${provider.name}?`,
|
|
76
|
+
options: provider.methods.map((method) => ({
|
|
77
|
+
value: method.kind,
|
|
78
|
+
label: method.label,
|
|
79
|
+
description: methodDescription(method, provider),
|
|
80
|
+
})),
|
|
81
|
+
});
|
|
82
|
+
const method = provider.methods.find((one) => one.kind === kind);
|
|
83
|
+
if (method === undefined)
|
|
84
|
+
throw new WhistleActionError("method_unavailable", "Choose a listed method.");
|
|
85
|
+
return method;
|
|
86
|
+
}
|
|
87
|
+
/** Run the existing provider-owned sign-in after the caller applies credential policy. */
|
|
88
|
+
export async function signInProvider(owner, provider, interaction, request = {}) {
|
|
89
|
+
const method = await chooseMethod(provider, request.method, interaction);
|
|
90
|
+
interaction.notify({
|
|
91
|
+
type: "progress",
|
|
92
|
+
message: `Signing in to ${provider.name}…`,
|
|
93
|
+
});
|
|
94
|
+
await owner.login(request.option === undefined
|
|
95
|
+
? { providerId: provider.id, method: method.kind }
|
|
96
|
+
: {
|
|
97
|
+
providerId: provider.id,
|
|
98
|
+
method: method.kind,
|
|
99
|
+
option: request.option,
|
|
100
|
+
}, interaction);
|
|
101
|
+
interaction.notify({
|
|
102
|
+
type: "success",
|
|
103
|
+
message: `Signed in to ${provider.name}.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function signOutProvider(owner, provider, interaction) {
|
|
107
|
+
const confirmed = await interaction.prompt({
|
|
108
|
+
type: "confirmation",
|
|
109
|
+
message: `Sign out of ${provider.name}? Local Sessions stop using this account.`,
|
|
110
|
+
confirmLabel: "Sign out",
|
|
111
|
+
cancelLabel: "Keep signed in",
|
|
112
|
+
});
|
|
113
|
+
if (!confirmed)
|
|
114
|
+
return;
|
|
115
|
+
await owner.logout(provider.id, interaction);
|
|
116
|
+
interaction.notify({
|
|
117
|
+
type: "success",
|
|
118
|
+
message: `Signed out of ${provider.name}.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Cancelling a sign-in is an ordinary ending, not a failure. */
|
|
122
|
+
async function unlessCancelled(interaction, work) {
|
|
123
|
+
try {
|
|
124
|
+
await work();
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
if (interaction.signal.aborted)
|
|
129
|
+
return false;
|
|
130
|
+
throw cause;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Provider-agnostic account commands. Every question and status update is a
|
|
135
|
+
* Whistle interaction, so any client that renders interactions renders login.
|
|
136
|
+
*/
|
|
137
|
+
export function accountCommandDefinitions(owner, options) {
|
|
138
|
+
const { policy } = options;
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
id: "account",
|
|
142
|
+
title: "Model accounts",
|
|
143
|
+
category: "Settings",
|
|
144
|
+
projections: {
|
|
145
|
+
help: { order: 10 },
|
|
146
|
+
slash: { aliases: ["account"], order: 10 },
|
|
147
|
+
},
|
|
148
|
+
handler: async (input) => {
|
|
149
|
+
const summary = await owner.status(input.signal ?? new AbortController().signal);
|
|
150
|
+
input.interaction?.notify({ type: "info", message: summary });
|
|
151
|
+
return summary;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "login",
|
|
156
|
+
title: "Sign in to a model provider",
|
|
157
|
+
category: "Settings",
|
|
158
|
+
projections: {
|
|
159
|
+
help: { order: 11 },
|
|
160
|
+
menu: { order: 11 },
|
|
161
|
+
palette: { order: 11 },
|
|
162
|
+
slash: { aliases: ["login"], order: 11 },
|
|
163
|
+
},
|
|
164
|
+
action: { inputSchema: loginArgumentsSchema },
|
|
165
|
+
handler: async (input) => {
|
|
166
|
+
const interaction = requireInteraction(input);
|
|
167
|
+
const request = loginArguments.parse(input.arguments ?? {});
|
|
168
|
+
await unlessCancelled(interaction, async () => {
|
|
169
|
+
const registry = loginRegistry(await owner.providers(interaction.signal), { policy, host: options.host, scope: options.scope });
|
|
170
|
+
const access = request.providerId === undefined
|
|
171
|
+
? await owner.access(interaction.signal)
|
|
172
|
+
: [];
|
|
173
|
+
const provider = await chooseProvider(registry, request.providerId, interaction, "Choose a model provider", providerChoices(registry, access, owner.popularProviderIds, "login"));
|
|
174
|
+
if (request.providerId === undefined &&
|
|
175
|
+
access.some((item) => item.providerId === provider.id)) {
|
|
176
|
+
const action = await interaction.prompt({
|
|
177
|
+
type: "choice",
|
|
178
|
+
message: provider.name,
|
|
179
|
+
options: [
|
|
180
|
+
{ value: "switch", label: "Switch account" },
|
|
181
|
+
{ value: "logout", label: "Sign out" },
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
if (action === "logout") {
|
|
185
|
+
await signOutProvider(owner, provider, interaction);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await signInProvider(owner, provider, interaction, request);
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "logout",
|
|
195
|
+
title: "Sign out of a model provider",
|
|
196
|
+
category: "Settings",
|
|
197
|
+
projections: {
|
|
198
|
+
help: { order: 12 },
|
|
199
|
+
palette: { order: 12 },
|
|
200
|
+
slash: { aliases: ["logout"], order: 12 },
|
|
201
|
+
},
|
|
202
|
+
action: { inputSchema: logoutArgumentsSchema },
|
|
203
|
+
handler: async (input) => {
|
|
204
|
+
const interaction = requireInteraction(input);
|
|
205
|
+
const request = logoutArguments.parse(input.arguments ?? {});
|
|
206
|
+
await unlessCancelled(interaction, async () => {
|
|
207
|
+
// Only providers the credential owner reports as signed in can be signed out of.
|
|
208
|
+
const signedIn = (await owner.access(interaction.signal))
|
|
209
|
+
.map((access) => ({
|
|
210
|
+
id: access.providerId,
|
|
211
|
+
name: access.providerName,
|
|
212
|
+
methods: [],
|
|
213
|
+
}))
|
|
214
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
215
|
+
if (signedIn.length === 0) {
|
|
216
|
+
interaction.notify({
|
|
217
|
+
type: "info",
|
|
218
|
+
message: await owner.status(interaction.signal),
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const provider = await chooseProvider(signedIn, request.providerId, interaction, "Sign out of which provider?", signedIn.map((provider) => ({
|
|
223
|
+
value: provider.id,
|
|
224
|
+
label: provider.name,
|
|
225
|
+
})));
|
|
226
|
+
await signOutProvider(owner, provider, interaction);
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
/** Contribute the account commands as one lease. */
|
|
233
|
+
export function contributeAccountCommands(runtime, owner, options) {
|
|
234
|
+
return runtime.contribute({
|
|
235
|
+
sourceId: options.sourceId,
|
|
236
|
+
namespace: options.namespace,
|
|
237
|
+
commands: accountCommandDefinitions(owner, options),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ClientContributionV1 } from "@fungi.computer/shiitake/client-entry";
|
|
2
|
+
import type { ParakeetCommandRuntime } from "./commands/use-slash-commands.js";
|
|
3
|
+
/** Ordinary advertised actions over the current admitted rows and observation. */
|
|
4
|
+
export declare function useClientActions(commands: ParakeetCommandRuntime): {
|
|
5
|
+
/** Current Session-owned state of admitted action intents. */
|
|
6
|
+
state: ReadonlyMap<{
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly input?: {
|
|
10
|
+
readonly [x: string]: import("effect/Schema").Json;
|
|
11
|
+
} | undefined;
|
|
12
|
+
}, Readonly<{
|
|
13
|
+
pending: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>>;
|
|
16
|
+
/** Observation is live and this Session's action owner is attached. */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** Execute an admitted intent through its owning advertised command. */
|
|
19
|
+
execute: (entryId: string, action: NonNullable<ClientContributionV1["waiting"]>["cancel"], waiting?: boolean) => Promise<void>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { useClientContributions, useParakeetInteractions, useParakeetSession, useStatus, } from "./conversation.js";
|
|
3
|
+
import { createClientActions, } from "./core/client-actions.js";
|
|
4
|
+
const emptyActions = new Map();
|
|
5
|
+
const noActions = () => emptyActions;
|
|
6
|
+
const noSubscription = () => () => undefined;
|
|
7
|
+
/** Ordinary advertised actions over the current admitted rows and observation. */
|
|
8
|
+
export function useClientActions(commands) {
|
|
9
|
+
const contributions = useClientContributions();
|
|
10
|
+
const { observation } = useStatus();
|
|
11
|
+
const interactions = useParakeetInteractions();
|
|
12
|
+
const session = useParakeetSession();
|
|
13
|
+
const [owner, setOwner] = useState();
|
|
14
|
+
// This mutable input carries committed slots, never an optimistic projection.
|
|
15
|
+
const input = useRef({ contributions, observation });
|
|
16
|
+
input.current = { contributions, observation };
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const selected = createClientActions({
|
|
19
|
+
commands,
|
|
20
|
+
contributions: () => input.current.contributions,
|
|
21
|
+
online: () => input.current.observation.status === "live",
|
|
22
|
+
interactions,
|
|
23
|
+
});
|
|
24
|
+
setOwner(selected);
|
|
25
|
+
return () => {
|
|
26
|
+
selected.dispose();
|
|
27
|
+
};
|
|
28
|
+
}, [commands, interactions, session]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
owner?.refresh();
|
|
31
|
+
}, [owner, contributions]);
|
|
32
|
+
const state = useSyncExternalStore(owner?.subscribe ?? noSubscription, owner?.snapshot ?? noActions, owner?.snapshot ?? noActions);
|
|
33
|
+
return {
|
|
34
|
+
/** Current Session-owned state of admitted action intents. */
|
|
35
|
+
state,
|
|
36
|
+
/** Observation is live and this Session's action owner is attached. */
|
|
37
|
+
enabled: owner !== undefined && observation.status === "live",
|
|
38
|
+
/** Execute an admitted intent through its owning advertised command. */
|
|
39
|
+
execute: (entryId, action, waiting = false) => owner?.execute(entryId, action, waiting) ?? Promise.resolve(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SessionExtensionSlots } from "@fungi.computer/shiitake/client";
|
|
2
|
+
import type { WhistleRuntime } from "@fungi.computer/whistle";
|
|
3
|
+
import type { ParakeetSession } from "../session.js";
|
|
4
|
+
/** Why one advertised catalog could not become commands. */
|
|
5
|
+
export type AdvertisedCatalogError = Readonly<{
|
|
6
|
+
/** The extension that advertised it. */
|
|
7
|
+
extensionId: string;
|
|
8
|
+
/** What was wrong. */
|
|
9
|
+
message: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Live commands for every catalog a Session's extensions advertise. */
|
|
12
|
+
export type AdvertisedCommands = Readonly<{
|
|
13
|
+
/** Apply the latest extension slots; changed catalogs replace their commands. */
|
|
14
|
+
update: (slots: SessionExtensionSlots) => readonly AdvertisedCatalogError[];
|
|
15
|
+
/** Withdraw every advertised command. */
|
|
16
|
+
dispose: () => void;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Turn each running extension's advertised `whistle.catalog/v1` catalog into
|
|
20
|
+
* Whistle commands that call that extension's own door. The client names no
|
|
21
|
+
* command: a new extension's catalog appears as new commands.
|
|
22
|
+
*/
|
|
23
|
+
export declare function advertisedCommands(runtime: Pick<WhistleRuntime, "contribute">, session: Pick<ParakeetSession, "extension">, onInspect?: (entryId: string, commandId: string) => void): AdvertisedCommands;
|