@fungi.computer/parakeet 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/client-contributions.d.ts +1 -0
  2. package/dist/commands/awaiting-driver.d.ts +20 -0
  3. package/dist/commands/awaiting-driver.js +121 -0
  4. package/dist/commands/builtins.js +6 -4
  5. package/dist/commands/interactions.d.ts +25 -0
  6. package/dist/commands/interactions.js +76 -2
  7. package/dist/commands/run.js +3 -3
  8. package/dist/commands/slash.d.ts +7 -3
  9. package/dist/commands/slash.js +2 -0
  10. package/dist/commands/use-slash-commands.d.ts +13 -0
  11. package/dist/commands/use-slash-commands.js +58 -1
  12. package/dist/components/ChatComposer.d.ts +2 -0
  13. package/dist/components/ChatComposer.js +150 -38
  14. package/dist/components/ClientRows.js +42 -26
  15. package/dist/components/CompletionMenu.d.ts +54 -0
  16. package/dist/components/CompletionMenu.js +52 -0
  17. package/dist/components/DecisionComposer.d.ts +30 -0
  18. package/dist/components/DecisionComposer.js +146 -0
  19. package/dist/components/InteractionPanel.d.ts +10 -1
  20. package/dist/components/InteractionPanel.js +9 -4
  21. package/dist/components/SlashMenu.d.ts +1 -3
  22. package/dist/components/SlashMenu.js +5 -13
  23. package/dist/components/TimelineItem.d.ts +1 -0
  24. package/dist/components/TimelineItem.js +3 -3
  25. package/dist/components/ToolGroup.d.ts +1 -0
  26. package/dist/components/ToolGroup.js +7 -7
  27. package/dist/conversation.d.ts +5 -1
  28. package/dist/conversation.js +72 -20
  29. package/dist/core/approval-decision.d.ts +198 -0
  30. package/dist/core/approval-decision.js +291 -0
  31. package/dist/core/awaiting-answer.d.ts +229 -0
  32. package/dist/core/awaiting-answer.js +303 -0
  33. package/dist/core/build-timeline.js +56 -5
  34. package/dist/core/client-actions.d.ts +1 -0
  35. package/dist/core/client-actions.js +7 -1
  36. package/dist/core/draft-storage.d.ts +4 -0
  37. package/dist/core/draft-storage.js +12 -5
  38. package/dist/core/session-query.d.ts +8 -0
  39. package/dist/core/session-query.js +13 -11
  40. package/dist/headless.d.ts +6 -2
  41. package/dist/headless.js +5 -1
  42. package/dist/parakeet.d.ts +6 -2
  43. package/dist/parakeet.js +15 -6
  44. package/dist/store/types.d.ts +5 -1
  45. package/dist/styles.css +395 -113
  46. package/dist/use-approval-awaiting.d.ts +22 -0
  47. package/dist/use-approval-awaiting.js +81 -0
  48. package/dist/use-decision.d.ts +20 -0
  49. package/dist/use-decision.js +79 -0
  50. package/docs/api/commands/slash.md +30 -26
  51. package/docs/api/headless.md +822 -41
  52. package/docs/api/index.md +13 -4
  53. package/package.json +13 -7
  54. package/src/completion-menu.css +71 -0
  55. package/src/styles.css +174 -46
