@hanzo/build 0.2.78 → 0.2.80

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/README.md CHANGED
@@ -226,10 +226,15 @@ are any member's.
226
226
  - Plugins: TypeScript connectors built on the platform, from source or from a
227
227
  description of an API; a failed build says why. Browse offers building one and
228
228
  lists what the deployment mounts.
229
- - Agents: Browse is the platform's presets whose tool calls run on the platform,
230
- each a Use that opens a new agent written from it; Yours is the org's own —
231
- model, instructions, tools, budget — created, edited (only what changed is
232
- sent) and deleted.
229
+ - Agents: Browse is every agent to start from — the host's default agents
230
+ (`cast`) and personas (`personas`), and the platform's presets whose tool
231
+ calls run on the platform — searched by name and filtered by category. Use
232
+ makes one agent of the org's own from one template (a name taken becomes
233
+ `-2`, `-3`), with its face, model and instructions, and opens its profile;
234
+ nothing is added in bulk. Start from scratch, Clone yourself (the person
235
+ signed in, with writing pasted or uploaded) and Create from a person make one
236
+ too. Yours is the org's own — model, instructions, tools, budget — created,
237
+ edited (only what changed is sent) and deleted.
233
238
 
234
239
  **New.** "What's up next?", and at the foot the composer: where the run runs
235
240
  (Cloud is the platform's sandbox with the codebase's environment; the org's
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Agents: the org's own — a model, instructions, the tools it may call and a
3
- * budget — and the presets one can start from.
2
+ * Agents: the org's own — a model, instructions, the tools it may call, a
3
+ * budget and a face — and the templates one is made from.
4
4
  *
5
5
  * GET /v1/agent {agents: [agent]}, without instructions
6
6
  * POST /v1/agent create → 201 agent
@@ -35,6 +35,8 @@ export interface Agent {
35
35
  task: number;
36
36
  spent: number;
37
37
  period: Period | '';
38
+ /** Its picture: an https link or a data URL; '' draws its emoji or initial. */
39
+ avatar: string;
38
40
  emoji: string;
39
41
  }
40
42
  export interface Preset {
@@ -52,6 +54,8 @@ export interface Draft {
52
54
  cap: string;
53
55
  task: string;
54
56
  period: Period;
57
+ /** Sent on create only: an https link or a data URL. */
58
+ avatar: string;
55
59
  }
56
60
  /** An agent's name, as the handler takes it. */
57
61
  export declare const NAME: RegExp;
@@ -86,29 +90,45 @@ export declare function profile(t: Target, ref: string): Promise<{
86
90
  agent: Agent;
87
91
  runs: Run[];
88
92
  }>;
89
- /** A default agent a host offers: a name, its line, its model and its instructions. */
93
+ /** An agent to start from: a default agent, a persona or a preset. */
90
94
  export interface Cast {
95
+ /** The name it is made under. */
91
96
  name: string;
97
+ /** What Browse calls it. */
98
+ title: string;
92
99
  description: string;
100
+ /** What Browse lists it under. */
101
+ category: string;
93
102
  model: string;
94
103
  instructions: string;
95
- /** Its face, an address of the host, when it has one. */
104
+ /** Reads the instructions, where they are a file the host serves. */
105
+ read?: () => Promise<string>;
106
+ /** Its face, an address of the host. */
96
107
  avatar?: string;
108
+ tools?: string[];
97
109
  }
98
- /** What a default agent may spend until its owner says otherwise, in dollars: each month, and one run. */
110
+ /** What an agent made from a template may spend until its owner says otherwise, in dollars: each month, and one run. */
99
111
  export declare const CAST_CAP = "10";
100
112
  export declare const CAST_TASK = "1";
101
- /** The cast members the org has no agent for, by name in any case. */
102
- export declare const missing: (cast: readonly Cast[], have: readonly {
103
- name: string;
104
- }[]) => Cast[];
105
- /** Create every cast member the org lacks, one at a time; the names made, and the first refusal, if any. */
106
- export declare function hire(t: Target, cast: readonly Cast[], have: readonly {
113
+ /**
114
+ * A name the API takes: accents dropped, any other run of characters a dash,
115
+ * from a letter or digit, up to 64. "Spärck Jones" is Sparck-Jones.
116
+ */
117
+ export declare const handle: (name: string) => string;
118
+ /** `name`, or `name-2`, `name-3`… : the first the org has no agent called, in any case. */
119
+ export declare function free(name: string, have: readonly {
107
120
  name: string;
108
- }[]): Promise<{
109
- made: string[];
110
- error: unknown;
111
- }>;
121
+ }[]): string;
122
+ /** An address of the page as an https link the API keeps, or '' (a page served over http has none). */
123
+ export declare const link: (at: string | undefined, base: string) => string;
124
+ /**
125
+ * Make the org's own agent from a template: under the first name the org has
126
+ * free (read now, so it is never one that exists), with its instructions read
127
+ * and its face linked. The template is not touched.
128
+ */
129
+ export declare function use(t: Target, c: Cast, base: string): Promise<Agent>;
130
+ /** Anyone as a template: their name, who they are, and writing of theirs to take the voice from. */
131
+ export declare function person(name: string, about: string, writing: string, avatar?: string): Cast;
112
132
  export declare function create(t: Target, d: Draft): Promise<Agent>;
113
133
  /** The fields that differ from `was`, and nothing else: an omitted field keeps its value. */
114
134
  export declare function changes(was: Agent, d: Draft): Record<string, unknown>;
@@ -116,5 +136,5 @@ export declare function update(t: Target, was: Agent, d: Draft): Promise<Agent>;
116
136
  export declare function remove(t: Target, ref: string): Promise<void>;
117
137
  /** The presets that make a working agent. */
118
138
  export declare function presets(t: Target): Promise<Preset[]>;
119
- /** A new agent's draft from a preset. */
120
- export declare function fromPreset(p: Preset): Draft;
139
+ /** A preset as a template. */
140
+ export declare function fromPreset(p: Preset): Cast;
package/lib/api/agents.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Agents: the org's own — a model, instructions, the tools it may call and a
3
- * budget — and the presets one can start from.
2
+ * Agents: the org's own — a model, instructions, the tools it may call, a
3
+ * budget and a face — and the templates one is made from.
4
4
  *
5
5
  * GET /v1/agent {agents: [agent]}, without instructions
6
6
  * POST /v1/agent create → 201 agent
@@ -47,6 +47,7 @@ export function agent(raw) {
47
47
  task: num(o.max_task_micro_usd),
48
48
  spent: num(o.consumed_micro_usd),
49
49
  period: PERIODS.includes(period) ? period : '',
50
+ avatar: str(o.avatar),
50
51
  emoji: str(o.emoji),
51
52
  };
52
53
  }
@@ -61,9 +62,10 @@ export function draft(a) {
61
62
  cap: dollars(a.cap),
62
63
  task: dollars(a.task),
63
64
  period: a.period || 'month',
65
+ avatar: a.avatar,
64
66
  };
65
67
  }
66
- export const EMPTY = { name: '', description: '', model: '', instructions: '', tools: [], cap: '', task: '', period: 'month' };
68
+ export const EMPTY = { name: '', description: '', model: '', instructions: '', tools: [], cap: '', task: '', period: 'month', avatar: '' };
67
69
  /** Why a draft cannot be saved as it stands, or '' when it can. */
