@guuey/chat 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/hitl.d.ts +5 -0
  2. package/dist/hitl.d.ts.map +1 -1
  3. package/dist/hitl.js +5 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/native/components.d.ts.map +1 -1
  8. package/dist/native/components.js +5 -5
  9. package/dist/oauth.d.ts +87 -0
  10. package/dist/oauth.d.ts.map +1 -0
  11. package/dist/oauth.js +121 -0
  12. package/dist/plan.d.ts.map +1 -1
  13. package/dist/plan.js +4 -1
  14. package/dist/react/components.d.ts.map +1 -1
  15. package/dist/react/components.js +7 -5
  16. package/dist/react/guuey-chat.d.ts +14 -0
  17. package/dist/react/guuey-chat.d.ts.map +1 -1
  18. package/dist/react/guuey-chat.js +21 -3
  19. package/dist/react/oauth-return.d.ts +61 -0
  20. package/dist/react/oauth-return.d.ts.map +1 -0
  21. package/dist/react/oauth-return.js +103 -0
  22. package/dist/react/use-transcript.d.ts +3 -3
  23. package/dist/react/use-transcript.d.ts.map +1 -1
  24. package/dist/react/use-transcript.js +4 -27
  25. package/dist/react.d.ts +1 -0
  26. package/dist/react.d.ts.map +1 -1
  27. package/dist/react.js +1 -0
  28. package/dist/strings.d.ts +10 -0
  29. package/dist/strings.d.ts.map +1 -1
  30. package/dist/strings.js +4 -0
  31. package/dist/types.d.ts +25 -11
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +4 -3
  34. package/src/corpus/README.md +7 -1
  35. package/src/corpus/__snapshots__/corpus.test.ts.snap +126 -7
  36. package/src/corpus/drive.ts +43 -12
  37. package/src/corpus/fixtures.ts +95 -4
  38. package/src/hitl.ts +5 -0
  39. package/src/index.ts +11 -0
  40. package/src/native/components.tsx +15 -7
  41. package/src/oauth.ts +157 -0
  42. package/src/plan.ts +4 -1
  43. package/src/react/components.tsx +17 -9
  44. package/src/react/guuey-chat.tsx +49 -1
  45. package/src/react/oauth-return.ts +129 -0
  46. package/src/react/use-transcript.ts +7 -32
  47. package/src/react.tsx +9 -0
  48. package/src/strings.ts +14 -0
  49. package/src/types.ts +22 -11
  50. package/styles.css +26 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The web half of the OAuth "authorize this server" arm (guuey#178 Slice 4)
3
+ * — the DOM-touching pieces the pure `../oauth.js` helpers deliberately
4
+ * leave out. Every web surface (the `<GuueyChat>` composite, the widget, the
5
+ * studio agent page) shares these three so the redirect + return behave the
6
+ * same everywhere:
7
+ *
8
+ * - {@link oauthReturnToHere} — the surface's own location, with a stale
9
+ * `?connected=`/`?error=` from a PREVIOUS dance stripped, ready to be
10
+ * the next `returnTo`.
11
+ * - {@link openOAuthAuthorize} — how the link is opened: a top-level page
12
+ * navigates in place (the broker 302s straight back to `returnTo`); a
13
+ * FRAMED page (the widget inside a customer origin) opens a new tab,
14
+ * because identity providers refuse to render inside a third-party
15
+ * frame (`X-Frame-Options` / `frame-ancestors`).
16
+ * - {@link useOAuthReturn} — on mount, read the broker's return params off
17
+ * the address bar, REPLACE the URL without them (a reload never re-shows
18
+ * the notice), and hand the surface a one-shot notice to render.
19
+ */
20
+ import { useCallback, useEffect, useState } from "react";
21
+ import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
22
+ import type { HitlPromptAction } from "../hitl.js";
23
+ import { oauthAuthorizeHref, parseOAuthReturn, stripOAuthReturn, type OAuthReturn } from "../oauth.js";
24
+ import type { HitlPromptItem } from "../types.js";
25
+
26
+ /** The surface's own location as the next `returnTo` (stale return params stripped). */
27
+ export function oauthReturnToHere(): string {
28
+ return stripOAuthReturn(window.location.href);
29
+ }
30
+
31
+ /** The slice of `window` the opener touches (injectable — jsdom's `location.assign` cannot be spied). */
32
+ export interface OAuthWindow {
33
+ self: object;
34
+ top: object | null;
35
+ open: (url: string, target: string, features: string) => unknown;
36
+ location: { assign: (url: string) => void };
37
+ }
38
+
39
+ /** Whether this document is rendered inside another origin's frame. */
40
+ function isFramed(win: OAuthWindow): boolean {
41
+ try {
42
+ return win.top !== win.self;
43
+ } catch {
44
+ // A cross-origin `window.top` read throws in some browsers — that IS framed.
45
+ return true;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Open the authorize link. In place when top-level (the dance ends back on
51
+ * this page); a new tab when framed (the IdP will not render in a frame —
52
+ * `returnTo` then lands the SAME page top-level in that tab, where
53
+ * {@link useOAuthReturn} shows the notice; the embedded chat picks the
54
+ * connection up on its next turn).
55
+ */
56
+ export function openOAuthAuthorize(href: string, win: OAuthWindow = window): void {
57
+ if (isFramed(win)) {
58
+ win.open(href, "_blank", "noopener,noreferrer");
59
+ return;
60
+ }
61
+ win.location.assign(href);
62
+ }
63
+
64
+ export interface UseOAuthReturnResult {
65
+ /** The broker's answer found on the address bar at mount, until dismissed. */
66
+ notice: OAuthReturn | null;
67
+ dismiss: () => void;
68
+ }
69
+
70
+ /**
71
+ * Read `?connected=<serverName>` / `?error=<reason>` off the current
72
+ * location ONCE (on mount), strip them from the address bar via
73
+ * `history.replaceState`, and expose the result as a dismissible notice.
74
+ * SSR-safe: nothing runs until the effect.
75
+ */
76
+ export function useOAuthReturn(): UseOAuthReturnResult {
77
+ const [notice, setNotice] = useState<OAuthReturn | null>(null);
78
+ useEffect(() => {
79
+ if (typeof window === "undefined") return;
80
+ const href = window.location.href;
81
+ const found = parseOAuthReturn(href);
82
+ if (found === null) return;
83
+ const clean = stripOAuthReturn(href);
84
+ if (clean !== href) window.history.replaceState(window.history.state, "", clean);
85
+ setNotice(found);
86
+ }, []);
87
+ const dismiss = useCallback(() => setNotice(null), []);
88
+ return { notice, dismiss };
89
+ }
90
+
91
+ export interface OAuthPromptActionArgs {
92
+ item: HitlPromptItem;
93
+ action: HitlPromptAction;
94
+ /** The kit's ledger (`useTranscriptInputs().answerHitlPrompt`) — records the pick locally; nothing is delivered. */
95
+ answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
96
+ /** Where the broker should send the user back; defaults to {@link oauthReturnToHere}. */
97
+ returnTo?: string;
98
+ /** How to open the link; defaults to {@link openOAuthAuthorize}. */
99
+ open?: (href: string, ask: AgPausedAsk) => void;
100
+ }
101
+
102
+ /**
103
+ * The OAuth arm of a prompt action, shared by every web surface. Returns
104
+ * `false` (nothing done) when the item is not an OAuth ask, so a host's
105
+ * #207 hitl branch (build the answer, POST it to the pod door) runs as
106
+ * before. For an OAuth ask:
107
+ *
108
+ * - a mode pick (or a plain accept on a mode-less ask) records the pick in
109
+ * the ledger — the card shows "Connecting — <mode>" — and OPENS
110
+ * `authorizationUrl&mode=<id>&returnTo=<here>`; NOTHING is posted to the
111
+ * pod (there is no answer door — the answer is the redirect);
112
+ * - "Not now" / decline / dismiss all record `cancelled` (still pending,
113
+ * re-askable): nothing is written anywhere and the pod asks again next
114
+ * turn.
115
+ */
116
+ export function oauthPromptAction(args: OAuthPromptActionArgs): boolean {
117
+ const { item, action, answerHitlPrompt } = args;
118
+ if (item.oauth === null) return false;
119
+ if (typeof action === "object" || action === "accept") {
120
+ const grantModeId = typeof action === "object" ? action.grantModeId : null;
121
+ answerHitlPrompt(item.ask, action);
122
+ const href = oauthAuthorizeHref(item.ask, grantModeId, args.returnTo ?? oauthReturnToHere());
123
+ if (args.open !== undefined) args.open(href, item.ask);
124
+ else openOAuthAuthorize(href);
125
+ return true;
126
+ }
127
+ answerHitlPrompt(item.ask, "dismiss");
128
+ return true;
129
+ }
@@ -227,9 +227,9 @@ export interface UseTranscriptInputsResult {
227
227
  * VALIDATES it against the ask's persisted record (`validateHitlAnswer`
228
228
  * — required-iff-declared, echo-must-be-declared, requestState byte-echo)
229
229
  * BEFORE anything dispatches, records it in the ledger, and returns it
230
- * for the HOST to deliver — the kit renders and validates; the answer
231
- * transport is the host's (no client→pod hitl-answer channel exists on
232
- * the guuey wire today; see the #16 producer flag).
230
+ * for the HOST to deliver — the kit renders and validates; the transport
231
+ * is the host's (`@guuey/agent-client`'s `createHitlAnswerRelay` posts it
232
+ * to `<pod>/agent/hitl-answer`, guuey#207).
233
233
  */
234
234
  answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
235
235
  }
@@ -250,36 +250,12 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
250
250
  return () => clearInterval(timer);
251
251
  }, [invoke.status]);
