@hanzo/build 0.2.59 → 0.2.61
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 +13 -3
- package/lib/api/conversations.d.ts +20 -0
- package/lib/api/conversations.js +23 -0
- package/lib/api/pref.d.ts +10 -0
- package/lib/api/pref.js +7 -0
- package/lib/builder.js +2 -2
- package/lib/carry.d.ts +17 -0
- package/lib/carry.js +95 -0
- package/lib/gear.d.ts +51 -0
- package/lib/gear.js +123 -0
- package/lib/index.d.ts +22 -5
- package/lib/index.js +20 -5
- package/lib/landing.js +30 -10
- package/lib/mic.d.ts +10 -0
- package/lib/mic.js +95 -0
- package/lib/mind.d.ts +18 -1
- package/lib/mind.js +48 -5
- package/lib/panel.js +14 -7
- package/lib/plugin.d.ts +56 -0
- package/lib/plugin.js +1 -0
- package/lib/plus.d.ts +46 -0
- package/lib/plus.js +58 -0
- package/lib/prefs.d.ts +14 -1
- package/lib/prefs.js +13 -6
- package/lib/project.js +4 -7
- package/lib/prompt.d.ts +68 -19
- package/lib/prompt.js +170 -95
- package/lib/run.js +14 -44
- package/lib/side.d.ts +68 -0
- package/lib/side.js +195 -0
- package/lib/suggest.d.ts +202 -0
- package/lib/suggest.js +329 -0
- package/lib/suggestions.d.ts +20 -0
- package/lib/suggestions.js +170 -0
- package/package.json +2 -2
- package/lib/grip.d.ts +0 -40
- package/lib/grip.js +0 -108
package/lib/suggest.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
export const ICONS = ['github', 'agent', 'chat', 'file', 'task'];
|
|
2
|
+
/** Hanzo's model the rows are asked of: it decodes under a strict JSON schema at api.hanzo.ai. */
|
|
3
|
+
export const MODEL = 'zen5-flash';
|
|
4
|
+
/** At most this many rows. */
|
|
5
|
+
export const MOST = 3;
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
/** Rows this old are made again whatever happened. */
|
|
8
|
+
export const DAY = 24 * HOUR;
|
|
9
|
+
/** Rows younger than this are kept even when the activity has moved. */
|
|
10
|
+
export const QUIET = HOUR;
|
|
11
|
+
/** How many dismissals are remembered. */
|
|
12
|
+
export const REMEMBER = 100;
|
|
13
|
+
/** How many of each source the model reads. */
|
|
14
|
+
export const TAKE = { conversation: 8, issue: 6, pull: 4, run: 6, automation: 4, file: 4 };
|
|
15
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
16
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
17
|
+
/** One line, at most `n` characters. */
|
|
18
|
+
export function line(v, n) {
|
|
19
|
+
const s = str(v).replace(/\s+/g, ' ').trim();
|
|
20
|
+
return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
|
21
|
+
}
|
|
22
|
+
/** An https address, or ''. A row links to nothing else. */
|
|
23
|
+
export function https(v) {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(str(v));
|
|
26
|
+
return u.protocol === 'https:' ? u.href : '';
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** cyrb53: a 53-bit string hash, as 14 hex digits. A fingerprint, never a secret. */
|
|
33
|
+
export function hash(s) {
|
|
34
|
+
let h1 = 0xdeadbeef;
|
|
35
|
+
let h2 = 0x41c6ce57;
|
|
36
|
+
for (let i = 0; i < s.length; i++) {
|
|
37
|
+
const c = s.charCodeAt(i);
|
|
38
|
+
h1 = Math.imul(h1 ^ c, 2654435761);
|
|
39
|
+
h2 = Math.imul(h2 ^ c, 1597334677);
|
|
40
|
+
}
|
|
41
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
42
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
43
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
|
|
44
|
+
}
|
|
45
|
+
const time = (iso) => {
|
|
46
|
+
const t = Date.parse(iso);
|
|
47
|
+
return Number.isFinite(t) ? t : 0;
|
|
48
|
+
};
|
|
49
|
+
/** The first line of a text. */
|
|
50
|
+
const first = (s) => s.split('\n').find((l) => l.trim()) ?? '';
|
|
51
|
+
/** The person's activity as inputs: their own runs only, each kind's newest `TAKE`. */
|
|
52
|
+
export function inputs(a) {
|
|
53
|
+
const all = [
|
|
54
|
+
...a.conversations.map((c) => ({ kind: 'conversation', key: c.id, title: c.title, state: '', text: '', href: '', updated: c.updated })),
|
|
55
|
+
...a.issues.map((i) => ({
|
|
56
|
+
kind: i.pull ? 'pull' : 'issue',
|
|
57
|
+
key: i.key,
|
|
58
|
+
title: i.title,
|
|
59
|
+
state: i.state === 'closed' ? 'closed' : i.status || 'open',
|
|
60
|
+
text: [i.repo || i.forge || i.board, first(i.body)].filter(Boolean).join(': '),
|
|
61
|
+
href: https(i.url),
|
|
62
|
+
updated: i.updated,
|
|
63
|
+
})),
|
|
64
|
+
...a.runs
|
|
65
|
+
.filter((r) => a.me && r.actor === a.me)
|
|
66
|
+
.map((r) => ({
|
|
67
|
+
kind: 'run',
|
|
68
|
+
key: r.id,
|
|
69
|
+
title: r.title,
|
|
70
|
+
state: r.status,
|
|
71
|
+
text: [r.repo, r.branch].filter(Boolean).join(' @ '),
|
|
72
|
+
href: https(r.pr),
|
|
73
|
+
updated: r.updatedAt || r.createdAt,
|
|
74
|
+
})),
|
|
75
|
+
...a.automations
|
|
76
|
+
.filter((x) => !x.draft)
|
|
77
|
+
.map((x) => ({
|
|
78
|
+
kind: 'automation',
|
|
79
|
+
key: x.id,
|
|
80
|
+
title: x.name,
|
|
81
|
+
state: x.last?.status || (x.enabled ? 'on' : 'off'),
|
|
82
|
+
text: x.last?.summary || first(x.instructions),
|
|
83
|
+
href: '',
|
|
84
|
+
updated: x.last?.at || x.updated,
|
|
85
|
+
})),
|
|
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 })),
|
|
87
|
+
];
|
|
88
|
+
return pick(all);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The seed: what the account's preferences say (`callName`, `work`,
|
|
92
|
+
* `instructions` in /v1/pref) and what signup kept on the IAM user (`onboarding`
|
|
93
|
+
* in /v1/iam/preferences: the role picked, and personal, team or enterprise).
|
|
94
|
+
*/
|
|
95
|
+
export function seed(p, iam) {
|
|
96
|
+
const o = obj(obj(iam).onboarding);
|
|
97
|
+
const out = {
|
|
98
|
+
name: line(p.callName, 60),
|
|
99
|
+
work: line(p.work, 240),
|
|
100
|
+
role: line(o.role, 80),
|
|
101
|
+
use: line(o.use, 20),
|
|
102
|
+
instructions: line(p.instructions, 400),
|
|
103
|
+
};
|
|
104
|
+
return Object.fromEntries(Object.entries(out).filter(([, v]) => v));
|
|
105
|
+
}
|
|
106
|
+
/** Each kind's newest `TAKE`, newest first overall. */
|
|
107
|
+
export function pick(all) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const kind of Object.keys(TAKE)) {
|
|
110
|
+
out.push(...all
|
|
111
|
+
.filter((i) => i.kind === kind && i.title)
|
|
112
|
+
.sort((a, b) => time(b.updated) - time(a.updated))
|
|
113
|
+
.slice(0, TAKE[kind]));
|
|
114
|
+
}
|
|
115
|
+
return out.sort((a, b) => time(b.updated) - time(a.updated));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The fingerprint of what the rows are made from: each input's identity and
|
|
119
|
+
* state, and the seed. A new conversation, a closed issue or a finished run
|
|
120
|
+
* moves it; another turn in a conversation already read does not.
|
|
121
|
+
*/
|
|
122
|
+
export function sig(inputs, seed) {
|
|
123
|
+
const lines = inputs.map((i) => `${i.kind}\t${i.key}\t${i.state}`).sort();
|
|
124
|
+
const s = [seed.name, seed.work, seed.role, seed.use, seed.instructions].map((x) => x ?? '').join('\t');
|
|
125
|
+
return hash(`${lines.join('\n')}\n--\n${s}`);
|
|
126
|
+
}
|
|
127
|
+
/** Whether rows kept at `kept` are to be made again, now that the inputs read as `now`. */
|
|
128
|
+
export function due(kept, fingerprint, now) {
|
|
129
|
+
if (!kept)
|
|
130
|
+
return true;
|
|
131
|
+
const age = now - time(kept.at);
|
|
132
|
+
if (!time(kept.at) || age < 0 || age >= DAY)
|
|
133
|
+
return true;
|
|
134
|
+
return kept.sig !== fingerprint && age >= QUIET;
|
|
135
|
+
}
|
|
136
|
+
/** Whether anything newer could change kept rows yet: false while they are younger than `QUIET`. */
|
|
137
|
+
export function stale(kept, now) {
|
|
138
|
+
if (!kept)
|
|
139
|
+
return true;
|
|
140
|
+
const age = now - time(kept.at);
|
|
141
|
+
return !time(kept.at) || age < 0 || age >= QUIET;
|
|
142
|
+
}
|
|
143
|
+
/** The icon a row about `i` carries: GitHub's mark only for GitHub's own address. */
|
|
144
|
+
export function iconOf(i) {
|
|
145
|
+
switch (i.kind) {
|
|
146
|
+
case 'conversation':
|
|
147
|
+
return 'chat';
|
|
148
|
+
case 'issue':
|
|
149
|
+
case 'pull':
|
|
150
|
+
return /^https:\/\/github\.com\//.test(i.href) ? 'github' : 'task';
|
|
151
|
+
case 'run':
|
|
152
|
+
case 'automation':
|
|
153
|
+
return 'agent';
|
|
154
|
+
case 'file':
|
|
155
|
+
return 'file';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** The answer's shape, decoded strictly by the model. */
|
|
159
|
+
export const SCHEMA = {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: ['rows'],
|
|
163
|
+
properties: {
|
|
164
|
+
rows: {
|
|
165
|
+
type: 'array',
|
|
166
|
+
maxItems: MOST,
|
|
167
|
+
items: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
required: ['icon', 'title', 'href', 'summary', 'action', 'prompt'],
|
|
171
|
+
properties: {
|
|
172
|
+
icon: { type: 'string', enum: [...ICONS] },
|
|
173
|
+
title: { type: 'string' },
|
|
174
|
+
href: { type: 'string' },
|
|
175
|
+
summary: { type: 'string' },
|
|
176
|
+
action: { type: 'string' },
|
|
177
|
+
prompt: { type: 'string' },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
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.',
|
|
186
|
+
'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
|
+
'With no activity, suggest 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
|
+
'If nothing is worth saying, answer {"rows": []}. Fewer good rows beat three weak ones.',
|
|
196
|
+
].join('\n');
|
|
197
|
+
/** The chat completion that asks for rows, or null when there is nothing to make them from. */
|
|
198
|
+
export function request(inputs, seed, skip, now) {
|
|
199
|
+
const person = Object.fromEntries(Object.entries(seed).filter(([, v]) => typeof v === 'string' && v.trim()));
|
|
200
|
+
if (!inputs.length && !Object.keys(person).length)
|
|
201
|
+
return null;
|
|
202
|
+
const activity = inputs.map((i) => ({
|
|
203
|
+
kind: i.kind,
|
|
204
|
+
title: line(i.title, 140),
|
|
205
|
+
...(i.state ? { state: i.state } : {}),
|
|
206
|
+
...(i.text ? { text: line(i.text, 240) } : {}),
|
|
207
|
+
href: i.href,
|
|
208
|
+
...(i.updated ? { updated: i.updated.slice(0, 10) } : {}),
|
|
209
|
+
}));
|
|
210
|
+
return {
|
|
211
|
+
model: MODEL,
|
|
212
|
+
messages: [
|
|
213
|
+
{ role: 'system', content: SYSTEM },
|
|
214
|
+
{ role: 'user', content: JSON.stringify({ today: now.toISOString().slice(0, 10), person, activity, skip }) },
|
|
215
|
+
],
|
|
216
|
+
response_format: { type: 'json_schema', json_schema: { name: 'suggestions', strict: true, schema: SCHEMA } },
|
|
217
|
+
max_tokens: 1200,
|
|
218
|
+
temperature: 0.3,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The first JSON object in a model's text: all of it, or from its first `{` to its last `}`. */
|
|
222
|
+
function json(text) {
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
const a = text.indexOf('{');
|
|
228
|
+
const b = text.lastIndexOf('}');
|
|
229
|
+
if (a < 0 || b <= a)
|
|
230
|
+
return null;
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(text.slice(a, b + 1));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** The text of a chat completion's first choice, or ''. */
|
|
240
|
+
export function content(raw) {
|
|
241
|
+
const choices = obj(raw).choices;
|
|
242
|
+
const first = obj(Array.isArray(choices) ? choices[0] : null);
|
|
243
|
+
return str(obj(first.message).content);
|
|
244
|
+
}
|
|
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());
|
|
247
|
+
/**
|
|
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.
|
|
251
|
+
*/
|
|
252
|
+
export function answer(raw, inputs) {
|
|
253
|
+
const by = new Map(inputs.filter((i) => i.href).map((i) => [i.href, i]));
|
|
254
|
+
const rows = obj(json(content(raw))).rows;
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
257
|
+
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)
|
|
266
|
+
continue;
|
|
267
|
+
// GitHub's mark only beside GitHub's own address: a row without one is a task at most.
|
|
268
|
+
const named = ICONS.includes(o.icon) ? o.icon : 'chat';
|
|
269
|
+
const icon = source ? iconOf(source) : named === 'github' ? 'task' : named;
|
|
270
|
+
const id = idOf(said, title);
|
|
271
|
+
if (out.some((x) => x.id === id))
|
|
272
|
+
continue;
|
|
273
|
+
out.push({ id, icon, title: title.replace(/\.$/, ''), href: said, summary: line(o.summary, 600), action, prompt });
|
|
274
|
+
if (out.length === MOST)
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/** One kept row, or null for one that does not read as a row. */
|
|
280
|
+
function row(raw) {
|
|
281
|
+
const o = obj(raw);
|
|
282
|
+
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))
|
|
287
|
+
return null;
|
|
288
|
+
return {
|
|
289
|
+
id: str(o.id) || idOf(href, title),
|
|
290
|
+
icon: ICONS.includes(o.icon) ? o.icon : 'chat',
|
|
291
|
+
title,
|
|
292
|
+
href,
|
|
293
|
+
summary: str(o.summary),
|
|
294
|
+
action,
|
|
295
|
+
prompt,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** The `suggest` key of the preference document, or undefined when it holds none. */
|
|
299
|
+
export function kept(raw) {
|
|
300
|
+
const o = obj(raw);
|
|
301
|
+
if (!Object.keys(o).length)
|
|
302
|
+
return undefined;
|
|
303
|
+
return {
|
|
304
|
+
at: str(o.at),
|
|
305
|
+
sig: str(o.sig),
|
|
306
|
+
rows: (Array.isArray(o.rows) ? o.rows : []).map(row).filter((r) => r !== null).slice(0, MOST),
|
|
307
|
+
dismissed: (Array.isArray(o.dismissed) ? o.dismissed : []).filter((x) => typeof x === 'string' && x !== '').slice(0, REMEMBER),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/** The rows to draw: those not dismissed. */
|
|
311
|
+
export const shown = (k) => (k ? k.rows.filter((r) => !k.dismissed.includes(r.id)).slice(0, MOST) : []);
|
|
312
|
+
/** `k` with `id` dismissed: remembered first, the oldest forgotten past `REMEMBER`. */
|
|
313
|
+
export function dismissed(k, id) {
|
|
314
|
+
return { ...k, dismissed: [id, ...k.dismissed.filter((x) => x !== id)].slice(0, REMEMBER) };
|
|
315
|
+
}
|
|
316
|
+
/** The addresses and titles of dismissed rows the model is told to skip. */
|
|
317
|
+
export function skip(k, inputs) {
|
|
318
|
+
if (!k)
|
|
319
|
+
return [];
|
|
320
|
+
const gone = new Set(k.dismissed);
|
|
321
|
+
const out = new Set();
|
|
322
|
+
for (const i of inputs)
|
|
323
|
+
if (i.href && gone.has(idOf(i.href, '')))
|
|
324
|
+
out.add(i.href);
|
|
325
|
+
for (const r of k.rows)
|
|
326
|
+
if (gone.has(r.id))
|
|
327
|
+
out.add(r.href || r.title);
|
|
328
|
+
return [...out];
|
|
329
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Target } from './api/call.ts';
|
|
2
|
+
import { type Suggestion } from './suggest.ts';
|
|
3
|
+
export interface Suggested {
|
|
4
|
+
/** The rows to draw: at most three, none dismissed. */
|
|
5
|
+
rows: Suggestion[];
|
|
6
|
+
/** Dismiss one, remembered by the account. */
|
|
7
|
+
dismiss: (id: string) => void;
|
|
8
|
+
/** Why the rows could not be read, made or kept, in the platform's words. */
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** The person's suggestions. Signed out, or with nothing worth saying, there are none. */
|
|
12
|
+
export declare function useSuggestions(t: Target): Suggested;
|
|
13
|
+
export interface SuggestionsProps {
|
|
14
|
+
rows: readonly Suggestion[];
|
|
15
|
+
/** A row was chosen: its prompt, for the composer. */
|
|
16
|
+
onPick: (prompt: string) => void;
|
|
17
|
+
onDismiss: (id: string) => void;
|
|
18
|
+
}
|
|
19
|
+
/** The rows, or nothing when there are none. */
|
|
20
|
+
export declare function Suggestions({ rows, onPick, onDismiss }: SuggestionsProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* `useSuggestions` reads the kept rows from the account (`suggest` in /v1/pref)
|
|
9
|
+
* and, when they are due (suggest.ts `due`), reads the person's activity and
|
|
10
|
+
* asks Hanzo's model for new ones. One document per page, read once per person
|
|
11
|
+
* and org, shared by every composer on it.
|
|
12
|
+
*
|
|
13
|
+
* GET /v1/pref the kept rows, and callName, work, instructions
|
|
14
|
+
* GET /v1/agents/chat/conversations their conversations
|
|
15
|
+
* GET /v1/task/board the org's issues and pull requests
|
|
16
|
+
* GET /v1/agent/sessions?limit=20 Dev runs; their own are read
|
|
17
|
+
* GET /v1/auto/automations automations
|
|
18
|
+
* GET /v1/library?scope=mine their files
|
|
19
|
+
* POST /v1/iam/preferences {} what signup kept (`onboarding`)
|
|
20
|
+
* POST /v1/chat/completions the rows, as `MODEL` writes them
|
|
21
|
+
* PATCH /v1/pref {suggest} the rows kept, and each dismissal
|
|
22
|
+
*/
|
|
23
|
+
import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
|
|
24
|
+
import { ArrowUpRight, Bot, CalendarClock, FileText, Github, MessageSquare, X } from '@hanzogui/lucide-icons-2';
|
|
25
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@hanzo/ui';
|
|
26
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
27
|
+
import { automations } from './api/auto.js';
|
|
28
|
+
import { call, Refusal, unwrap } from './api/call.js';
|
|
29
|
+
import { conversations } from './api/conversations.js';
|
|
30
|
+
import { items } from './api/library.js';
|
|
31
|
+
import { read, save } from './api/pref.js';
|
|
32
|
+
import { list } from './api/sessions.js';
|
|
33
|
+
import { everything } from './api/work.js';
|
|
34
|
+
import { claims } from './claims.js';
|
|
35
|
+
import { Out } from './out.js';
|
|
36
|
+
import { answer, dismissed, due, inputs, request, seed, shown, sig, skip, stale, TAKE } from './suggest.js';
|
|
37
|
+
/** 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
|
+
function soft(p, none) {
|
|
39
|
+
return p.catch((e) => {
|
|
40
|
+
if (e instanceof Refusal && e.status >= 400 && e.status < 500 && e.status !== 401 && e.status !== 429)
|
|
41
|
+
return none;
|
|
42
|
+
throw e;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** The person's activity and seed, or null when a source could not be read: rows are never made from half of it. */
|
|
46
|
+
async function gather(t, p) {
|
|
47
|
+
const c = claims(t.token());
|
|
48
|
+
const me = typeof c?.owner === 'string' && typeof c?.sub === 'string' ? `${c.owner}/${c.sub}` : '';
|
|
49
|
+
try {
|
|
50
|
+
const [cs, is, rs, as, fs, iam] = await Promise.all([
|
|
51
|
+
soft(conversations(t), []),
|
|
52
|
+
soft(everything(t), []),
|
|
53
|
+
soft(list(t, { limit: 20 }), []),
|
|
54
|
+
soft(automations(t), []),
|
|
55
|
+
soft(items(t, { scope: 'mine', limit: TAKE.file }).then((page) => page.data), []),
|
|
56
|
+
soft(call(t, 'POST', '/v1/iam/preferences', {}).then(unwrap), {}),
|
|
57
|
+
]);
|
|
58
|
+
return { inputs: inputs({ conversations: cs, issues: is, runs: rs, automations: as, files: fs, me }), seed: seed(p, iam) };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// One document per page: the kept rows of whoever `loaded` names.
|
|
65
|
+
let doc = null;
|
|
66
|
+
let loaded = null;
|
|
67
|
+
const listeners = new Set();
|
|
68
|
+
const emit = () => listeners.forEach((f) => f());
|
|
69
|
+
const subscribe = (f) => {
|
|
70
|
+
listeners.add(f);
|
|
71
|
+
return () => listeners.delete(f);
|
|
72
|
+
};
|
|
73
|
+
/** Read the kept rows, and make new ones when they are due. Each await checks it still speaks for `id`. */
|
|
74
|
+
async function refresh(t, id) {
|
|
75
|
+
const p = await read(t);
|
|
76
|
+
if (loaded !== id)
|
|
77
|
+
return;
|
|
78
|
+
doc = p.suggest ?? null;
|
|
79
|
+
emit();
|
|
80
|
+
if (!stale(doc, Date.now()))
|
|
81
|
+
return;
|
|
82
|
+
const got = await gather(t, p);
|
|
83
|
+
if (loaded !== id || !got)
|
|
84
|
+
return;
|
|
85
|
+
const fingerprint = sig(got.inputs, got.seed);
|
|
86
|
+
if (!due(doc, fingerprint, Date.now()))
|
|
87
|
+
return;
|
|
88
|
+
const ask = request(got.inputs, got.seed, skip(doc, got.inputs), new Date());
|
|
89
|
+
const rows = ask ? answer(await call(t, 'POST', '/v1/chat/completions', ask), got.inputs) : [];
|
|
90
|
+
if (loaded !== id)
|
|
91
|
+
return;
|
|
92
|
+
// Dismissals made while the model wrote are the latest document's.
|
|
93
|
+
const gone = doc?.dismissed ?? [];
|
|
94
|
+
doc = { at: new Date().toISOString(), sig: fingerprint, rows: rows.filter((r) => !gone.includes(r.id)), dismissed: gone };
|
|
95
|
+
emit();
|
|
96
|
+
await save(t, { suggest: doc });
|
|
97
|
+
}
|
|
98
|
+
/** Whose rows: the platform, the org and the person. Signed out is nobody, and has none. */
|
|
99
|
+
const whose = (t) => {
|
|
100
|
+
const token = t.token();
|
|
101
|
+
if (!token)
|
|
102
|
+
return '';
|
|
103
|
+
const sub = claims(token)?.sub;
|
|
104
|
+
return `${t.api}|${t.org ?? ''}|${typeof sub === 'string' ? sub : ''}`;
|
|
105
|
+
};
|
|
106
|
+
function load(t) {
|
|
107
|
+
const id = whose(t);
|
|
108
|
+
if (loaded === id)
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
loaded = id;
|
|
111
|
+
doc = null;
|
|
112
|
+
emit();
|
|
113
|
+
return id ? refresh(t, id) : Promise.resolve();
|
|
114
|
+
}
|
|
115
|
+
const said = (e) => (e instanceof Error && e.message ? e.message : 'Suggestions could not be read.');
|
|
116
|
+
/** The person's suggestions. Signed out, or with nothing worth saying, there are none. */
|
|
117
|
+
export function useSuggestions(t) {
|
|
118
|
+
const [error, setError] = useState(null);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
load(t).then(() => setError(null), (e) => setError(said(e)));
|
|
121
|
+
}, [t]);
|
|
122
|
+
const now = useSyncExternalStore(subscribe, () => doc, () => doc);
|
|
123
|
+
const rows = useMemo(() => shown(now), [now]);
|
|
124
|
+
const dismiss = useCallback((id) => {
|
|
125
|
+
if (!doc)
|
|
126
|
+
return;
|
|
127
|
+
doc = dismissed(doc, id);
|
|
128
|
+
emit();
|
|
129
|
+
if (t.token())
|
|
130
|
+
save(t, { suggest: doc }).then(() => setError(null), (e) => setError(said(e)));
|
|
131
|
+
}, [t]);
|
|
132
|
+
return useMemo(() => ({ rows, dismiss, error }), [rows, dismiss, error]);
|
|
133
|
+
}
|
|
134
|
+
const MARKS = {
|
|
135
|
+
github: Github,
|
|
136
|
+
agent: Bot,
|
|
137
|
+
chat: MessageSquare,
|
|
138
|
+
file: FileText,
|
|
139
|
+
task: CalendarClock,
|
|
140
|
+
};
|
|
141
|
+
/** The row's pitch, px: a finger's on a phone, and the same at a desk. */
|
|
142
|
+
const PITCH = 44;
|
|
143
|
+
/** The rows, or nothing when there are none. */
|
|
144
|
+
export function Suggestions({ rows, onPick, onDismiss }) {
|
|
145
|
+
// A phone has no hover: its × stays in sight.
|
|
146
|
+
const touch = !useMedia().sm;
|
|
147
|
+
if (!rows.length)
|
|
148
|
+
return null;
|
|
149
|
+
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))) }));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One row. The whole row is one button, laid under the row's content (which is
|
|
153
|
+
* positioned after it, so it paints above, and lets presses through), and over
|
|
154
|
+
* it the title's link and the × are their own: three stops for the keyboard,
|
|
155
|
+
* none inside another.
|
|
156
|
+
*/
|
|
157
|
+
function Row({ row, touch, onPick, onDismiss }) {
|
|
158
|
+
const [near, setNear] = useState(false);
|
|
159
|
+
const Mark = MARKS[row.icon];
|
|
160
|
+
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" }) })] }));
|
|
161
|
+
}
|
|
162
|
+
/** 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. */
|
|
163
|
+
function Title({ row }) {
|
|
164
|
+
const words = (_jsx(SizableText, { fontSize: 15, color: "$soft", numberOfLines: 1, textDecorationLine: row.href ? 'underline' : 'none', textDecorationStyle: "dotted", children: row.title }));
|
|
165
|
+
const text = row.href ? (_jsxs(Out, { href: row.href, label: `${row.title}, opens the source`, children: [words, _jsx(ArrowUpRight, { size: 12, color: "$soft" })] })) : (words);
|
|
166
|
+
if (!row.summary) {
|
|
167
|
+
return (_jsx(XStack, { shrink: 0, maxW: "70%", minW: 0, pointerEvents: "auto", children: text }));
|
|
168
|
+
}
|
|
169
|
+
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 }) })] }));
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.61",
|
|
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.",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"@hanzo/font": "1.9.3",
|
|
13
13
|
"@hanzo/gui": "8.3.5",
|
|
14
14
|
"@hanzo/iam": "0.21.28",
|
|
15
|
-
"@hanzo/ui": "8.
|
|
15
|
+
"@hanzo/ui": "8.29.0",
|
|
16
16
|
"@hanzo/vite": "0.1.4",
|
|
17
17
|
"@hanzo/voice": "0.1.18",
|
|
18
18
|
"@hanzogui/lucide-icons-2": "8.3.5",
|
package/lib/grip.d.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A column's free edge, which is also how wide the column is: a vertical
|
|
3
|
-
* separator a person drags, or moves with the arrow keys, to size the column
|
|
4
|
-
* beside it. The project workspace sizes its chat with it, and a host sizes its
|
|
5
|
-
* own column with it (the Hanzo app's sidebar).
|
|
6
|
-
*
|
|
7
|
-
* side the edge of the window the column is pinned to: `left` grows
|
|
8
|
-
* rightwards (a sidebar, the chat), `right` grows leftwards (an
|
|
9
|
-
* aside). One control for both, the arithmetic reflected.
|
|
10
|
-
* span the column's width now, px; `floor` and `ceil` bound it.
|
|
11
|
-
* onSpan the width while it moves, at most once a frame.
|
|
12
|
-
* onKeep the width it settled on: a drag let go, a key, a double-click.
|
|
13
|
-
* onShut when given, a drag pulled well past the floor, or a key pressed
|
|
14
|
-
* at it, puts the column away; absent, the floor holds.
|
|
15
|
-
* reset the width a double-click returns to.
|
|
16
|
-
*
|
|
17
|
-
* Keys: the arrows step 8px (32px with Shift), Home and End go to the floor and
|
|
18
|
-
* the ceiling. Pointer events, so a mouse, a trackpad, a touchscreen and a pen
|
|
19
|
-
* are one path, with the pointer captured: a drag that outruns the handle, or
|
|
20
|
-
* crosses a framed page, keeps sizing. The drag is measured from the column as
|
|
21
|
-
* drawn when it begins, so the edge never jumps to the cursor.
|
|
22
|
-
*
|
|
23
|
-
* It sits inside the column, absolutely, on the column's free edge; the rest of
|
|
24
|
-
* its props place it elsewhere (a gutter beside the column) and win.
|
|
25
|
-
*/
|
|
26
|
-
import { YStack } from '@hanzo/gui';
|
|
27
|
-
import { type ComponentProps } from 'react';
|
|
28
|
-
export interface GripProps extends Omit<ComponentProps<typeof YStack>, 'children'> {
|
|
29
|
-
side: 'left' | 'right';
|
|
30
|
-
span: number;
|
|
31
|
-
floor: number;
|
|
32
|
-
ceil: number;
|
|
33
|
-
reset: number;
|
|
34
|
-
onSpan: (n: number) => void;
|
|
35
|
-
onKeep?: (n: number) => void;
|
|
36
|
-
onShut?: () => void;
|
|
37
|
-
/** The separator's accessible name: "Resize sidebar", "Resize the chat". */
|
|
38
|
-
label: string;
|
|
39
|
-
}
|
|
40
|
-
export declare function Grip({ side, span, floor, ceil, reset, onSpan, onKeep, onShut, label, ...rest }: GripProps): import("react").JSX.Element;
|