68
70
  export function refuse(d) {
69
71
  if (!NAME.test(d.name))
@@ -95,27 +97,52 @@ export async function profile(t, ref) {
95
97
  const raw = await call(t, 'GET', `/v1/agent/${seg(ref)}`);
96
98
  return { agent: agent(raw), runs: rows(obj(raw).recentRuns).map(run).filter((r) => r.id) };
97
99
  }
98
- /** What a default agent may spend until its owner says otherwise, in dollars: each month, and one run. */
100
+ /** What an agent made from a template may spend until its owner says otherwise, in dollars: each month, and one run. */
99
101
  export const CAST_CAP = '10';
100
102
  export const CAST_TASK = '1';
101
- /** The cast members the org has no agent for, by name in any case. */
102
- export const missing = (cast, have) => {
103
- const names = new Set(have.map((a) => a.name.toLowerCase()));
104
- return cast.filter((c) => !names.has(c.name.toLowerCase()));
105
- };
106
- /** Create every cast member the org lacks, one at a time; the names made, and the first refusal, if any. */
107
- export async function hire(t, cast, have) {
108
- const made = [];
109
- for (const c of missing(cast, have)) {
110
- try {
111
- await create(t, { ...EMPTY, name: c.name, description: c.description, model: c.model, instructions: c.instructions, cap: CAST_CAP, task: CAST_TASK, period: 'month' });
112
- made.push(c.name);
113
- }
114
- catch (error) {
115
- return { made, error };
116
- }
103
+ /**
104
+ * A name the API takes: accents dropped, any other run of characters a dash,
105
+ * from a letter or digit, up to 64. "Spärck Jones" is Sparck-Jones.
106
+ */
107
+ export const handle = (name) => name
108
+ .normalize('NFD')
109
+ .replace(/[\u0300-\u036f]/g, '')
110
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
111
+ .replace(/^[^A-Za-z0-9]+/, '')
112
+ .slice(0, 64)
113
+ .replace(/-+$/, '');
114
+ /** `name`, or `name-2`, `name-3`… : the first the org has no agent called, in any case. */
115
+ export function free(name, have) {
116
+ const taken = new Set(have.map((a) => a.name.toLowerCase()));
117
+ if (!taken.has(name.toLowerCase()))
118
+ return name;
119
+ for (let i = 2;; i++) {
120
+ const next = `${name.slice(0, 63 - String(i).length)}-${i}`;
121
+ if (!taken.has(next.toLowerCase()))
122
+ return next;
117
123
  }
118
- return { made, error: null };
124
+ }
125
+ /** An address of the page as an https link the API keeps, or '' (a page served over http has none). */
126
+ export const link = (at, base) => {
127
+ if (!at)
128
+ return '';
129
+ const url = new URL(at, base).href;
130
+ return url.startsWith('https://') || url.startsWith('data:') ? url : '';
131
+ };
132
+ /**
133
+ * Make the org's own agent from a template: under the first name the org has
134
+ * free (read now, so it is never one that exists), with its instructions read
135
+ * and its face linked. The template is not touched.
136
+ */
137
+ export async function use(t, c, base) {
138
+ const [have, instructions] = await Promise.all([agents(t), c.read ? c.read() : c.instructions]);
139
+ return create(t, { ...EMPTY, name: free(handle(c.name), have), description: c.description, model: c.model, instructions, tools: c.tools ?? [], cap: CAST_CAP, task: CAST_TASK, avatar: link(c.avatar, base) });
140
+ }
141
+ /** Anyone as a template: their name, who they are, and writing of theirs to take the voice from. */
142
+ export function person(name, about, writing, avatar = '') {
143
+ const who = name.trim();
144
+ const lines = [`You are ${who}.`, about.trim(), writing.trim() && `Writing by ${who}:\n\n${writing.trim()}`];
145
+ return { name: who, title: who, description: about.trim(), category: 'Person', model: '', instructions: lines.filter(Boolean).join('\n\n'), avatar };
119
146
  }
120
147
  export async function create(t, d) {
121
148
  const why = refuse(d);
@@ -132,6 +159,8 @@ export async function create(t, d) {
132
159
  };
133
160
  if (d.model)
134
161
  body.model = d.model;
162
+ if (d.avatar)
163
+ body.avatar = d.avatar;
135
164
  return agent(await call(t, 'POST', '/v1/agent', body));
136
165
  }
137
166
  /** The fields that differ from `was`, and nothing else: an omitted field keeps its value. */
@@ -173,7 +202,7 @@ export async function presets(t) {
173
202
  .map((p) => ({ id: str(p.id), title: str(p.title), prompt: str(p.systemPrompt) }))
174
203
  .filter((p) => p.id);
175
204
  }
176
- /** A new agent's draft from a preset. */
205
+ /** A preset as a template. */
177
206
  export function fromPreset(p) {
178
- return { ...EMPTY, name: p.id, description: p.title, instructions: p.prompt };
207
+ return { name: p.id, title: p.title || p.id, description: p.title, category: 'Preset', model: '', instructions: p.prompt };
179
208
  }
@@ -1,4 +1,7 @@
1
+ import { type Cast } from '../api/agents.ts';
1
2
  import { type Pane } from './ui.tsx';
3
+ /** Categories as Browse offers them: the default agents' and presets' first, as listed, then the rest by how many each holds. */
4
+ export declare function kinds(first: readonly Cast[], rest: readonly Cast[]): string[];
2
5
  export declare function Agents({ view, q, adding, onAdding, onView, onCount }: Pane): import("react").JSX.Element;
3
6
  /** One agent's page: who it is, what it runs on and reads, what it may call and spend, and what it has done. */
4
7
  export declare function Profile({ name }: {
@@ -3,10 +3,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  * Agents: the org's own — a model, instructions, the tools it may call, and
4
4
  * what it may spend.
5
5
  *
6
- * Browse is the platform's presets whose tool calls run on the platform;
7
- * starting from one opens a new agent written from it. Yours lists the org's
8
- * agents, and Add default agents makes every member of the host's cast the org
9
- * has none for (an org starts with none). Opening one is its profile page
6
+ * Browse is every agent there is to start from: the host's default agents
7
+ * (`cast`) and personas (`personas`), and the platform's presets whose tool
8
+ * calls run on the platform, by name and category. Use makes the org's own
9
+ * agent from one and opens it; nothing is added any other way, so an org starts
10
+ * with none. An agent is also made from scratch, from the person signed in
11
+ * (Clone yourself) or from anyone named. Yours lists the org's agents. Opening one is its profile page
10
12
  * (`-/customize/agents/<name>`): its face, part, model, instructions, tools,
11
13
  * budget and runs, edited in place, and only what changed is sent. An
12
14
  * agent calls only the tools it names, and only those that are on for the org,
@@ -15,11 +17,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
15
17
  * spend each period, and in one run.
16
18
  */
17
19
  import { SizableText, XStack, YStack } from '@hanzo/gui';
18
- import { ArrowLeft, Bot, Users } from '@hanzogui/lucide-icons-2';
20
+ import { ArrowLeft, Upload } from '@hanzogui/lucide-icons-2';
19
21
  import { Button, Input, Switch, Textarea } from '@hanzo/ui';
20
22
  import { ModelPicker } from '@hanzo/ui/models';
21
- import { useEffect, useMemo, useState } from 'react';
22
- import { agents, ALL, create, draft, EMPTY, fromPreset, hire, missing, one, PERIODS, presets, profile, remove, update } from '../api/agents.js';
23
+ import { useEffect, useMemo, useRef, useState } from 'react';
24
+ import { agents, ALL, create, draft, EMPTY, fromPreset, one, PERIODS, person, presets, profile, remove, update, use } from '../api/agents.js';
23
25
  import { models } from '../api/models.js';
24
26
  import { tools } from '../api/tools.js';
25
27
  import { useHost, useTarget } from '../host.js';
@@ -27,58 +29,106 @@ import { path } from '../route.js';
27
29
  import { useLoad } from './load.js';
28
30
  import { say } from './say.js';
29
31
  import { Add, Choice, Confirm, Empty, Failed, Field, Grid, Line, Mark, matches, mono, Part, Sheet, Soft, Tile, useCount, Visitor } from './ui.js';
32
+ /** How many cards Browse draws at a time. */
33
+ const PAGE = 60;
34
+ const ANY = 'All';
35
+ /** Categories as Browse offers them: the default agents' and presets' first, as listed, then the rest by how many each holds. */
36
+ export function kinds(first, rest) {
37
+ const lead = [...new Set(first.map((c) => c.category))];
38
+ const count = new Map();
39
+ for (const c of rest)
40
+ if (!lead.includes(c.category))
41
+ count.set(c.category, (count.get(c.category) ?? 0) + 1);
42
+ return [ANY, ...lead, ...[...count.keys()].sort((a, b) => count.get(b) - count.get(a) || a.localeCompare(b))];
43
+ }
30
44
  const money = (m) => `$${(m / 1_000_000).toFixed(m % 10_000 ? 4 : 2).replace(/\.?0+$/, '')}`;
31
45
  export function Agents({ view, q, adding, onAdding, onView, onCount }) {
32
46
  const host = useHost();
33
47
  const t = useTarget();
34
48
  const signed = Boolean(host.person);
49
+ const browsing = view === 'discover';
35
50
  const mine = useLoad(signed ? () => agents(t) : null, [], [t, signed]);
36
- const offered = useLoad(view === 'discover' ? () => presets(t) : null, [], [t, view]);
51
+ const offered = useLoad(browsing ? () => presets(t) : null, [], [t, view]);
52
+ const figures = useLoad(browsing && host.personas ? host.personas : null, [], [view]);
37
53
  useCount(mine.value.length, onCount);
38
- const [start, setStart] = useState(null);
39
54
  const [note, setNote] = useState('');
40
- const [hiring, setHiring] = useState(false);
41
- const cast = host.cast ?? [];
42
- const lacking = missing(cast, mine.value);
43
- // The host's cast, made in one press: each member the org has no agent for.
44
- const addCast = async () => {
45
- setHiring(true);
55
+ const [kind, setKind] = useState(ANY);
56
+ const [more, setMore] = useState(PAGE);
57
+ const [using, setUsing] = useState(null);
58
+ const [making, setMaking] = useState(null);
59
+ useEffect(() => setMore(PAGE), [q, kind]);
60
+ const opened = (a) => host.go(path({ kind: 'agent', name: a.name }));
61
+ const take = async (c) => {
62
+ setUsing(c);
46
63
  setNote('');
47
- const { made, error } = await hire(t, cast, mine.value);
48
- setHiring(false);
49
- mine.reload();
50
- setNote(error ? `${made.length ? `Added ${made.length}. ` : ''}${say(error)}` : `Added ${made.length} default ${made.length === 1 ? 'agent' : 'agents'}`);
51
- };
52
- const castButton = signed && lacking.length ? (_jsxs(Button, { size: "sm", variant: "outline", disabled: hiring, onPress: () => void addCast(), children: [_jsx(Users, { size: 14 }), " ", hiring ? 'Adding…' : lacking.length === cast.length ? 'Add default agents' : `Add ${lacking.length} default ${lacking.length === 1 ? 'agent' : 'agents'}`] })) : null;
53
- const closeNew = () => {
54
- setStart(null);
55
- onAdding(false);
64
+ try {
65
+ opened(await use(t, c, window.location.href));
66
+ }
67
+ catch (e) {
68
+ setNote(say(e));
69
+ }
70
+ finally {
71
+ setUsing(null);
72
+ }
56
73
  };
57
- const editor = adding || start ? (_jsx(Editor, { start: start ?? EMPTY, onClose: closeNew, onSaved: (a) => {
58
- closeNew();
74
+ const editor = adding ? (_jsx(Editor, { onClose: () => onAdding(false), onSaved: (a) => {
75
+ onAdding(false);
59
76
  onView('yours');
60
77
  mine.reload();
61
78
  setNote(`${a.name} is saved`);
62
79
  } })) : null;
63
- if (view === 'discover') {
64
- const names = new Set(mine.value.map((a) => a.name));
65
- const shown = offered.value.filter((p) => matches(q, p.id, p.title, p.prompt));
66
- return (_jsxs(YStack, { gap: "$3", children: [_jsx(Line, { children: note }), _jsx(Part, { title: "Start from a preset", detail: "A ready agent from Hanzo. You can change it before you save it.", children: offered.error && !offered.value.length ? (_jsx(Failed, { error: offered.error, onRetry: offered.reload })) : offered.loading && !offered.value.length ? (_jsx(Soft, { children: "Loading presets\u2026" })) : !shown.length ? (_jsx(Soft, { children: offered.value.length ? `No preset matches “${q.trim()}”.` : 'There are no presets yet. Start from a blank agent with New agent.' })) : (_jsx(Grid, { label: "Presets", children: shown.map((p) => (_jsx(Tile, { title: p.title || p.id, detail: p.prompt, meta: `Preset ${p.id}`, mark: _jsx(Mark, { name: p.title || p.id, icon: _jsx(Bot, { size: 15 }) }), action: signed ? _jsx(Add, { name: p.title || p.id, label: "Use", added: names.has(p.id), onPress: () => setStart(fromPreset(p)) }) : undefined }, p.id))) })) }), editor] }));
80
+ if (browsing) {
81
+ const first = [...(host.cast ?? []), ...offered.value.map(fromPreset)];
82
+ const all = [...first, ...figures.value];
83
+ const shown = all.filter((c) => (kind === ANY || c.category === kind) && matches(q, c.title, c.name, c.description, c.category));
84
+ const failed = figures.error ?? offered.error;
85
+ return (_jsxs(YStack, { gap: "$3", children: [signed ? (_jsxs(XStack, { gap: "$2", flexWrap: "wrap", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => onAdding(true), children: "Start from scratch" }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setMaking('self'), children: "Clone yourself" }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setMaking('person'), children: "Create from a person" })] })) : null, _jsx(XStack, { overflowX: "auto", overflowY: "hidden", pb: "$1", children: _jsx(Choice, { label: "Category", value: kind, options: kinds(first, figures.value).map((id) => ({ id, label: id })), onChange: setKind }) }), _jsx(Line, { children: note }), shown.length ? (_jsx(Grid, { label: "Agents to start from", children: shown.slice(0, more).map((c) => (_jsx(Tile, { title: c.title, detail: c.description === c.title || c.description === c.category ? undefined : c.description, meta: c.category, mark: _jsx(Mark, { name: c.title, size: 40, logo: c.avatar }), action: signed ? _jsx(Add, { name: c.title, label: "Use", added: false, busy: using !== null, onPress: () => void take(c) }) : undefined }, `${c.category}/${c.name}/${c.title}`))) })) : figures.loading || offered.loading ? null : (_jsx(Soft, { children: `No agent matches “${q.trim()}”.` })), shown.length > more ? (_jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", onPress: () => setMore(more + PAGE), children: "More" }) })) : null, failed ? _jsx(Failed, { error: failed, onRetry: figures.error ? figures.reload : offered.reload }) : figures.loading || offered.loading ? _jsx(Soft, { children: "Loading\u2026" }) : null, editor, making ? _jsx(Person, { self: making === 'self', onClose: () => setMaking(null), onMade: opened }) : null] }));
67
86
  }
68
87
  if (!signed) {
69
88
  return _jsx(Visitor, { children: "Sign in to see your agents." });
70
89
  }
71
90
  const shown = mine.value.filter((a) => matches(q, a.name, a.description, a.model));
72
- return (_jsxs(YStack, { gap: "$3", children: [_jsx(Line, { children: note }), mine.error && !mine.value.length ? (_jsx(Failed, { error: mine.error, onRetry: mine.reload })) : mine.loading && !mine.value.length ? (_jsx(Soft, { children: "Loading your agents\u2026" })) : !mine.value.length ? (_jsxs(Empty, { title: "No agents yet", detail: cast.length ? 'Add the default team, start from a preset, or give a new one a model, instructions and tools.' : 'Start from a preset, or give a new one a model, instructions and tools.', children: [castButton, _jsx(Button, { size: "sm", variant: "outline", onPress: () => onView('discover'), children: "Browse presets" }), _jsx(Button, { size: "sm", onPress: () => onAdding(true), children: "New agent" })] })) : !shown.length ? (_jsx(Soft, { children: `No agent of yours matches “${q.trim()}”.` })) : (_jsxs(_Fragment, { children: [castButton ? _jsx(XStack, { children: castButton }) : null, _jsx(Grid, { label: "Your agents", children: shown.map((a) => (_jsx(Tile, { title: a.name, detail: a.description || 'No description.', meta: [a.model, `${a.runs} ${a.runs === 1 ? 'run' : 'runs'}`, reach(a), a.cap ? `${money(a.spent)} of ${money(a.cap)} this ${a.period || 'period'}` : ''].filter(Boolean).join(' · '), mark: _jsx(Face, { agent: a, cast: cast }), onOpen: () => host.go(path({ kind: 'agent', name: a.name })) }, a.id || a.name))) })] })), editor] }));
91
+ return (_jsxs(YStack, { gap: "$3", children: [_jsx(Line, { children: note }), mine.error && !mine.value.length ? (_jsx(Failed, { error: mine.error, onRetry: mine.reload })) : mine.loading && !mine.value.length ? (_jsx(Soft, { children: "Loading your agents\u2026" })) : !mine.value.length ? (_jsxs(Empty, { title: "No agents yet", detail: "", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => onView('discover'), children: "Browse agents" }), _jsx(Button, { size: "sm", onPress: () => onAdding(true), children: "New agent" })] })) : !shown.length ? (_jsx(Soft, { children: `No agent of yours matches “${q.trim()}”.` })) : (_jsx(Grid, { label: "Your agents", children: shown.map((a) => (_jsx(Tile, { title: a.name, detail: a.description || 'No description.', meta: [a.model, `${a.runs} ${a.runs === 1 ? 'run' : 'runs'}`, reach(a), a.cap ? `${money(a.spent)} of ${money(a.cap)} this ${a.period || 'period'}` : ''].filter(Boolean).join(' · '), mark: _jsx(Face, { agent: a, cast: host.cast ?? [] }), onOpen: () => opened(a) }, a.id || a.name))) })), editor] }));
92
+ }
93
+ /** Clone yourself, or anyone named: a name, who they are, and writing of theirs, pasted or uploaded. */
94
+ function Person({ self, onClose, onMade }) {
95
+ const host = useHost();
96
+ const t = useTarget();
97
+ const me = self ? host.person : null;
98
+ const [name, setName] = useState(me?.name ?? '');
99
+ const [about, setAbout] = useState('');
100
+ const [writing, setWriting] = useState('');
101
+ const [working, setWorking] = useState(false);
102
+ const [note, setNote] = useState('');
103
+ const file = useRef(null);
104
+ const read = async (files) => {
105
+ const texts = await Promise.all([...(files ?? [])].map((f) => f.text()));
106
+ setWriting((w) => [w.trim(), ...texts.map((x) => x.trim())].filter(Boolean).join('\n\n'));
107
+ };
108
+ const save = async () => {
109
+ setWorking(true);
110
+ setNote('');
111
+ try {
112
+ onMade(await use(t, person(name, about, writing, me?.avatar), window.location.href));
113
+ }
114
+ catch (e) {
115
+ setNote(say(e));
116
+ setWorking(false);
117
+ }
118
+ };
119
+ return (_jsxs(Sheet, { title: self ? 'Clone yourself' : 'Create from a person', open: true, onOpenChange: (o) => !o && onClose(), children: [_jsx(Field, { label: "Name", children: _jsx(Input, { value: name, onChangeText: setName, "aria-label": "Name" }) }), _jsx(Field, { label: "Description", children: _jsx(Textarea, { value: about, onChangeText: setAbout, "aria-label": "Description", rows: 3 }) }), _jsx(Field, { label: "Writing", children: _jsx(Textarea, { value: writing, onChangeText: setWriting, "aria-label": "Writing", rows: 6 }) }), _jsxs(XStack, { children: [_jsxs(Button, { size: "sm", variant: "ghost", onPress: () => file.current?.click(), children: [_jsx(Upload, { size: 14 }), " Upload"] }), _jsx("input", { ref: file, type: "file", multiple: true, accept: ".txt,.md,.markdown,.csv,.json,.html,text/*", hidden: true, "aria-label": "Upload writing", onChange: (e) => {
120
+ void read(e.currentTarget.files);
121
+ e.currentTarget.value = '';
122
+ } })] }), _jsx(Line, { children: note }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { flex: 1 }), _jsx(Button, { size: "sm", variant: "ghost", onPress: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working || !name.trim(), onPress: () => void save(), children: "Create" })] })] }));
73
123
  }
74
124
  function reach(a) {
75
125
  if (a.tools.includes(ALL))
76
126
  return 'every tool';
77
127
  return a.tools.length === 1 ? '1 tool' : `${a.tools.length} tools`;
78
128
  }
79
- /** An agent's face: the cast member's picture, its emoji, or its initial. */
129
+ /** An agent's face: its picture, the default agent's of its name, its emoji, or its initial. */
80
130
  function Face({ agent: a, cast, size = 32 }) {
81
- const avatar = cast.find((c) => c.name.toLowerCase() === a.name.toLowerCase())?.avatar;
131
+ const avatar = a.avatar || cast.find((c) => c.name.toLowerCase() === a.name.toLowerCase())?.avatar;
82
132
  return _jsx(Mark, { name: a.name, size: size, logo: avatar, icon: a.emoji ? _jsx(SizableText, { size: size > 40 ? '$8' : '$4', children: a.emoji }) : undefined });
83
133
  }
84
134
  /** One agent's page: who it is, what it runs on and reads, what it may call and spend, and what it has done. */
@@ -95,12 +145,12 @@ export function Profile({ name }) {
95
145
  }, onDeleted: back })) : null] }));