@@ -0,0 +1,198 @@
1
+ import type { WhistleInteraction } from "@fungi.computer/whistle";
2
+ /** The ledger facts an approval card reads; the approvals ledger's own view satisfies it. */
3
+ export type ApprovalFacts = Readonly<{
4
+ /** The durable record. */
5
+ record: Readonly<{
6
+ /** Identity of the record. */
7
+ recordId: string;
8
+ /** Changes whenever the record does; an answer names the revision it saw. */
9
+ revision: number;
10
+ /** Who asked. */
11
+ requester: Readonly<{
12
+ /** Name shown for the requesting Agent. */
13
+ displayName: string;
14
+ /** The Agent, when one asked. */
15
+ agentId: string | null;
16
+ /** The Session, when one asked. */
17
+ sessionId: string | null;
18
+ /** The principal that holds the request. */
19
+ principalId: string;
20
+ }>;
21
+ /** The tool's module. */
22
+ module: string;
23
+ /** The tool's operation. */
24
+ operation: string;
25
+ /** Whether the tool writes inside Fungi or reaches outside it. */
26
+ access: string;
27
+ /** What the tool would be given, as the ledger describes it. */
28
+ description: Readonly<{
29
+ /** Size of the input in bytes. */
30
+ inputBytes: number;
31
+ /** Digest of the input. */
32
+ inputSha256: string;
33
+ }>;
34
+ /** Whether the effect can be undone. */
35
+ reversibility: string;
36
+ /** When the request lapses. */
37
+ expiresAt: number;
38
+ }>;
39
+ /** The holder may answer it. */
40
+ canAnswer: boolean;
41
+ }>;
42
+ /** What the host knows about a command the Agent asked to run outside the sandbox. */
43
+ export type ApprovalReview = Readonly<{
44
+ /** The command line. */
45
+ command: string;
46
+ /** Where it would run. */
47
+ cwd: string;
48
+ /** Why the Agent says it needs it. */
49
+ justification: string;
50
+ /** A direct program, or shell text. */
51
+ form: "direct" | "shell";
52
+ /** A command, or an extension install. */
53
+ kind: "exec" | "extension-install";
54
+ /** The program and its arguments, when direct. */
55
+ argv?: readonly string[];
56
+ /** The command words an Always-allow rule would match. */
57
+ prefix?: readonly string[];
58
+ /** A warning shown when no rule is offered. */
59
+ warning?: string;
60
+ /** Everything an Always-allow rule would cover. */
61
+ alwaysAllowCoverage?: string;
62
+ /** Why no Always-allow rule is offered. */
63
+ alwaysAllowUnavailableReason?: string;
64
+ /** What a reusable rule risks. */
65
+ risk?: Readonly<{
66
+ /** The risk in one sentence, named once above the confirmation. */
67
+ summary: string;
68
+ /** The risk in full, shown under Details. */
69
+ detail: string;
70
+ }>;
71
+ }>;
72
+ /** One labelled value in a card's facts or details. */
73
+ export type ApprovalEntry = Readonly<{
74
+ /** What the value is. */
75
+ label: string;
76
+ /** The value. */
77
+ value: string;
78
+ }>;
79
+ /** The words an approval prompt carries above its choices. */
80
+ export type ApprovalContent = Readonly<{
81
+ /** The title. */
82
+ title: string;
83
+ /** One sentence under it. */
84
+ summary?: string | undefined;
85
+ /** Short labelled lines. */
86
+ facts?: readonly ApprovalEntry[] | undefined;
87
+ /** Long text, when the command is too long for a title. */
88
+ body?: string | undefined;
89
+ /** A short name for `body`. */
90
+ bodyLabel?: string | undefined;
91
+ /** Everything else, shown on demand. */
92
+ details?: readonly ApprovalEntry[] | undefined;
93
+ }>;
94
+ /** Everything one approval shows, and what Always allow would confirm. */
95
+ export type ApprovalCard = Readonly<{
96
+ /** The first prompt's words. */
97
+ content: ApprovalContent;
98
+ /** The Always-allow choice, when a rule is offered. */
99
+ always?: string | undefined;
100
+ /** What the Always-allow confirmation says. */
101
+ confirmation?: (ApprovalContent & {
102
+ /** The rule's coverage, whole. */
103
+ body: string;
104
+ }) | undefined;
105
+ /** The risk named above the confirmation, once. */
106
+ confirmWarning?: string | undefined;
107
+ /** The confirming choice. */
108
+ confirmYes?: string | undefined;
109
+ }>;
110
+ /**
111
+ * The words of one approval. A connected tool gets its effect; a command the
112
+ * Agent wants to run outside the sandbox gets its folder, reason and rule.
113
+ * `home` shortens paths inside it to `~`; `now` dates the expiry.
114
+ */
115
+ export declare function approvalCardCopy(view: ApprovalFacts, review: ApprovalReview | undefined, options: Readonly<{
116
+ width: number;
117
+ home: string;
118
+ now: number;
119
+ }>): ApprovalCard;
120
+ /** What an answer was refused for, in words for the person. */
121
+ export declare function approvalRefusal(reason: string, decision: "approve" | "deny" | "always-allow"): string;
122
+ /** What the person decided. */
123
+ export type ApprovalDecision = "approve" | "deny" | "always-allow";
124
+ /** A decision, pinned to the revision the person saw. */
125
+ export type ApprovalAnswerInput = Readonly<{
126
+ /** The record being answered. */
127
+ recordId: string;
128
+ /** Approve once, deny, or approve and keep a rule. */
129
+ decision: ApprovalDecision;
130
+ /** The revision on screen; a changed record refuses the answer. */
131
+ expectedRevision: number;
132
+ /** Why it was denied, when the person said. */
133
+ denialNote?: string;
134
+ }>;
135
+ /** How the owner took an answer; a refusal names its reason. */
136
+ export type ApprovalAnswerResult = Readonly<{
137
+ /** `refused`, or whatever else the owner calls an accepted answer. */
138
+ kind: string;
139
+ /** Why it was refused. */
140
+ reason?: string;
141
+ }>;
142
+ /** Where approvals come from and where answers go. */
143
+ export type ApprovalSource = Readonly<{
144
+ /** The record being shown, when there is one. */
145
+ snapshot: () => ApprovalFacts | undefined;
146
+ /** Observe the record changing. */
147
+ subscribe: (listener: () => void) => () => void;
148
+ /** What the host knows about the command, for an escape review. */
149
+ review?: ApprovalReview | undefined;
150
+ /** Answer it; a refusal names its reason. */
151
+ answer: (input: ApprovalAnswerInput) => Promise<ApprovalAnswerResult>;
152
+ /** Let the whole family of this tool through from now on, where the surface can. */
153
+ family?: Readonly<{
154
+ /** The choice's name. */
155
+ label: string;
156
+ /** Turn the rule on; false when it was refused. */
157
+ enable: () => Promise<boolean>;
158
+ }> | undefined;
159
+ }>;
160
+ /** The answer was sent. */
161
+ export type ApprovalAnswered = Readonly<{
162
+ /** Discriminant. */
163
+ kind: "answered";
164
+ }>;
165
+ /** The answer was refused, or could not be sent. */
166
+ export type ApprovalRefused = Readonly<{
167
+ /** Discriminant. */
168
+ kind: "refused";
169
+ /** What to tell the person. */
170
+ message: string;
171
+ }>;
172
+ /** The record changed or went away before an answer was sent. */
173
+ export type ApprovalAbandoned = Readonly<{
174
+ /** Discriminant. */
175
+ kind: "abandoned";
176
+ }>;
177
+ /** How asking ended. */
178
+ export type ApprovalOutcome = ApprovalAnswered | ApprovalRefused | ApprovalAbandoned;
179
+ /**
180
+ * Ask the person about one approval through a conversation's decision
181
+ * prompts, then send the answer. Nothing is sent until Enter answers, Always
182
+ * allow and the family rule each need a second, separate confirmation whose
183
+ * default is Back, and an
184
+ * answer is only sent while the record is still the one that was shown.
185
+ */
186
+ export declare function askApproval(interaction: WhistleInteraction, input: Readonly<{
187
+ view: ApprovalFacts;
188
+ source: ApprovalSource;
189
+ home: string;
190
+ width: number;
191
+ now: number;
192
+ /** The longest denial note. */
193
+ noteLimit: number;
194
+ /** The record on screen is still the current one. */
195
+ stillCurrent: () => boolean;
196
+ /** An error reached the person as a refusal; report its cause. */
197
+ onFailure: (cause: unknown) => void;
198
+ }>): Promise<ApprovalOutcome>;
@@ -0,0 +1,291 @@
1
+ const shorten = (value, width) => {
2
+ if (value.length <= width)
3
+ return value;
4
+ const left = Math.max(1, Math.floor((width - 1) / 2));
5
+ return `${value.slice(0, left)}…${value.slice(-(width - left - 1))}`;
6
+ };
7
+ const lead = (value, width) => value.length <= width ? value : `${value.slice(0, width - 1)}…`;
8
+ const entries = (pairs) => pairs.map(([label, value]) => ({ label, value }));
9
+ function recordDetails(view, now) {
10
+ const { record } = view;
11
+ const remaining = Math.max(0, record.expiresAt - now);
12
+ return [
13
+ [
14
+ "Agent",
15
+ `${record.requester.displayName} · ${record.requester.agentId ?? "none"}`,
16
+ ],
17
+ ["Session", record.requester.sessionId ?? "none"],
18
+ ["Requester", record.requester.principalId],
19
+ ["Request", `${record.module}.${record.operation} · ${record.access}`],
20
+ [
21
+ "Input",
22
+ `${record.description.inputBytes.toLocaleString()} bytes · SHA-256 ${record.description.inputSha256.slice(0, 8)}…${record.description.inputSha256.slice(-6)}`,
23
+ ],
24
+ [
25
+ "Expires",
26
+ `in ${String(Math.floor(remaining / 3_600_000))} h ${String(Math.floor((remaining % 3_600_000) / 60_000))} m`,
27
+ ],
28
+ ["Record", `${record.recordId} · revision ${String(record.revision)}`],
29
+ ];
30
+ }
31
+ function undoDetail(view) {
32
+ return [
33
+ "Undo",
34
+ view.record.reversibility === "irreversible-external"
35
+ ? "Can't be undone"
36
+ : "Can be undone",
37
+ ];
38
+ }
39
+ function connectedToolCard(view, now) {
40
+ const { record } = view;
41
+ return {
42
+ content: {
43
+ title: `Allow ${record.module} · ${record.operation}?`,
44
+ summary: `${record.requester.displayName} wants to use a connected tool.`,
45
+ facts: [
46
+ {
47
+ label: "Effect",
48
+ value: (record.access === "write"
49
+ ? "Changes data in Fungi"
50
+ : "Changes things outside Fungi") +
51
+ (record.reversibility === "irreversible-external"
52
+ ? " · can't be undone"
53
+ : ""),
54
+ },
55
+ ],
56
+ details: entries([undoDetail(view), ...recordDetails(view, now)]),
57
+ },
58
+ };
59
+ }
60
+ function escapeCard(view, review, width, home, now) {
61
+ const prefix = review.prefix?.join(" ");
62
+ const offered = prefix !== undefined && review.alwaysAllowUnavailableReason === undefined;
63
+ const command = review.command;
64
+ const title = `Allow \`${command}\`?`.length <= width
65
+ ? `Allow \`${command}\`?`
66
+ : "Allow this command?";
67
+ const folder = review.cwd === home
68
+ ? "~"
69
+ : review.cwd.startsWith(`${home}/`)
70
+ ? `~${review.cwd.slice(home.length)}`
71
+ : review.cwd;
72
+ const reasonWidth = Math.max(8, (width - 8) * 2 - 2);
73
+ const reason = review.justification.length <= reasonWidth
74
+ ? `“${review.justification}”`
75
+ : `“${review.justification.slice(0, reasonWidth - 1)}…”`;
76
+ const facts = [
77
+ ["Folder", shorten(folder, Math.max(8, width - 10))],
78
+ ["Why", reason],
79
+ ];
80
+ if (review.kind === "extension-install")
81
+ facts.push([
82
+ "Note",
83
+ "Extensions run inside Fungi with access to every Agent.",
84
+ ]);
85
+ // The complete host-provided coverage stays in Details; the main card has a
86
+ // one-line preview so long Git option lists cannot displace the choices.
87
+ if (offered && review.alwaysAllowCoverage !== undefined)
88
+ facts.push([
89
+ "Scope",
90
+ lead(review.alwaysAllowCoverage, Math.max(8, width - 12)),
91
+ ]);
92
+ if (!offered && review.warning !== undefined)
93
+ facts.push(["Warning", review.warning]);
94
+ const always = !offered
95
+ ? undefined
96
+ : `Always allow “${shorten(prefix, Math.max(8, width - 5 - "Always allow “” in this project…".length))}” in this project…`;
97
+ const details = [["Command", command]];
98
+ if (review.argv !== undefined)
99
+ details.push(["Arguments", JSON.stringify(review.argv)]);
100
+ details.push(["Folder", review.cwd], ["Runs", "Outside the sandbox, as you"], undoDetail(view));
101
+ if (!offered && review.alwaysAllowUnavailableReason !== undefined)
102
+ details.push(["Rule", review.alwaysAllowUnavailableReason]);
103
+ else if (review.form === "shell")
104
+ details.push(["Rule", "Shell command: no Always-allow rule can match it."]);
105
+ else if (offered) {
106
+ details.push([
107
+ "Rule",
108
+ `“${prefix}” would match \`${prefix} …\` in this project`,
109
+ ]);
110
+ if (review.alwaysAllowCoverage !== undefined)
111
+ details.push(["Coverage", review.alwaysAllowCoverage]);
112
+ if (review.risk !== undefined)
113
+ details.push(["Risk", review.risk.detail]);
114
+ }
115
+ if (!offered && review.warning !== undefined)
116
+ // Details replace the preview by label; do not repeat the same
117
+ // once-only warning as a second "Risk" row.
118
+ details.push(["Warning", review.warning]);
119
+ details.push(...recordDetails(view, now));
120
+ return {
121
+ content: {
122
+ title,
123
+ body: title === "Allow this command?" ? command : undefined,
124
+ bodyLabel: title === "Allow this command?" ? "Command" : undefined,
125
+ summary: `${view.record.requester.displayName} wants to run this outside the sandbox, as you.`,
126
+ facts: entries(facts),
127
+ details: entries(details),
128
+ },
129
+ always: always === undefined ? undefined : lead(always, Math.max(12, width - 5)),
130
+ confirmation: !offered
131
+ ? undefined
132
+ : {
133
+ title: `Always allow “${prefix}” in this project?`,
134
+ body: lead(review.alwaysAllowCoverage ??
135
+ `Any Workspace Agent here can then run \`${prefix} …\` without asking.`, Math.max(12, (width - 8) * 2 - 2)),
136
+ },
137
+ confirmWarning: offered ? review.risk?.summary : undefined,
138
+ confirmYes: !offered
139
+ ? undefined
140
+ : `Always allow “${prefix}” and run it now`,
141
+ };
142
+ }
143
+ /**
144
+ * The words of one approval. A connected tool gets its effect; a command the
145
+ * Agent wants to run outside the sandbox gets its folder, reason and rule.
146
+ * `home` shortens paths inside it to `~`; `now` dates the expiry.
147
+ */
148
+ export function approvalCardCopy(view, review, options) {
149
+ // A prompt's title is at most sixty characters.
150
+ const width = Math.min(options.width, 60);
151
+ return review === undefined
152
+ ? connectedToolCard(view, options.now)
153
+ : escapeCard(view, review, width, options.home, options.now);
154
+ }
155
+ /** What an answer was refused for, in words for the person. */
156
+ export function approvalRefusal(reason, decision) {
157
+ switch (reason) {
158
+ case "stale-revision":
159
+ case "already-answered":
160
+ return "This request changed. Look again before answering.";
161
+ case "expired":
162
+ return "This request expired. Nothing ran.";
163
+ case "not-eligible":
164
+ if (decision === "always-allow")
165
+ return "Always allow isn't available for this command. Approve once or deny.";
166
+ break;
167
+ default:
168
+ break;
169
+ }
170
+ return `Couldn't answer (${reason}). Nothing ran.`;
171
+ }
172
+ const failureMessage = "Couldn't send your answer. It may not have been recorded.";
173
+ /**
174
+ * Ask the person about one approval through a conversation's decision
175
+ * prompts, then send the answer. Nothing is sent until Enter answers, Always
176
+ * allow and the family rule each need a second, separate confirmation whose
177
+ * default is Back, and an
178
+ * answer is only sent while the record is still the one that was shown.
179
+ */
180
+ export async function askApproval(interaction, input) {
181
+ const { view, source } = input;
182
+ const card = approvalCardCopy(view, source.review, {
183
+ width: input.width,
184
+ home: input.home,
185
+ now: input.now,
186
+ });
187
+ const record = view.record;
188
+ try {
189
+ let decision;
190
+ let denialNote;
191
+ let familyOn = false;
192
+ while (decision === undefined) {
193
+ const answer = await interaction.prompt({
194
+ type: "decision",
195
+ message: card.content.title,
196
+ summary: familyOn
197
+ ? "Automatic approval is on for this family."
198
+ : card.content.summary,
199
+ facts: card.content.facts?.map((fact) => ({ ...fact })),
200
+ body: card.content.body,
201
+ bodyLabel: card.content.bodyLabel,
202
+ details: card.content.details?.map((entry) => ({ ...entry })),
203
+ options: [
204
+ { value: "approve", label: "Approve once" },
205
+ ...(card.always === undefined
206
+ ? []
207
+ : [{ value: "always", label: card.always }]),
208
+ ...(source.family === undefined || familyOn
209
+ ? []
210
+ : [{ value: "family", label: source.family.label }]),
211
+ { value: "deny", label: "Deny" },
212
+ ],
213
+ other: {
214
+ label: "Deny and say why…",
215
+ placeholder: "A short reason for the Agent (optional)",
216
+ maxLength: input.noteLimit,
217
+ blank: "deny",
218
+ },
219
+ });
220
+ if (!input.stillCurrent())
221
+ return { kind: "abandoned" };
222
+ if (answer.text !== undefined) {
223
+ denialNote = answer.text.trim() || undefined;
224
+ decision = "deny";
225
+ }
226
+ else if (answer.selected[0] === "always") {
227
+ const confirmation = card.confirmation;
228
+ const confirmed = await interaction.prompt({
229
+ type: "decision",
230
+ message: confirmation?.title ?? card.content.title,
231
+ summary: card.confirmWarning,
232
+ body: confirmation?.body,
233
+ options: [
234
+ { value: "back", label: "Back" },
235
+ { value: "always-allow", label: card.confirmYes ?? "Always allow" },
236
+ ],
237
+ escape: "back",
238
+ });
239
+ if (!input.stillCurrent())
240
+ return { kind: "abandoned" };
241
+ if (confirmed.selected[0] === "always-allow")
242
+ decision = "always-allow";
243
+ }
244
+ else if (answer.selected[0] === "family" &&
245
+ source.family !== undefined) {
246
+ // A standing rule, like Always allow: it needs its own confirmation.
247
+ const confirmed = await interaction.prompt({
248
+ type: "decision",
249
+ message: `${source.family.label}?`,
250
+ summary: `Later ${record.module} requests that ${record.access === "write" ? "change data in Fungi" : "reach outside Fungi"} will run without asking.`,
251
+ options: [
252
+ { value: "back", label: "Back" },
253
+ { value: "family", label: source.family.label },
254
+ ],
255
+ escape: "back",
256
+ });
257
+ if (!input.stillCurrent())
258
+ return { kind: "abandoned" };
259
+ if (confirmed.selected[0] === "family") {
260
+ familyOn = await source.family.enable();
261
+ if (!input.stillCurrent())
262
+ return { kind: "abandoned" };
263
+ }
264
+ }
265
+ else if (answer.selected[0] === "approve")
266
+ decision = "approve";
267
+ else if (answer.selected[0] === "deny")
268
+ decision = "deny";
269
+ }
270
+ const pinned = {
271
+ recordId: record.recordId,
272
+ decision,
273
+ expectedRevision: record.revision,
274
+ };
275
+ const result = await source.answer(denialNote === undefined ? pinned : { ...pinned, denialNote });
276
+ if (!input.stillCurrent())
277
+ return { kind: "abandoned" };
278
+ return result.kind === "refused"
279
+ ? {
280
+ kind: "refused",
281
+ message: approvalRefusal(result.reason ?? "unknown", decision),
282
+ }
283
+ : { kind: "answered" };
284
+ }
285
+ catch (cause) {
286
+ if (interaction.signal.aborted)
287
+ return { kind: "abandoned" };
288
+ input.onFailure(cause);
289
+ return { kind: "refused", message: failureMessage };
290
+ }
291
+ }