252
252
 
253
- // R10 ledger: the hook exposes only the LATEST pending ask; the
254
- // transcript keeps the record of every ask and its resolution.
253
+ // R10 ledger (the LINK invite): the hook exposes only the LATEST pending
254
+ // invite; the transcript keeps the record of every ask and its resolution.
255
+ // Consent is NOT ledgered here — it is an AgJSON paused turn in the fold
256
+ // (`hitlPromptsFromFold` below, guuey#207).
255
257
  const [prompts, setPrompts] = useState<ProfilePromptInput[]>([]);
256
258
  const promptSeq = useRef(0);
257
- useEffect(() => {
258
- const request = invoke.profileConsentRequest;
259
- if (request === null) {
260
- setPrompts((prev) =>
261
- prev.some((p) => p.kind === "consent" && p.state === "pending")
262
- ? prev.map((p) =>
263
- p.kind === "consent" && p.state === "pending" ? { ...p, state: "dismissed" } : p,
264
- )
265
- : prev,
266
- );
267
- return;
268
- }
269
- setPrompts((prev) => {
270
- if (prev.some((p) => p.kind === "consent" && p.state === "pending")) return prev;
271
- return [
272
- ...prev,
273
- {
274
- id: `consent.${promptSeq.current++}`,
275
- kind: "consent",
276
- appId: request.appId,
277
- requested: request.requested,
278
- state: "pending",
279
- },
280
- ];
281
- });
282
- }, [invoke.profileConsentRequest]);
283
259
  useEffect(() => {
284
260
  const request = invoke.profileLinkRequest;
285
261
  if (request === null) {
@@ -313,7 +289,6 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
313
289
  // Clearing the hook's pending request AFTER the ledger moved keeps the
314
290
  // dismissal effect above from double-transitioning it.
315
291
  const pending = prompts.find((p) => p.id === id);
316
- if (pending?.kind === "consent") invoke.clearProfileConsentRequest();
317
292
  if (pending?.kind === "link") invoke.clearProfileLinkRequest();
318
293
  },
319
294
  [invoke, prompts],
package/src/react.tsx CHANGED
@@ -49,5 +49,14 @@ export {
49
49
  type UseTranscriptInputsResult,
50
50
  } from "./react/use-transcript.js";
51
51
  export { GuueyChat, type GuueyChatProps, type GuueyChatHandle } from "./react/guuey-chat.js";
52
+ export {
53
+ oauthPromptAction,
54
+ oauthReturnToHere,
55
+ openOAuthAuthorize,
56
+ useOAuthReturn,
57
+ type OAuthPromptActionArgs,
58
+ type OAuthWindow,
59
+ type UseOAuthReturnResult,
60
+ } from "./react/oauth-return.js";
52
61
  export { Markdown } from "./react/markdown.js";
53
62
  export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
package/src/strings.ts CHANGED
@@ -96,6 +96,16 @@ export interface ChatStrings {
96
96
  /** The answered record line, e.g. `Allowed — Always`. */
97
97
  promptAnsweredWith: (modeLabel: string) => string;
98
98
  promptDeclinedRecord: string;
99
+ /**
100
+ * The OAuth arm (guuey#178): the dismiss action ("Not now" — nothing is
101
+ * written, the ask re-emits next turn), the answered record (the user was
102
+ * sent to the provider), and the return notices the surface shows after
103
+ * the broker 302s back with `?connected=<serverName>` / `?error=<reason>`.
104
+ */
105
+ promptNotNow: string;
106
+ promptOAuthSent: (modeLabel: string) => string;
107
+ oauthConnected: (serverName: string) => string;
108
+ oauthFailed: (reason: string) => string;
99
109
 
100
110
  /** R16 — the notice row's label (provenance shows only under debug). */
101
111
  noticeLabel: string;
@@ -171,6 +181,10 @@ export const defaultChatStrings: ChatStrings = {
171
181
  promptDismissed: "Dismissed",
172
182
  promptAnsweredWith: (modeLabel) => `Allowed — ${modeLabel}`,
173
183
  promptDeclinedRecord: "Not allowed",
184
+ promptNotNow: "Not now",
185
+ promptOAuthSent: (modeLabel) => `Connecting — ${modeLabel}`,
186
+ oauthConnected: (serverName) => `Connected ${serverName}. The agent can use it from your next message.`,
187
+ oauthFailed: (reason) => `Couldn't connect: ${reason}`,
174
188
 
175
189
  noticeLabel: "Note",
176
190
 
package/src/types.ts CHANGED
@@ -47,14 +47,17 @@ export interface TranscriptMessage {
47
47
  }
48
48
 
49
49
  /**
50
- * A pending/resolved consent or link ask (R10) — the turn-level
51
- * `profile-consent` / `profile-link` events lifted into renderable state.
52
- * The assembler (3b) accumulates these from `invokeTurn` events; `id` is
53
- * assembler-chosen and stable for the ask's lifetime.
50
+ * A pending/resolved account-LINK invite (R10) — the turn-level
51
+ * `profile-link` event lifted into renderable state. The assembler (3b)
52
+ * accumulates these from `invokeTurn` events; `id` is assembler-chosen and
53
+ * stable for the ask's lifetime. (Consent is NOT a profile arm any more:
54
+ * since guuey#207 the pod asks over AgJSON `hitl.ask` + `turn.done
55
+ * outcome:"paused"` with declared `grantModes`, and it renders through
56
+ * {@link HitlPromptInput}.)
54
57
  */
55
58
  export interface ProfilePromptInput {
56
59
  id: string;
57
- kind: "consent" | "link";
60
+ kind: "link";
58
61
  appId: string;
59
62
  requested: "read" | "read-write";
60
63
  state: "pending" | "answered" | "declined" | "dismissed";
@@ -83,9 +86,9 @@ export interface HitlPromptInput {
83
86
 
84
87
  /**
85
88
  * R10 prompt inputs — a discriminated union (narrow on `kind`). The
86
- * profile arm is the original guuey-wire shape unchanged; `hitl` arrived
87
- * with spec draft.2. (Direct un-narrowed reads of profile-only fields are
88
- * the one shape this union retired — narrow first.)
89
+ * profile arm is the guuey-wire LINK invite; `hitl` (spec draft.2) carries
90
+ * every AgJSON ask, consent included. (Direct un-narrowed reads of
91
+ * profile-only fields are the one shape this union retired — narrow first.)
89
92
  */
90
93
  export type PromptItemInput = ProfilePromptInput | HitlPromptInput;
91
94
 
@@ -117,7 +120,7 @@ export interface TranscriptInputs {
117
120
  statusElapsedMs: number;
118
121
  activeTool: string | null;
119
122
  error: { message: string; code: string | null } | null;
120
- /** Pending/answered consent + link asks (R10). */
123
+ /** Pending/answered asks (R10): the link invite + AgJSON hitl asks (consent). */
121
124
  prompts: PromptItemInput[];
122
125
  /**
123
126
  * The settled conversation, both roles, in order. Assistant entries are
@@ -320,12 +323,12 @@ export interface CitationsItem extends BaseItem {
320
323
  style: "chips" | "list";
321
324
  }
322
325
 
323
- /** R10 — the guuey-wire consent/link prompt card (the original arm). */
326
+ /** R10 — the guuey-wire account-LINK prompt card (the original arm). */
324
327
  export interface ProfilePromptItem extends BaseItem {
325
328
  kind: "prompt";
326
329
  /** The `PromptItemInput.id` this row records — the host's resolution key. */
327
330
  promptId: string;
328
- promptKind: "consent" | "link";
331
+ promptKind: "link";
329
332
  appId: string;
330
333
  requested: "read" | "read-write";
331
334
  state: "pending" | "answered" | "declined" | "dismissed";
@@ -352,6 +355,14 @@ export interface HitlPromptItem extends BaseItem {
352
355
  askKind: AgPausedAsk["kind"];
353
356
  /** Declared accept variants; empty = a plain accept/decline ask. */
354
357
  grantModes: readonly AgGrantMode[];
358
+ /**
359
+ * The OAuth arm (guuey#178): set when the ask is `kind:"auth"` with
360
+ * `authConfig.scheme:"oauth2"` + an `authorizationUrl`. There is no
361
+ * answer door for this ask — a mode pick OPENS `authorizationUrl` with
362
+ * `&mode=` + `&returnTo=` appended (`oauthAuthorizeHref`); "Not now" is a
363
+ * plain dismissal. `null` for every other ask.
364
+ */
365
+ oauth: { authorizationUrl: string; scopes: readonly string[] } | null;
355
366
  state: "pending" | "resolved" | "declined" | "cancelled";
356
367
  /** Echo of the chosen mode id (identity, never displayed as meaning). */
357
368
  chosenModeId: string | null;
package/styles.css CHANGED
@@ -349,6 +349,32 @@
349
349
  margin: 0;
350
350
  }
351
351
 
352
+ /* The OAuth "authorize this server" return notice (guuey#178) — shown by
353
+ <GuueyChat> after the broker sends the user back with ?connected= / ?error=. */
354
+ .guuey-chat-oauth-notice {
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: space-between;
358
+ gap: 8px;
359
+ margin: 0;
360
+ padding: 8px 12px;
361
+ font-size: 0.85em;
362
+ color: var(--guuey-chat-ink);
363
+ background: color-mix(in srgb, var(--guuey-chat-accent) 10%, var(--guuey-chat-surface));
364
+ border-top: 1px solid var(--guuey-chat-canvas-muted);
365
+ }
366
+ .guuey-chat-oauth-error {
367
+ background: color-mix(in srgb, var(--guuey-chat-error) 7%, var(--guuey-chat-surface));
368
+ }
369
+ .guuey-chat-oauth-dismiss {
370
+ font: inherit;
371
+ cursor: pointer;
372
+ border: 0;
373
+ background: transparent;
374
+ color: var(--guuey-chat-ink-muted);
375
+ text-decoration: underline;
376
+ }
377
+
352
378
  /* ── R11 errors ── */
353
379
  .guuey-chat-error {
354
380
  border: 1px solid color-mix(in srgb, var(--guuey-chat-error) 45%, transparent);