96
146
  }
97
147
  /** Write a new agent, or change one; an existing agent is read whole first, for its instructions. */
98
- function Editor({ agent, start, onClose, onSaved, onDeleted, }) {
148
+ function Editor({ agent, onClose, onSaved, onDeleted, }) {
99
149
  const t = useTarget();
100
150
  const full = useLoad(agent ? () => one(t, agent.id || agent.name) : null, null, [t, agent?.id]);
101
151
  const catalog = useLoad(() => models(t), [], [t]);
102
152
  const on = useLoad(() => tools(t, { activated: true }), [], [t]);
103
- const [d, setD] = useState(start ?? EMPTY);
153
+ const [d, setD] = useState(EMPTY);
104
154
  const [ready, setReady] = useState(!agent);
105
155
  const [working, setWorking] = useState(false);
106
156
  const [note, setNote] = useState('');
@@ -29,11 +29,9 @@ const LABEL = {
29
29
  plugins: { title: 'Plugins', says: 'Connectors you build in TypeScript.', add: 'Build plugin', find: 'Search plugins', icon: _jsx(Puzzle, { size: 15 }) },
30
30
  agents: { title: 'Agents', says: 'Agents with their own model, instructions and budget.', add: 'New agent', find: 'Search agents', icon: _jsx(Bot, { size: 15 }) },
31
31
  };
32
- /** Where a tab opens: an org's own agents (and the cast to add) for Agents; Browse for the rest, what there is to add being the page. */
33
- const opens = (tab) => (tab === 'agents' ? 'yours' : 'discover');
34
32
  export function Customize({ tab, view: first }) {
35
33
  const host = useHost();
36
- const [view, setView] = useState(first ?? opens(tab));
34
+ const [view, setView] = useState(first ?? 'discover');
37
35
  const [q, setQ] = useState('');
38
36
  const [adding, setAdding] = useState(false);
39
37
  const [count, setCount] = useState(0);
@@ -41,7 +39,7 @@ export function Customize({ tab, view: first }) {
41
39
  const pick = (next) => {
42
40
  setQ('');
43
41
  setAdding(false);
44
- setView(opens(next));
42
+ setView('discover');
45
43
  setCount(0);
46
44
  host.go(path({ kind: 'customize', tab: next }));
47
45
  };
package/lib/host.d.ts CHANGED
@@ -107,8 +107,10 @@ export interface Host {
107
107
  browser?: Browser;
108
108
  /** The host's sidebar lists Settings' sections and Customize's tabs, so the pages draw neither. */
109
109
  sections?: boolean;
110
- /** The default agents an org may add in one press (Customize › Agents). Absent, none are offered. */
110
+ /** The default agents Customize › Agents offers to start from. Absent, none. */
111
111
  cast?: readonly Cast[];
112
+ /** Every persona Customize › Agents offers to start from, read when Browse opens. Absent, none. */
113
+ personas?: () => Promise<readonly Cast[]>;
112
114
  }
113
115
  export declare function HostProvider({ host, children }: {
114
116
  host: Host;
package/lib/index.d.ts CHANGED
@@ -74,5 +74,5 @@ export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, use
74
74
  * the person's own activity: `useSuggestions` reads and makes them, `Suggestions`
75
75
  * draws them. The row is `Hint` here: `Suggestion` is the side panel's.
76
76
  */
77
- export { Suggestions, useSuggestions, type Suggested, type SuggestionsProps } from './suggestions.tsx';
78
- export type { Suggestion as Hint } from './suggest.ts';
77
+ export { book, hand, Suggestions, taken, useSuggestions, type Suggested, type SuggestionsProps } from './suggestions.tsx';
78
+ export type { Act, Suggestion as Hint } from './suggest.ts';
package/lib/index.js CHANGED
@@ -68,4 +68,4 @@ export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, use
68
68
  * the person's own activity: `useSuggestions` reads and makes them, `Suggestions`
69
69
  * draws them. The row is `Hint` here: `Suggestion` is the side panel's.
70
70
  */
71
- export { Suggestions, useSuggestions } from './suggestions.js';
71
+ export { book, hand, Suggestions, taken, useSuggestions } from './suggestions.js';
package/lib/landing.js CHANGED
@@ -37,7 +37,8 @@ import { Publish } from './publish.js';
37
37
  import { path } from './route.js';
38
38
  import { useCarry } from './carry.js';
39
39
  import { DEV_MODES } from './gear.js';
40
- import { Suggestions, useSuggestions } from './suggestions.js';
40
+ import { book, hand, Suggestions, taken, useSuggestions } from './suggestions.js';
41
+ import { want } from './library.js';
41
42
  import { MEASURE, Prompt, useEar } from './prompt.js';
42
43
  import { Monitor as Device } from '@hanzogui/lucide-icons-2';
43
44
  import { compose, Files, pick, read as readFiles } from './tools.js';
@@ -175,9 +176,9 @@ export function Landing({ onStarted }) {
175
176
  return { branches: names.map((name) => ({ name })), next: null };
176
177
  }, [t, codebase, home]);
177
178
  // The composer sends only a draft with words in it, and not while a send is out.
178
- const send = async () => {
179
+ const send = async (said = draft) => {
179
180
  if (!signed) {
180
- hold({ draft, files, sent: true });
181
+ hold({ draft: said, files, sent: true });
181
182
  host.signIn?.();
182
183
  return;
183
184
  }
@@ -203,7 +204,7 @@ export function Landing({ onStarted }) {
203
204
  setNote('');
204
205
  try {
205
206
  // What the plugins that are on add: words for the run, or an answer of their own.
206
- const prepped = carry.preparing ? await carry.prep({ text: draft.trim(), files: [], thread: '' }, new AbortController().signal) : {};
207
+ const prepped = carry.preparing ? await carry.prep({ text: said.trim(), files: [], thread: '' }, new AbortController().signal) : {};
207
208
  // An answer of a plugin's own (a deck) is the answer: no run is started for it.
208
209
  if (prepped.reply) {
209
210
  // Its words, without the page it drew (Chat shows that in the side panel).
@@ -216,7 +217,7 @@ export function Landing({ onStarted }) {
216
217
  return;
217
218
  }
218
219
  const run = await start(t, {
219
- prompt: compose([draft.trim(), carry.text, prepped.system ?? ''].filter(Boolean).join('\n\n---\n'), files),
220
+ prompt: compose([said.trim(), carry.text, prepped.system ?? ''].filter(Boolean).join('\n\n---\n'), files),
220
221
  // The name alone. The org rides the request, and a slash is not a repo name.
221
222
  // An empty base or place is not sent: the default branch, and the sandbox.
222
223
  repo: repo?.name ?? hub?.full_name,
@@ -249,6 +250,45 @@ export function Landing({ onStarted }) {
249
250
  // Sent once, on the sign-in that it waited for.
250
251
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
252
  }, [signed]);
253
+ // A run a suggestion in Chat left for Dev starts on arrival, once its person is known.
254
+ const [handed] = useState(() => ({ prompt: taken('task') }));
255
+ useEffect(() => {
256
+ if (!signed || !handed.prompt)
257
+ return;
258
+ const p = handed.prompt;
259
+ handed.prompt = '';
260
+ setDraft(p);
261
+ void send(p);
262
+ // Started once, on the sign-in that it waited for.
263
+ // eslint-disable-next-line react-hooks/exhaustive-deps
264
+ }, [signed]);
265
+ /** What pressing a suggestion does: open what it names, chat its prompt, start its run, or make its scheduled task. */
266
+ const act = (a) => {
267
+ switch (a.kind) {
268
+ case 'open':
269
+ if (a.item)
270
+ want(a.item);
271
+ if (/^https:\/\//.test(a.to))
272
+ window.open(a.to, '_blank', 'noopener,noreferrer');
273
+ else if (a.to.startsWith('/'))
274
+ host.open(a.to);
275
+ else
276
+ host.go(a.to);
277
+ return;
278
+ case 'send':
279
+ hand('send', a.prompt);
280
+ host.open('/chat');
281
+ return;
282
+ case 'task':
283
+ setDraft(a.prompt);
284
+ void send(a.prompt);
285
+ return;
286
+ case 'schedule':
287
+ setNote('');
288
+ void book(t, a, 'dev').then((made) => host.go(path({ kind: 'automation', id: made.id })), (e) => setNote(e instanceof Error && e.message ? e.message : 'The scheduled task could not be made.'));
289
+ return;
290
+ }
291
+ };
252
292
  /** Start the agent that finds a codebase's environment, and open it: its run is the progress. A refusal is the dialog's to say. */
253
293
  const setup = async (repo) => {
254
294
  const run = await start(t, { prompt: SETUP, repo, mode: 'setup', model: mind.model, effort: mind.effort });
@@ -319,7 +359,7 @@ export function Landing({ onStarted }) {
319
359
  plugins: carry.plugins,
320
360
  devices,
321
361
  memory: { on: carry.gear.gear.memory, set: (on) => carry.gear.set({ memory: on }) },
322
- }, on: carry.on, slash: carry.skills, at: carry.plugins, mode: carry.mode, where: where, below: _jsx(Suggestions, { rows: hints.rows, onPick: setDraft, onDismiss: hints.dismiss }), mind: mind, limits: limits, onUpgrade: () => host.go(path({ kind: 'screen', screen: 'plans' })), voice: voice, language: prefs.language ?? null, onLanguage: (id) => void savePrefs({ language: id }), talk: talk })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), carry.dialog, codebase ? (_jsx(SetupDialog, { repo: codebase, homes: homes, open: offering, onOpenChange: setOffering, onStart: setup, onRepo: choose, onSaved: () => {
362
+ }, on: carry.on, slash: carry.skills, at: carry.plugins, mode: carry.mode, where: where, below: _jsx(Suggestions, { rows: hints.rows, onAct: act, onDismiss: hints.dismiss }), mind: mind, limits: limits, onUpgrade: () => host.go(path({ kind: 'screen', screen: 'plans' })), voice: voice, language: prefs.language ?? null, onLanguage: (id) => void savePrefs({ language: id }), talk: talk })] })] }), _jsx(Publish, { source: adding, onClose: () => setAdding(null) }), carry.dialog, codebase ? (_jsx(SetupDialog, { repo: codebase, homes: homes, open: offering, onOpenChange: setOffering, onStart: setup, onRepo: choose, onSaved: () => {
323
363
  setOffering(false);
324
364
  env.reload();
325
365
  } })) : null] }));
package/lib/mic.js CHANGED
@@ -119,7 +119,10 @@ export function Dictation({ voice, disabled = false, narrow = false, language =
119
119
  const { inputs, allowed, using } = useInputs(open);
120
120
  const [device, setDevice] = useState(() => (typeof window === 'undefined' ? null : kept()));
121
121
  const spoken = LANGUAGES.find((l) => l.id === language);
122
- return (_jsxs(XStack, { "data-slot": "dictation", items: "center", rounded: 8, borderWidth: 1, borderColor: open ? '$borderColor' : 'transparent', hoverStyle: { borderColor: '$borderColor' }, shrink: 0, children: [_jsx(Control, { asChild: true, size: narrow ? 44 : 30, ...PLAIN, children: _jsx(Voice, { voice: voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(MicIcon, { size: 16, color: "var(--foreground)", fill: state === 'idle' ? 'none' : 'var(--foreground)' }) }) }), _jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, disabled: disabled, children: _jsx(XStack, { render: "button", "aria-label": "Dictation settings", "data-slot": "dictation-menu", width: 16, height: 30, items: "center", justify: "center", rounded: 6, cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, ...(narrow ? { width: 44, height: 44 } : null), children: _jsx(ChevronDown, { size: 14, color: "$aside" }) }) }), _jsxs(DropdownMenuContent, { align: "end", minW: 200, "data-slot": "dictation-panel", children: [_jsx(Meter, { device: device, on: open && allowed }), inputs.length === 0 ? (_jsx(DropdownMenuLabel, { children: _jsx(SizableText, { fontSize: 14, color: "$hint", children: "No devices found" }) })) : (inputs.map((d, i) => {
122
+ // The chevron is drawn while the mic is reached for (pointer, keyboard, menu open); at rest the row is the mic alone.
123
+ const [near, setNear] = useState(false);
124
+ const shown = open || near;
125
+ return (_jsxs(XStack, { "data-slot": "dictation", items: "center", rounded: 8, borderWidth: 1, borderColor: open ? '$borderColor' : 'transparent', hoverStyle: { borderColor: '$borderColor' }, shrink: 0, onPointerEnter: () => setNear(true), onPointerLeave: () => setNear(false), onFocus: () => setNear(true), onBlur: () => setNear(false), children: [_jsx(Control, { asChild: true, size: narrow ? 44 : 30, ...PLAIN, children: _jsx(Voice, { voice: voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(MicIcon, { size: 16, color: "var(--foreground)", fill: state === 'idle' ? 'none' : 'var(--foreground)' }) }) }), _jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, disabled: disabled, children: _jsx(XStack, { render: "button", "aria-label": "Dictation settings", "data-slot": "dictation-menu", width: 16, height: 30, items: "center", justify: "center", rounded: 6, cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, ...(narrow ? { width: 44, height: 44 } : null), children: _jsx(ChevronDown, { size: 14, color: "$soft", opacity: shown || narrow ? 1 : 0 }) }) }), _jsxs(DropdownMenuContent, { align: "end", minW: 200, "data-slot": "dictation-panel", children: [_jsx(Meter, { device: device, on: open && allowed }), inputs.length === 0 ? (_jsx(DropdownMenuLabel, { children: _jsx(SizableText, { fontSize: 14, color: "$hint", children: "No devices found" }) })) : (inputs.map((d, i) => {
123
126
  const on = device ? d.deviceId === device : d.groupId === using;
124
127
  return (_jsx(DropdownMenuItem, { preventCloseOnSelect: true, onSelect: () => {
125
128
  setDevice(d.deviceId);
package/lib/prompt.js CHANGED
@@ -168,14 +168,16 @@ export function prompt(o, state) {
168
168
  disabled,
169
169
  placeholder: o.placeholder ?? ASK,
170
170
  ...(o.label ? { label: o.label } : {}),
171
- rows: 2,
171
+ // At rest two rows: one line of text, then the tools. It grows with the text to the kit's ceiling, then scrolls.
172
+ rows: 1,
172
173
  field: {
173
174
  ...o.field,
174
175
  fontSize: 16,
175
176
  lineHeight: 24,
176
177
  px: 8,
177
178
  pt: 2,
178
- minH: 48,
179
+ pb: 0,
180
+ minH: 26,
179
181
  bg: 'transparent',
180
182
  },
181
183
  head,
package/lib/suggest.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Up to three suggestions under the composer, each one person's own: what
3
- * happened in their recent work, linked to where it happened, and the next step
4
- * as a prompt the composer takes.
2
+ * Three suggestions under the composer, each one person's own and each a thing
3
+ * done at once when pressed (`Act`): open what it names (a conversation, a run,
4
+ * a Studio creation, an item, an issue), send a new chat, start a Dev run, or
5
+ * schedule a task. None only fills the composer.
5
6
  *
6
7
  * Nothing here is written by hand. The rows are generated by Hanzo's own model
7
8
  * (`MODEL`, POST /v1/chat/completions with the person's bearer, so the call is
@@ -24,28 +25,49 @@
24
25
  * This file is the pure half: inputs to a request, an answer to validated rows,
25
26
  * the fingerprint and the policy. suggestions.tsx reads the sources and draws them.
26
27
  */
27
- import type { Automation } from './api/auto.ts';
28
+ import type { Automation, Schedule } from './api/auto.ts';
28
29
  import type { Conversation } from './api/conversations.ts';
29
30
  import type { Item } from './api/library.ts';
30
31
  import type { Session } from './api/sessions.ts';
31
32
  import type { Issue } from './merge.ts';
32
33
  export type Icon = 'github' | 'agent' | 'chat' | 'file' | 'task';
33
34
  export declare const ICONS: readonly Icon[];
34
- /** One row: where it came from, what happened, and what to do next. */
35
+ /**
36
+ * What pressing a row does, at once:
37
+ *
38
+ * open the thing it names: `to` is an app address (`/chat/<id>`,
39
+ * `/studio/c/<id>`), a builder path (`sess_…`, `-/automations/<id>`,
40
+ * `-/artifacts` with `item`), or another site's (a new tab)
41
+ * send a new chat, asked `prompt`
42
+ * task a new Dev run on `prompt`
43
+ * schedule a scheduled task, made and shown
44
+ */
45
+ export type Act = {
46
+ kind: 'open';
47
+ to: string;
48
+ item?: string;
49
+ } | {
50
+ kind: 'send';
51
+ prompt: string;
52
+ } | {
53
+ kind: 'task';
54
+ prompt: string;
55
+ } | {
56
+ kind: 'schedule';
57
+ name: string;
58
+ prompt: string;
59
+ schedule: Schedule;
60
+ };
61
+ /** One row: one short action in plain words, and what pressing it does. */
35
62
  export interface Suggestion {
36
- /** Stable across generations: the source's address, or the title for a row with none. */
63
+ /** Stable across generations: what it opens, or its words. */
37
64
  id: string;
38
65
  icon: Icon;
39
- /** What happened, a few words: "Hanzo app login fixes landed". */
66
+ /** The action, a few plain words: "Review poster.png in Studio". */
40
67
  title: string;
41
- /** The source's own https address, copied from an input, or '' for none. */
42
- href: string;
43
68
  /** What the source says, a sentence or three: the hover card. */
44
69
  summary: string;
45
- /** The next step, a few words: "Verify the user flow". */
46
- action: string;
47
- /** What the composer is given when the row is chosen. */
48
- prompt: string;
70
+ act: Act;
49
71
  }
50
72
  /** One piece of the person's activity, as the model is shown it. */
51
73
  export interface Input {
@@ -59,6 +81,10 @@ export interface Input {
59
81
  text: string;
60
82
  /** Its own https address, or ''. */
61
83
  href: string;
84
+ /** Where opening it goes (`Act` open's `to`), or '' when nothing opens it. */
85
+ to: string;
86
+ /** A Library item's id, for an item the Library opens at. */
87
+ item?: string;
62
88
  /** RFC 3339, or ''. */
63
89
  updated: string;
64
90
  }
@@ -142,6 +168,8 @@ export declare function due(kept: Kept | null, fingerprint: string, now: number)
142
168
  export declare function stale(kept: Kept | null, now: number): boolean;
143
169
  /** The icon a row about `i` carries: GitHub's mark only for GitHub's own address. */
144
170
  export declare function iconOf(i: Input): Icon;
171
+ /** What a row does, as the model names it. */
172
+ export declare const ACTIONS: readonly ['open', 'send', 'task', 'schedule'];
145
173
  /** The answer's shape, decoded strictly by the model. */
146
174
  export declare const SCHEMA: {
147
175
  readonly type: 'object';
@@ -154,7 +182,7 @@ export declare const SCHEMA: {
154
182
  readonly items: {
155
183
  readonly type: 'object';
156
184
  readonly additionalProperties: false;
157
- readonly required: readonly ['icon', 'title', 'href', 'summary', 'action', 'prompt'];
185
+ readonly required: readonly ['icon', 'title', 'summary', 'action', 'source', 'prompt', 'every'];
158
186
  readonly properties: {
159
187
  readonly icon: {
160
188
  readonly type: 'string';
@@ -163,18 +191,22 @@ export declare const SCHEMA: {
163
191
  readonly title: {
164
192
  readonly type: 'string';
165
193
  };
166
- readonly href: {
167
- readonly type: 'string';
168
- };
169
194
  readonly summary: {
170
195
  readonly type: 'string';
171
196
  };
172
197
  readonly action: {
173
198
  readonly type: 'string';
199
+ readonly enum: readonly ["open", "send", "task", "schedule"];
200
+ };
201
+ readonly source: {
202
+ readonly type: 'string';
174
203
  };
175
204
  readonly prompt: {
176
205
  readonly type: 'string';
177
206
  };
207
+ readonly every: {
208
+ readonly type: 'string';
209
+ };
178
210
  };
179
211
  };
180
212
  };
@@ -182,21 +214,24 @@ export declare const SCHEMA: {
182
214
  };
183
215
  /** The chat completion that asks for rows, or null when there is nothing to make them from. */
184
216
  export declare function request(inputs: Input[], seed: Seed, skip: string[], now: Date): Record<string, unknown> | null;
217
+ /** `every` as a schedule, or null for words that are not one. */
218
+ export declare function every(v: string): Schedule | null;
185
219
  /** The text of a chat completion's first choice, or ''. */
186
220
  export declare function content(raw: unknown): string;
187
- /** A row's id: its address, or for a row without one, its title. */
188
- export declare const idOf: (href: string, title: string) => string;
221
+ /** A row's id: what it opens, or its words. */
222
+ export declare const idOf: (to: string, title: string) => string;
189
223
  /**
190
- * The rows of a chat completion, validated: each with every field, an address
191
- * that is one of the inputs' own (or none), its icon from that input, one row
192
- * per source, at most `MOST`. Anything else is dropped, never repaired.
224
+ * The rows of a chat completion, validated: each with words and an action that
225
+ * binds (an open names an input's key; a send, a task or a schedule carries its
226
+ * prompt, and a schedule a time it reads), one row per thing, at most `MOST`.
227
+ * Anything else is dropped, never repaired.
193
228
  */
194
229
  export declare function answer(raw: unknown, inputs: Input[]): Suggestion[];
195
230
  /** The `suggest` key of the preference document, or undefined when it holds none. */
196
231
  export declare function kept(raw: unknown): Kept | undefined;
197
232
  /** The rows to draw: those not dismissed. */
198
233
  export declare const shown: (k: Kept | null | undefined) => Suggestion[];
199
- /** What Hanzo does, three ways in: what fills the rows a person's activity and signup do not. */
234
+ /** What Hanzo does, three ways in, each done at once: what fills the rows a person's activity and signup do not. */
200
235
  export declare const STARTERS: readonly Suggestion[];
201
236
  /** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
202
237
  export declare const fill: (k: Kept | null | undefined) => Suggestion[];
package/lib/suggest.js CHANGED
@@ -51,7 +51,7 @@ const first = (s) => s.split('\n').find((l) => l.trim()) ?? '';
51
51
  /** The person's activity as inputs: their own runs only, each kind's newest `TAKE`. */
52
52
  export function inputs(a) {
53
53
  const all = [
54
- ...a.conversations.map((c) => ({ kind: 'conversation', key: c.id, title: c.title, state: '', text: '', href: '', updated: c.updated })),
54
+ ...a.conversations.map((c) => ({ kind: 'conversation', key: c.id, title: c.title, state: '', text: '', href: '', to: `/chat/${encodeURIComponent(c.id)}`, updated: c.updated })),
55
55
  ...a.issues.map((i) => ({
56
56
  kind: i.pull ? 'pull' : 'issue',
57
57
  key: i.key,
@@ -59,6 +59,7 @@ export function inputs(a) {
59
59
  state: i.state === 'closed' ? 'closed' : i.status || 'open',
60
60
  text: [i.repo || i.forge || i.board, first(i.body)].filter(Boolean).join(': '),
61
61
  href: https(i.url),
62
+ to: https(i.url),
62
63
  updated: i.updated,
63
64
  })),
64
65
  ...a.runs
@@ -70,6 +71,7 @@ export function inputs(a) {
70
71
  state: r.status,
71
72
  text: [r.repo, r.branch].filter(Boolean).join(' @ '),
72
73
  href: https(r.pr),
74
+ to: r.id,
73
75
  updated: r.updatedAt || r.createdAt,
74
76
  })),
75
77
  ...a.automations
@@ -81,9 +83,11 @@ export function inputs(a) {
81
83
  state: x.last?.status || (x.enabled ? 'on' : 'off'),
82
84
  text: x.last?.summary || first(x.instructions),
83
85
  href: '',
86
+ to: `-/automations/${x.id}`,
84
87
  updated: x.last?.at || x.updated,
85
88
  })),
86
- ...a.files.map((f) => ({ kind: 'file', key: f.id, title: f.name, state: f.kind, text: f.source, href: '', updated: f.updated || f.created })),
89
+ // An item opens where it was made when that has an address (a Studio creation), else in the Library at it.
90
+ ...a.files.map((f) => ({ kind: 'file', key: f.id, title: f.name, state: f.kind, text: f.source, href: '', ...(f.origin.startsWith('/') ? { to: f.origin } : { to: '-/artifacts', item: f.id }), updated: f.updated || f.created })),
87
91
  ];
88
92
  return pick(all);
89
93
  }
@@ -155,6 +159,8 @@ export function iconOf(i) {
155
159
  return 'file';
156
160
  }
157
161
  }
162
+ /** What a row does, as the model names it. */
163
+ export const ACTIONS = ['open', 'send', 'task', 'schedule'];
158
164
  /** The answer's shape, decoded strictly by the model. */
159
165
  export const SCHEMA = {
160
166
  type: 'object',
@@ -167,32 +173,35 @@ export const SCHEMA = {
167
173
  items: {
168
174
  type: 'object',
169
175
  additionalProperties: false,
170
- required: ['icon', 'title', 'href', 'summary', 'action', 'prompt'],
176
+ required: ['icon', 'title', 'summary', 'action', 'source', 'prompt', 'every'],
171
177
  properties: {
172
178
  icon: { type: 'string', enum: [...ICONS] },
173
179
  title: { type: 'string' },
174
- href: { type: 'string' },
175
180
  summary: { type: 'string' },
176
- action: { type: 'string' },
181
+ action: { type: 'string', enum: [...ACTIONS] },
182
+ source: { type: 'string' },
177
183
  prompt: { type: 'string' },
184
+ every: { type: 'string' },
178
185
  },
179
186
  },
180
187
  },
181
188
  },
182
189
  };
183
190
  const SYSTEM = [
184
- 'You choose up to three next steps for one person, from their own recent activity, to show under the box where they type to Hanzo.',
185
- 'Each row names something that happened and the step it calls for. Pick what is most worth their attention now: something that landed and needs checking, something open and urgent, something they were in the middle of.',
191
+ 'You choose three things for one person to do next with Hanzo, from their own recent activity, shown under the box where they type. Pressing a row DOES it at once, so every row is an action, never a draft.',
192
+ 'Each row has one action:',
193
+ '- open: open the thing an activity item names (a conversation to pick up, a run to check, a creation or file to review, an issue to read). source is that item\'s key, copied exactly.',
194
+ '- send: start a new chat and ask prompt. Use it for a question or a piece of work a chat answers (a summary, a draft, research).',
195
+ '- task: start a Dev coding run on prompt, in the person\'s current project. Use it for a change to code.',
196
+ '- schedule: make a scheduled task that runs prompt every `every`. every is one of: hourly · daily HH:MM · weekdays HH:MM · weekly mon|tue|wed|thu|fri|sat|sun HH:MM (24-hour).',
186
197
  'Fields:',
187
- '- title: what happened, at most 6 words, naming the thing ("Hanzo app login fixes landed", "Kai P0 mapping bug is open").',
188
- '- action: the next step, imperative, at most 6 words ("Verify the user flow").',
189
- '- summary: one to three plain sentences of what the source says. Only facts from the input; never invent numbers, names or dates.',
190
- '- prompt: the message the person would send Hanzo to do the step, first person, complete on its own, naming the source and its address when it has one.',
191
- '- href: copied exactly from the input the row is about, or "" when that input has none. Never write an address that is not in the input.',
192
- '- icon: github for a github.com address, agent for a run or an automation, chat for a conversation, file for a file, task for any other issue or task.',
193
- 'Answer three rows. Take them from the activity first; fill the rest from what the person told us at signup (their role, their work): first things to ask Hanzo, with href "".',
194
- 'Never repeat an item listed under skip. Never suggest the same item twice.',
195
- 'With neither activity nor anything from signup worth a row, answer fewer; never pad with a weak one.',
198
+ '- title: the action itself, plain words, at most 7, naming the thing: "Review poster.png in Studio", "Continue the physics chat", "Pick up the Oct 10 hello thread". Never a label and an action glued together.',
199
+ '- summary: one or two plain sentences of what the source says, for the hover card. Only facts from the input; never invent numbers, names or dates.',
200
+ '- source: for open, the key of the activity item; else "".',
201
+ '- prompt: for send, task and schedule, the complete message, first person, naming its source; for open, "".',
202
+ '- every: for schedule; else "".',
203
+ '- icon: github for a github.com issue or pull request, agent for a run, a task or a schedule, chat for a conversation or a send, file for a file or creation, task for any other issue.',
204
+ 'Answer three rows: the activity first, then from what the person told us at signup (their role, their work). A row you cannot bind to one of the actions is no row. Never repeat an item under skip, and never the same item twice.',
196
205
  ].join('\n');
197
206
  /** The chat completion that asks for rows, or null when there is nothing to make them from. */
198
207
  export function request(inputs, seed, skip, now) {
@@ -200,11 +209,11 @@ export function request(inputs, seed, skip, now) {
200
209
  if (!inputs.length && !Object.keys(person).length)
201
210
  return null;
202
211
  const activity = inputs.map((i) => ({
212
+ key: i.key,
203
213
  kind: i.kind,
204
214
  title: line(i.title, 140),
205
215
  ...(i.state ? { state: i.state } : {}),
206
216
  ...(i.text ? { text: line(i.text, 240) } : {}),
207
- href: i.href,
208
217
  ...(i.updated ? { updated: i.updated.slice(0, 10) } : {}),
209
218
  }));
210
219
  return {
@@ -218,6 +227,19 @@ export function request(inputs, seed, skip, now) {
218
227
  temperature: 0.3,
219
228
  };
220
229
  }
230
+ const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
231
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
232
+ /** `every` as a schedule, or null for words that are not one. */
233
+ export function every(v) {
234
+ const [kind, a, b] = v.trim().toLowerCase().split(/\s+/);
235
+ if (kind === 'hourly' && !a)
236
+ return { kind: 'hourly', at: '00:00' };
237
+ if ((kind === 'daily' || kind === 'weekdays') && a && HHMM.test(a) && !b)
238
+ return { kind, at: a };
239
+ if (kind === 'weekly' && a && DAYS.includes(a) && b && HHMM.test(b))
240
+ return { kind: 'weekly', day: a, at: b };
241
+ return null;
242
+ }
221
243
  /** The first JSON object in a model's text: all of it, or from its first `{` to its last `}`. */
222
244
  function json(text) {
223
245
  try {
@@ -242,57 +264,89 @@ export function content(raw) {
242
264
  const first = obj(Array.isArray(choices) ? choices[0] : null);
243
265
  return str(obj(first.message).content);
244
266
  }
245
- /** A row's id: its address, or for a row without one, its title. */
246
- export const idOf = (href, title) => hash(href || title.toLowerCase());
267
+ /** A row's id: what it opens, or its words. */
268
+ export const idOf = (to, title) => hash(to || title.toLowerCase());
269
+ /** A model's row bound to what pressing it does, or null for one that binds to nothing. */
270
+ function bind(o, inputs) {
271
+ const prompt = str(o.prompt).trim().slice(0, 2000);
272
+ switch (str(o.action)) {
273
+ case 'open': {
274
+ const i = inputs.find((x) => x.key === str(o.source).trim());
275
+ if (!i?.to)
276
+ return null;
277
+ return i.item ? { kind: 'open', to: i.to, item: i.item } : { kind: 'open', to: i.to };
278
+ }
279
+ case 'send':
280
+ case 'task':
281
+ return prompt ? { kind: str(o.action), prompt } : null;
282
+ case 'schedule': {
283
+ const when = every(str(o.every));
284
+ return prompt && when ? { kind: 'schedule', name: line(o.title, 80), prompt, schedule: when } : null;
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+ /** What a row is about, for its mark: the input it opens, if it opens one. */
290
+ const about = (act, inputs) => (act.kind === 'open' ? inputs.find((i) => i.to === act.to && i.item === act.item) : undefined);
247
291
  /**
248
- * The rows of a chat completion, validated: each with every field, an address
249
- * that is one of the inputs' own (or none), its icon from that input, one row
250
- * per source, at most `MOST`. Anything else is dropped, never repaired.
292
+ * The rows of a chat completion, validated: each with words and an action that
293
+ * binds (an open names an input's key; a send, a task or a schedule carries its
294
+ * prompt, and a schedule a time it reads), one row per thing, at most `MOST`.
295
+ * Anything else is dropped, never repaired.
251
296
  */
252
297
  export function answer(raw, inputs) {
253
- const by = new Map(inputs.filter((i) => i.href).map((i) => [i.href, i]));
254
298
  const rows = obj(json(content(raw))).rows;
255
299
  const out = [];
256
300
  for (const r of Array.isArray(rows) ? rows : []) {
257
301
  const o = obj(r);
258
- const title = line(o.title, 80);
259
- const action = line(o.action, 80).replace(/\.$/, '');
260
- const prompt = str(o.prompt).trim().slice(0, 2000);
261
- if (!title || !action || !prompt)
262
- continue;
263
- const said = str(o.href).trim();
264
- const source = said ? by.get(said) : undefined;
265
- if (said && !source)
302
+ const title = line(o.title, 80).replace(/\.$/, '');
303
+ const act = bind(o, inputs);
304
+ if (!title || !act)
266
305
  continue;
267
- // GitHub's mark only beside GitHub's own address: a row without one is a task at most.
306
+ const source = about(act, inputs);
307
+ // GitHub's mark only beside GitHub's own address: anything else is a task at most.
268
308
  const named = ICONS.includes(o.icon) ? o.icon : 'chat';
269
309
  const icon = source ? iconOf(source) : named === 'github' ? 'task' : named;
270
- const id = idOf(said, title);
310
+ const id = idOf(act.kind === 'open' ? `${act.to}${act.item ?? ''}` : '', title);
271
311
  if (out.some((x) => x.id === id))
272
312
  continue;
273
- out.push({ id, icon, title: title.replace(/\.$/, ''), href: said, summary: line(o.summary, 600), action, prompt });
313
+ out.push({ id, icon, title, summary: line(o.summary, 600), act });
274
314
  if (out.length === MOST)
275
315
  break;
276
316
  }
277
317
  return out;
278
318
  }
279
- /** One kept row, or null for one that does not read as a row. */
319
+ /** A kept action, or null for one that does not read as one. */
320
+ function actOf(raw) {
321
+ const o = obj(raw);
322
+ const prompt = str(o.prompt);
323
+ switch (str(o.kind)) {
324
+ case 'open':
325
+ return str(o.to) ? { kind: 'open', to: str(o.to), ...(str(o.item) ? { item: str(o.item) } : {}) } : null;
326
+ case 'send':
327
+ case 'task':
328
+ return prompt ? { kind: str(o.kind), prompt } : null;
329
+ case 'schedule': {
330
+ const sc = obj(o.schedule);
331
+ const kind = str(sc.kind);
332
+ return prompt && kind ? { kind: 'schedule', name: str(o.name), prompt, schedule: { kind, ...(str(sc.at) ? { at: str(sc.at) } : {}), ...(str(sc.day) ? { day: str(sc.day) } : {}) } } : null;
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+ /** One kept row, or null for one that does not read as a row (a row from before rows were actions, among them). */
280
338
  function row(raw) {
281
339
  const o = obj(raw);
282
340
  const title = str(o.title);
283
- const action = str(o.action);
284
- const prompt = str(o.prompt);
285
- const href = str(o.href);
286
- if (!title || !action || !prompt || (href && https(href) !== href))
341
+ const act = actOf(o.act);
342
+ if (!title || !act)
287
343
  return null;
288
344
  return {
289
- id: str(o.id) || idOf(href, title),
345
+ id: str(o.id) || idOf(act.kind === 'open' ? act.to : '', title),
290
346
  icon: ICONS.includes(o.icon) ? o.icon : 'chat',
291
347
  title,
292
- href,
293
348
  summary: str(o.summary),
294
- action,
295
- prompt,
349
+ act,
296
350
  };
297
351
  }
298
352
  /** The `suggest` key of the preference document, or undefined when it holds none. */
@@ -309,11 +363,11 @@ export function kept(raw) {
309
363
  }
310
364
  /** The rows to draw: those not dismissed. */
311
365
  export const shown = (k) => (k ? k.rows.filter((r) => !k.dismissed.includes(r.id)).slice(0, MOST) : []);
312
- /** What Hanzo does, three ways in: what fills the rows a person's activity and signup do not. */
366
+ /** What Hanzo does, three ways in, each done at once: what fills the rows a person's activity and signup do not. */
313
367
  export const STARTERS = [
314
- { id: 'start:chat', icon: 'chat', title: 'Think something through', href: '', summary: '', action: 'Ask Hanzo', prompt: 'Help me think through ' },
315
- { id: 'start:dev', icon: 'agent', title: 'Build an app or a site', href: '', summary: '', action: 'Describe it', prompt: 'Build me ' },
316
- { id: 'start:research', icon: 'file', title: 'Research a topic', href: '', summary: '', action: 'Get a cited brief', prompt: 'Research this and give me a short brief with sources: ' },
368
+ { id: 'start:chat', icon: 'chat', title: 'Ask what Hanzo can do for you', summary: '', act: { kind: 'send', prompt: 'What can you do for me? Give me three concrete things to try today.' } },
369
+ { id: 'start:dev', icon: 'agent', title: 'Build a landing page with Dev', summary: '', act: { kind: 'task', prompt: 'Build a one-page landing site for my project and give me the preview link.' } },
370
+ { id: 'start:research', icon: 'file', title: 'Get a cited brief on AI agents', summary: '', act: { kind: 'send', prompt: 'Research the state of AI coding agents this month and give me a short brief with sources.' } },
317
371
  ];
318
372
  /** The kept rows, filled to `MOST` with the starters not dismissed: never one lonely row. */
319
373
  export const fill = (k) => {
@@ -333,10 +387,10 @@ export function skip(k, inputs) {
333
387
  const gone = new Set(k.dismissed);
334
388
  const out = new Set();
335
389
  for (const i of inputs)
336
- if (i.href && gone.has(idOf(i.href, '')))
337
- out.add(i.href);
390
+ if (i.to && gone.has(idOf(`${i.to}${i.item ?? ''}`, '')))
391
+ out.add(i.key);
338
392
  for (const r of k.rows)
339
393
  if (gone.has(r.id))
340
- out.add(r.href || r.title);
394
+ out.add(r.title);
341
395
  return [...out];
342
396
  }
@@ -1,5 +1,6 @@
1
+ import { type Automation, type Source } from './api/auto.ts';
1
2
  import { type Target } from './api/call.ts';
2
- import { type Suggestion } from './suggest.ts';
3
+ import { type Act, type Suggestion } from './suggest.ts';
3
4
  export interface Suggested {
4
5
  /** The rows to draw: three when any are, none dismissed. */
5
6
  rows: Suggestion[];
@@ -10,11 +11,24 @@ export interface Suggested {
10
11
  }
11
12
  /** The person's suggestions. Signed out, or with nothing worth saying, there are none. */
12
13
  export declare function useSuggestions(t: Target): Suggested;
14
+ /**
15
+ * Leave `prompt` for the product `kind` names, which does it on arrival, once:
16
+ * Chat sends a `send` as a new chat, Dev starts a `task` as a run. Same-tab
17
+ * session storage, written by the person's own press, so an address can never
18
+ * carry one (a link is not consent to spend).
19
+ */
20
+ export declare function hand(kind: 'send' | 'task', prompt: string): void;
21
+ /** The prompt left for `kind`, taken so it is done once, or ''. */
22
+ export declare function taken(kind: 'send' | 'task'): string;
23
+ /** Make a `schedule` row's task: on, asking before it acts, and notifying its person. */
24
+ export declare function book(t: Target, act: Extract<Act, {
25
+ kind: 'schedule';
26
+ }>, source: Source): Promise<Automation>;
13
27
  export interface SuggestionsProps {
14
28
  rows: readonly Suggestion[];
15
- /** A row was chosen: its prompt, for the composer. */
16
- onPick: (prompt: string) => void;
29
+ /** A row was pressed: do what it says. */
30
+ onAct: (act: Act, row: Suggestion) => void;
17
31
  onDismiss: (id: string) => void;
18
32
  }
19
33
  /** The rows, or nothing when there are none. */
20
- export declare function Suggestions({ rows, onPick, onDismiss }: SuggestionsProps): import("react").JSX.Element | null;
34
+ export declare function Suggestions({ rows, onAct, onDismiss }: SuggestionsProps): import("react").JSX.Element | null;
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * The suggestions under the composer (above it on a phone): up to three rows,
4
- * each the source's mark, what happened linked to where it happened (its
5
- * summary on hover or focus), then the next step. Choosing a row puts its
6
- * prompt in the composer; its × dismisses it, and the account remembers.
4
+ * each the source's mark and one short action, its summary on hover. Pressing
5
+ * a row DOES it (suggest.ts `Act`): it opens what it names, sends its prompt as
6
+ * a new chat, starts its run, or makes its scheduled task. Nothing only fills
7
+ * the composer. Its × dismisses it, and the account remembers.
7
8
  *
8
9
  * `useSuggestions` reads the kept rows from the account (`suggest` in /v1/pref)
9
10
  * and, when they are due (suggest.ts `due`), reads the person's activity and
@@ -19,12 +20,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
20
  * POST /v1/iam/preferences {} what signup kept (`onboarding`)
20
21
  * POST /v1/chat/completions the rows, as `MODEL` writes them
21
22
  * PATCH /v1/pref {suggest} the rows kept, and each dismissal
23
+ * POST /v1/auto/automations a `schedule` row's task
22
24
  */
23
25
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
24
- import { ArrowUpRight, Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2';
26
+ import { Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2';
25
27
  import { HoverCard, HoverCardContent, HoverCardTrigger } from '@hanzo/ui';
26
28
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
27
- import { automations } from './api/auto.js';
29
+ import { automations, create } from './api/auto.js';
28
30
  import { call, Refusal, unwrap } from './api/call.js';
29
31
  import { conversations } from './api/conversations.js';
30
32
  import { items } from './api/library.js';
@@ -32,7 +34,6 @@ import { read, save } from './api/pref.js';
32
34
  import { list } from './api/sessions.js';
33
35
  import { everything } from './api/work.js';
34
36
  import { claims } from './claims.js';
35
- import { Out } from './out.js';
36
37
  import { answer, dismissed, due, fill, inputs, request, seed, sig, skip, stale, TAKE } from './suggest.js';
37
38
  /** A source that refuses this person (a 4xx other than an expired sign-in or a rate) has nothing for them; any other failure leaves it unknown. */
38
39
  function soft(p, none) {
@@ -132,6 +133,49 @@ export function useSuggestions(t) {
132
133
  }, [t]);
133
134
  return useMemo(() => ({ rows, dismiss, error }), [rows, dismiss, error]);
134
135
  }
136
+ /** Where a row leaves its act for the product that does it. */
137
+ const HAND = 'hanzo.suggest.hand';
138
+ /**
139
+ * Leave `prompt` for the product `kind` names, which does it on arrival, once:
140
+ * Chat sends a `send` as a new chat, Dev starts a `task` as a run. Same-tab
141
+ * session storage, written by the person's own press, so an address can never
142
+ * carry one (a link is not consent to spend).
143
+ */
144
+ export function hand(kind, prompt) {
145
+ try {
146
+ window.sessionStorage.setItem(HAND, JSON.stringify({ kind, prompt }));
147
+ }
148
+ catch {
149
+ // Without storage the product opens without it.
150
+ }
151
+ }
152
+ /** The prompt left for `kind`, taken so it is done once, or ''. */
153
+ export function taken(kind) {
154
+ try {
155
+ const v = JSON.parse(window.sessionStorage.getItem(HAND) ?? 'null');
156
+ if (v?.kind !== kind || typeof v.prompt !== 'string')
157
+ return '';
158
+ window.sessionStorage.removeItem(HAND);
159
+ return v.prompt.trim();
160
+ }
161
+ catch {
162
+ return '';
163
+ }
164
+ }
165
+ /** Make a `schedule` row's task: on, asking before it acts, and notifying its person. */
166
+ export function book(t, act, source) {
167
+ return create(t, {
168
+ name: act.name || act.prompt.slice(0, 80),
169
+ instructions: act.prompt,
170
+ model: null,
171
+ schedule: { ...act.schedule, tz: Intl.DateTimeFormat().resolvedOptions().timeZone },
172
+ permissions: 'ask',
173
+ notify: true,
174
+ postTo: [],
175
+ enabled: true,
176
+ source,
177
+ });
178
+ }
135
179
  const MARKS = {
136
180
  github: Github,
137
181
  agent: Bot,
@@ -142,30 +186,17 @@ const MARKS = {
142
186
  /** The row's pitch, px: a finger's on a phone, and the same at a desk. */
143
187
  const PITCH = 44;
144
188
  /** The rows, or nothing when there are none. */
145
- export function Suggestions({ rows, onPick, onDismiss }) {
189
+ export function Suggestions({ rows, onAct, onDismiss }) {
146
190
  // A phone has no hover: its × stays in sight.
147
191
  const touch = !useMedia().sm;
148
192
  if (!rows.length)
149
193
  return null;
150
- return (_jsx(YStack, { role: "list", "aria-label": "Suggestions", "data-slot": "suggestions", children: rows.map((r) => (_jsx(Row, { row: r, touch: touch, onPick: onPick, onDismiss: onDismiss }, r.id))) }));
194
+ return (_jsx(YStack, { role: "list", "aria-label": "Suggestions", "data-slot": "suggestions", children: rows.map((r) => (_jsx(Row, { row: r, touch: touch, onAct: onAct, onDismiss: onDismiss }, r.id))) }));
151
195
  }
152
- /**
153
- * One row. The whole row is one button, laid under the row's content (which is
154
- * positioned after it, so it paints above, and lets presses through), and over
155
- * it the title's link and the × are their own: three stops for the keyboard,
156
- * none inside another.
157
- */
158
- function Row({ row, touch, onPick, onDismiss }) {
196
+ /** One row: the whole of it one button that does the act, and its × beside it. Two stops for the keyboard, neither inside the other. */
197
+ function Row({ row, touch, onAct, onDismiss }) {
159
198
  const [near, setNear] = useState(false);
160
199
  const Mark = MARKS[row.icon];
161
- return (_jsxs(XStack, { role: "listitem", "data-slot": "suggestion", position: "relative", items: "center", gap: "$3", minH: PITCH, px: "$3", rounded: "$4", hoverStyle: { bg: '$hover' }, onPointerEnter: () => setNear(true), onPointerLeave: () => setNear(false), children: [_jsx(YStack, { render: "button", "aria-label": `${row.title}. ${row.action}`, onPress: () => onPick(row.prompt), position: "absolute", t: 0, r: 0, b: 0, l: 0, rounded: "$4", cursor: "pointer", focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' } }), _jsxs(XStack, { position: "relative", flex: 1, minW: 0, items: "center", gap: "$3", pointerEvents: "none", children: [_jsx(Mark, { size: 18, color: "$soft" }), _jsxs(XStack, { flex: 1, minW: 0, items: "center", children: [_jsx(Title, { row: row }), _jsx(SizableText, { fontSize: 15, color: "$soft", numberOfLines: 1, shrink: 1, minW: 0, children: `. ${row.action}` })] })] }), _jsx(XStack, { render: "button", "aria-label": `Dismiss ${row.title}`, position: "relative", onPress: () => onDismiss(row.id), p: "$1.5", rounded: "$2", cursor: "pointer", opacity: touch || near ? 1 : 0, hoverStyle: { bg: '$hover' }, focusVisibleStyle: { opacity: 1, outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(X, { size: 16, color: "$soft" }) })] }));
162
- }
163
- /** What happened, linked to its source with a ↗ when it has one, its summary in a card above it. It keeps its words before the step does, up to 70% of the line. */
164
- function Title({ row }) {
165
- const words = (_jsx(SizableText, { fontSize: 15, color: "$soft", numberOfLines: 1, textDecorationLine: row.href ? 'underline' : 'none', textDecorationStyle: "dotted", children: row.title }));
166
- const text = row.href ? (_jsxs(Out, { href: row.href, label: `${row.title}, opens the source`, children: [words, _jsx(ArrowUpRight, { size: 12, color: "$soft" })] })) : (words);
167
- if (!row.summary) {
168
- return (_jsx(XStack, { shrink: 0, maxW: "70%", minW: 0, pointerEvents: "auto", children: text }));
169
- }
170
- return (_jsxs(HoverCard, { openDelay: 300, closeDelay: 150, children: [_jsx(HoverCardTrigger, { shrink: 0, maxW: "70%", minW: 0, pointerEvents: "auto", children: text }), _jsx(HoverCardContent, { side: "top", align: "start", sideOffset: 8, width: 470, maxW: "calc(100vw - 32px)", rounded: "$6", p: "$4", children: _jsx(SizableText, { fontSize: 16, lineHeight: 26, color: "$ink", children: row.summary }) })] }));
200
+ const button = (_jsxs(XStack, { render: "button", "data-act": row.act.kind, "aria-label": row.title, onPress: () => onAct(row.act, row), flex: 1, minW: 0, self: "stretch", items: "center", gap: "$3", pl: "$3", rounded: "$4", cursor: "pointer", focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(Mark, { size: 18, color: "$soft" }), _jsx(SizableText, { fontSize: 15, color: "$soft", numberOfLines: 1, shrink: 1, minW: 0, children: row.title })] }));
201
+ return (_jsxs(XStack, { role: "listitem", "data-slot": "suggestion", items: "center", gap: "$1", minH: PITCH, pr: "$2", rounded: "$4", hoverStyle: { bg: '$hover' }, onPointerEnter: () => setNear(true), onPointerLeave: () => setNear(false), children: [row.summary ? (_jsxs(HoverCard, { openDelay: 300, closeDelay: 150, children: [_jsx(HoverCardTrigger, { flex: 1, minW: 0, self: "stretch", flexDirection: "row", children: button }), _jsx(HoverCardContent, { side: "top", align: "start", sideOffset: 8, width: 470, maxW: "calc(100vw - 32px)", rounded: "$6", p: "$4", children: _jsx(SizableText, { fontSize: 16, lineHeight: 26, color: "$ink", children: row.summary }) })] })) : (button), _jsx(XStack, { render: "button", "aria-label": `Dismiss ${row.title}`, onPress: () => onDismiss(row.id), p: "$1.5", rounded: "$2", cursor: "pointer", opacity: touch || near ? 1 : 0, hoverStyle: { bg: '$hover' }, focusVisibleStyle: { opacity: 1, outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(X, { size: 16, color: "$soft" }) })] }));
171
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.78",
3
+ "version": "0.2.80",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",