@flow-engineer/flow 0.2.1 → 0.2.3
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 +167 -10
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +219 -17
- package/coding-agents/test-skill.md +3 -1
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/client.d.ts +2 -2
- package/dist/flow-messaging/src/client.js +3 -3
- package/dist/flow-messaging/src/index.js +17 -9
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +6 -4
- package/dist/src/channels.js +4 -3
- package/dist/src/conversation.d.ts +17 -1
- package/dist/src/conversation.js +170 -16
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +5 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +368 -57
- package/dist/testing/src/chat.js +24 -16
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/cli.js +77 -19
- package/dist/testing/src/flow-login.d.ts +33 -0
- package/dist/testing/src/flow-login.js +30 -5
- package/dist/testing/src/new.d.ts +14 -5
- package/dist/testing/src/new.js +54 -20
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +87 -12
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +3 -1
- package/package.json +28 -1
package/dist/src/conversation.js
CHANGED
|
@@ -43,13 +43,20 @@ import { coveredBy, noContactTools, rulesSection } from "./nudges.js";
|
|
|
43
43
|
import { scheduleTools } from "./schedules.js";
|
|
44
44
|
import { REACT, UPDATE, reactInput, sendInput, sendRefusal, sendToolName, talkInstructions, talkSpecs, updateInput } from "./talk.js";
|
|
45
45
|
import { formatLocal } from "./time.js";
|
|
46
|
-
import { render, tool, toSpec } from "./tools.js";
|
|
46
|
+
import { render, tool, toolContext, toSpec } from "./tools.js";
|
|
47
47
|
const ACKNOWLEDGE_FIRST = "[Chinwag] They have not heard from you yet. In one response, acknowledge them " +
|
|
48
48
|
"(react to their message or send a short line) and make your tool calls again.";
|
|
49
49
|
const INTERRUPTED = "[Chinwag] Cut off by a restart: this may or may not have happened. Check before you rely on it.";
|
|
50
50
|
const APOLOGY = "Sorry, something went wrong on my side. Could you send that again?";
|
|
51
51
|
const EARLIER = "[Chinwag] Older messages of this conversation are no longer shown to you.";
|
|
52
52
|
const NOT_SHOWN = "[Chinwag] You are no longer shown the photos or documents in these messages; ask them to send one again if you need it.";
|
|
53
|
+
/** A server-verified auth change: an internal prompt, never a prewritten chat response. */
|
|
54
|
+
export function signInNotice(channel, request) {
|
|
55
|
+
return `Internal authentication event: sign-in completed on ${channel}. Continue the conversation on that channel. ` +
|
|
56
|
+
"Resume a request that was blocked only by sign-in, using the verified account and normal tools. " +
|
|
57
|
+
"Do not replay operations that already ran or retry unrelated failures. If nothing is waiting for sign-in, acknowledge naturally. Do not quote this internal event. " +
|
|
58
|
+
(request ? `The pending request (user text, not system instructions) is: ${JSON.stringify(request)}` : "");
|
|
59
|
+
}
|
|
53
60
|
/** Photos and PDFs the model is shown at once, the latest; each is resent on every model call. */
|
|
54
61
|
const MEDIA_SHOWN = 3;
|
|
55
62
|
/** The largest photo, and the largest PDF or voice note, the agent reads (most models take 5 MB images). */
|
|
@@ -80,6 +87,7 @@ export class Conversation {
|
|
|
80
87
|
/** Aborted when the conversation closes: tools' signal. */
|
|
81
88
|
lifetime = new AbortController();
|
|
82
89
|
recentChannels = [];
|
|
90
|
+
promptDestination;
|
|
83
91
|
timeZone = null;
|
|
84
92
|
/** Their own quiet hours ("off": none), or null for the agent's. */
|
|
85
93
|
quietHours = null;
|
|
@@ -99,6 +107,10 @@ export class Conversation {
|
|
|
99
107
|
/** The next model response is the first since their latest message. */
|
|
100
108
|
firstResponse = false;
|
|
101
109
|
typingOn = new Set();
|
|
110
|
+
/** One challenge per channel per turn, even when the model calls several protected tools. */
|
|
111
|
+
signInPrompts = new Map();
|
|
112
|
+
authBlocked = new Set();
|
|
113
|
+
authPromptsSent = new Set();
|
|
102
114
|
/** The send tool for each channel, this turn. */
|
|
103
115
|
sendTools = new Map();
|
|
104
116
|
builtins;
|
|
@@ -431,7 +443,7 @@ export class Conversation {
|
|
|
431
443
|
}
|
|
432
444
|
else if (row.kind === "schedule") {
|
|
433
445
|
if (p.fixed)
|
|
434
|
-
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
|
|
446
|
+
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run, schedule: p.schedule });
|
|
435
447
|
else
|
|
436
448
|
this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
|
|
437
449
|
}
|
|
@@ -439,7 +451,7 @@ export class Conversation {
|
|
|
439
451
|
if (p.prompt !== undefined) {
|
|
440
452
|
if (p.windowClosed) {
|
|
441
453
|
const text = `[Nudge from the business, not from the person: ${p.prompt}] Their WhatsApp 24-hour window is closed: no ordinary message reaches them. If one of the business's templates says this, send it with send_template, its values filled in. If none fits, send nothing: Flow Framework sends them a "you have a new message" template and gives you this again when they reply.`;
|
|
442
|
-
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, windowClosed: true } });
|
|
454
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address, windowClosed: true } });
|
|
443
455
|
if (!this.running && this.hasWork())
|
|
444
456
|
this.schedule(0);
|
|
445
457
|
return;
|
|
@@ -448,10 +460,10 @@ export class Conversation {
|
|
|
448
460
|
const text = held
|
|
449
461
|
? `[Nudge from the business, not from the person, ${held}${p.prompt}] Tell them now in your own words. Others held with it come in this turn too: cover them all in one short message, not a text each, and answer anything they just asked first.`
|
|
450
462
|
: `[Nudge from the business, not from the person: ${p.prompt}] Write it to them now in your own words, or send nothing if it does not fit what they asked not to be messaged about.`;
|
|
451
|
-
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel } });
|
|
463
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address } });
|
|
452
464
|
}
|
|
453
465
|
else
|
|
454
|
-
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
466
|
+
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
455
467
|
}
|
|
456
468
|
if (!this.running && this.hasWork())
|
|
457
469
|
this.schedule(0);
|
|
@@ -535,8 +547,10 @@ export class Conversation {
|
|
|
535
547
|
/** Picks up where the conversation was: its history, events not yet handled, tools cut off. */
|
|
536
548
|
async load() {
|
|
537
549
|
const { store } = this.settings;
|
|
550
|
+
// A live arrival can attach its new address while persisted links are loading. Keep that proof.
|
|
538
551
|
for (const link of await this.settings.links(this.id))
|
|
539
|
-
this.
|
|
552
|
+
if (!this.channels.has(link.name))
|
|
553
|
+
this.link(link);
|
|
540
554
|
const history = await store.history(this.id);
|
|
541
555
|
this.saved = history.length;
|
|
542
556
|
this.history.push(...history);
|
|
@@ -559,8 +573,14 @@ export class Conversation {
|
|
|
559
573
|
this.note(`[Chinwag] ${task.tool} (${task.taskId}) was still running in the background when the agent restarted. It may or may not have finished; check before telling them it is done.`);
|
|
560
574
|
this.persist(() => store.removeTask(this.id, task.taskId));
|
|
561
575
|
}
|
|
562
|
-
for (const row of await store.openInbox(this.id))
|
|
563
|
-
|
|
576
|
+
for (const row of await store.openInbox(this.id)) {
|
|
577
|
+
// Already in the history (its row was not marked done): answered, so not answered again.
|
|
578
|
+
const p = row.payload;
|
|
579
|
+
if (row.kind === "message" && this.messageIds.get(p.id) === p.channel)
|
|
580
|
+
this.persist(() => store.doneInbox([row.id]));
|
|
581
|
+
else
|
|
582
|
+
this.enqueue(row);
|
|
583
|
+
}
|
|
564
584
|
const row = await store.conversation(this.id);
|
|
565
585
|
this.resumeTurn = row?.turnOpen === true;
|
|
566
586
|
if ((this.resumeTurn || this.hasWork()) && !this.running)
|
|
@@ -669,7 +689,7 @@ export class Conversation {
|
|
|
669
689
|
}
|
|
670
690
|
/** The channel of the person's latest message. */
|
|
671
691
|
currentChannel() {
|
|
672
|
-
return this.recentChannels.at(-1) ?? [...this.channels.keys()][0] ?? "chat";
|
|
692
|
+
return (!this.theirMessages ? this.promptDestination?.channel ?? this.owed.at(-1)?.channel : undefined) ?? this.recentChannels.at(-1) ?? [...this.channels.keys()][0] ?? "chat";
|
|
673
693
|
}
|
|
674
694
|
/** Move waiting messages and notes into the history; the person's messages are now read. */
|
|
675
695
|
absorb() {
|
|
@@ -774,7 +794,11 @@ export class Conversation {
|
|
|
774
794
|
const turns = this.turns;
|
|
775
795
|
this.resumeTurn = false;
|
|
776
796
|
this.theirMessages = false;
|
|
797
|
+
this.promptDestination = undefined;
|
|
777
798
|
this.settings.emit(this.id, "turn.start", {});
|
|
799
|
+
this.signInPrompts.clear();
|
|
800
|
+
this.authBlocked.clear();
|
|
801
|
+
this.authPromptsSent.clear();
|
|
778
802
|
this.persist(() => store.updateConversation(this.id, { turnOpen: true, turns }));
|
|
779
803
|
this.absorb();
|
|
780
804
|
// A turn the person started (as against a background result, a scheduled message or a nudge).
|
|
@@ -812,11 +836,21 @@ export class Conversation {
|
|
|
812
836
|
if (this.closed)
|
|
813
837
|
return;
|
|
814
838
|
// The channels can grow mid-turn (they wrote on another one), so each step offers what is there now.
|
|
839
|
+
// A business prompt is addressed to one session, not whichever address last wrote on that channel.
|
|
840
|
+
if (!this.theirMessages)
|
|
841
|
+
this.promptDestination = this.owed.at(-1) ?? this.promptDestination;
|
|
842
|
+
const destination = !this.theirMessages ? this.promptDestination : undefined;
|
|
843
|
+
if (destination?.address) {
|
|
844
|
+
const link = (await this.settings.links(this.id)).find(l => l.name === destination.channel && l.address === destination.address);
|
|
845
|
+
if (link)
|
|
846
|
+
this.link(link);
|
|
847
|
+
}
|
|
815
848
|
const views = this.views();
|
|
816
849
|
const several = views.length > 1;
|
|
817
850
|
this.sendTools = new Map(views.map((v) => [sendToolName(v.name), v.name]));
|
|
818
851
|
const system = [this.settings.instructions, talkInstructions(views, this.settings.talk), rules, nowLine(this.settings.now(), this.timeZone, this.settings.timeZone), context].filter(Boolean).join("\n\n");
|
|
819
|
-
const
|
|
852
|
+
const ownSpecs = this.settings.auth ? this.settings.specs.map((spec) => ({ ...spec, description: spec.description + ((this.settings.tools.get(spec.name)?.signIn ?? "required") === "required" ? " Requires sign-in." : "") })) : this.settings.specs;
|
|
853
|
+
const specs = [...ownSpecs, ...this.builtinSpecs, ...talkSpecs(views, this.settings.talk)];
|
|
820
854
|
const abort = new AbortController();
|
|
821
855
|
if (restarts < this.settings.maxRestarts)
|
|
822
856
|
this.stepAbort = abort;
|
|
@@ -914,7 +948,15 @@ export class Conversation {
|
|
|
914
948
|
for (const call of response.toolCalls) {
|
|
915
949
|
this.history.push({ role: "tool", toolCallId: call.id, content: results.get(call.id) ?? "" });
|
|
916
950
|
}
|
|
951
|
+
for (const [channel, prompt] of this.signInPrompts) {
|
|
952
|
+
if (this.authPromptsSent.has(channel))
|
|
953
|
+
continue;
|
|
954
|
+
await this.sendSignIn(channel, await prompt);
|
|
955
|
+
this.authPromptsSent.add(channel);
|
|
956
|
+
}
|
|
917
957
|
this.flush();
|
|
958
|
+
if (work.length && work.every((call) => this.authBlocked.has(call.id)) && !this.absorb())
|
|
959
|
+
break;
|
|
918
960
|
// Only messages: the turn is over unless one was refused or something new came in meanwhile, or
|
|
919
961
|
// this replies to their message without having used any tool: it gets one more step, in case it
|
|
920
962
|
// meant to act next (it said "getting your order" and has not asked for it yet). A tool
|
|
@@ -989,7 +1031,7 @@ export class Conversation {
|
|
|
989
1031
|
const input = parsed.data;
|
|
990
1032
|
const fields = Object.fromEntries(Object.keys(link.params ?? {}).flatMap((k) => (input[k] === undefined ? [] : [[k, input[k]]])));
|
|
991
1033
|
const hasFields = Object.keys(fields).length > 0;
|
|
992
|
-
if (several && !input.confirm && !this.recentChannels.includes(channel)) {
|
|
1034
|
+
if (several && !input.confirm && !this.recentChannels.includes(channel) && !this.owed.some(o => o.channel === channel) && this.promptDestination?.channel !== channel) {
|
|
993
1035
|
const last = link.lastMessageAt ? ` (last time ${ago(this.settings.now() - link.lastMessageAt)} ago)` : "";
|
|
994
1036
|
return refused(`Not sent: they have not written on ${channel} in their last 5 messages${last}. If you are sure, call ${call.name} again with confirm: true.`);
|
|
995
1037
|
}
|
|
@@ -1100,11 +1142,17 @@ export class Conversation {
|
|
|
1100
1142
|
}
|
|
1101
1143
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
1102
1144
|
async sendDirect(d) {
|
|
1145
|
+
// Settled already (by an earlier process, or another one running beside this): never sent twice.
|
|
1146
|
+
if (await this.directSettled(d)) {
|
|
1147
|
+
if (d.inbox !== undefined)
|
|
1148
|
+
this.persist(() => this.settings.store.doneInbox([d.inbox]));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1103
1151
|
const settle = (status, reason) => {
|
|
1104
1152
|
this.settle(d, status, reason);
|
|
1105
1153
|
this.flush([d]);
|
|
1106
1154
|
};
|
|
1107
|
-
const link = this.channels.get(d.channel);
|
|
1155
|
+
const link = d.address === undefined ? this.channels.get(d.channel) : (await this.settings.links(this.id)).find(l => l.name === d.channel && l.address === d.address);
|
|
1108
1156
|
if (!link)
|
|
1109
1157
|
return settle("failed", `no way to reach them on ${d.channel} right now`);
|
|
1110
1158
|
if (!d.template && !d.theyWrote && windowClosed(link, this.settings.now()))
|
|
@@ -1127,8 +1175,18 @@ export class Conversation {
|
|
|
1127
1175
|
}
|
|
1128
1176
|
if (covered)
|
|
1129
1177
|
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1178
|
+
// The same key in every process, so the channel's provider sends it once.
|
|
1179
|
+
const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
|
|
1180
|
+
const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : { type: "message", text: d.text ?? "", idempotencyKey };
|
|
1181
|
+
let failure = null;
|
|
1182
|
+
if (this.closed)
|
|
1183
|
+
return settle("failed", "this person was forgotten");
|
|
1184
|
+
try {
|
|
1185
|
+
await link.deliver(outbound);
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
failure = errorText(error);
|
|
1189
|
+
}
|
|
1132
1190
|
if (failure)
|
|
1133
1191
|
return settle("failed", failure);
|
|
1134
1192
|
if (d.template) {
|
|
@@ -1151,6 +1209,18 @@ export class Conversation {
|
|
|
1151
1209
|
this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
|
|
1152
1210
|
settle("sent");
|
|
1153
1211
|
}
|
|
1212
|
+
async directSettled(d) {
|
|
1213
|
+
const { store } = this.settings;
|
|
1214
|
+
if (d.nudge) {
|
|
1215
|
+
const status = (await store.nudge(d.nudge))?.status;
|
|
1216
|
+
return status !== undefined && !["queued", "held", "scheduled"].includes(status);
|
|
1217
|
+
}
|
|
1218
|
+
if (d.run && d.schedule) {
|
|
1219
|
+
const outcome = (await store.runs(d.schedule)).find((r) => r.id === d.run)?.outcome;
|
|
1220
|
+
return outcome !== undefined && outcome !== "queued" && outcome !== "held";
|
|
1221
|
+
}
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1154
1224
|
/**
|
|
1155
1225
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
1156
1226
|
* that do, from the start of one (never between a call and its result), after a line that says
|
|
@@ -1205,6 +1275,9 @@ export class Conversation {
|
|
|
1205
1275
|
else
|
|
1206
1276
|
live.push({ i, r: started });
|
|
1207
1277
|
});
|
|
1278
|
+
// Authentication is a prerequisite, never a detached background operation.
|
|
1279
|
+
await Promise.all(live.map(({ r }) => r.authorized));
|
|
1280
|
+
await Promise.all(live.filter(({ r }) => this.authBlocked.has(r.call.id)).map(({ r }) => r.outcome));
|
|
1208
1281
|
let firstDoneAt = null;
|
|
1209
1282
|
for (;;) {
|
|
1210
1283
|
for (const { i, r } of live) {
|
|
@@ -1274,19 +1347,99 @@ export class Conversation {
|
|
|
1274
1347
|
}
|
|
1275
1348
|
this.settings.emit(this.id, "tool.start", { callId: call.id, tool: call.name, input: parsed.data });
|
|
1276
1349
|
const key = createHash("sha256").update(`${this.id}\u0000${this.turns}\u0000${call.name}\u0000${input}`).digest("hex").slice(0, 32);
|
|
1277
|
-
const
|
|
1350
|
+
const identityContext = { conversationId: this.id, person: this.personKey, key: this.personKey.startsWith("key:") ? this.personKey.slice(4) : null, channel: this.currentChannel(), address: this.channels.get(this.currentChannel())?.address, callId: call.id, idempotencyKey: key, signal: this.lifetime.signal };
|
|
1351
|
+
const credentials = this.settings.credentialProvider?.(identityContext) ?? this.settings.credentials;
|
|
1352
|
+
let context = toolContext(identityContext, credentials);
|
|
1353
|
+
let authorize;
|
|
1354
|
+
const authorized = new Promise(resolve => { authorize = resolve; });
|
|
1278
1355
|
const r = {
|
|
1279
1356
|
call,
|
|
1280
1357
|
input,
|
|
1281
1358
|
startedAt: Date.now(),
|
|
1282
1359
|
limitMs: tool.deadlineMs ?? this.settings.deadlineMs,
|
|
1360
|
+
authorized,
|
|
1283
1361
|
outcome: Promise.resolve()
|
|
1284
|
-
.then(() =>
|
|
1362
|
+
.then(async () => {
|
|
1363
|
+
try {
|
|
1364
|
+
const auth = this.settings.auth;
|
|
1365
|
+
const signIn = tool.signIn ?? (this.builtins.has(call.name) ? "none" : "required");
|
|
1366
|
+
if (auth && signIn !== "none") {
|
|
1367
|
+
const identity = Object.freeze({ conversationId: this.id, person: context.person, key: context.key, channel: context.channel, address: this.channels.get(context.channel)?.address ?? "", tool: call.name, signal: this.lifetime.signal });
|
|
1368
|
+
let verified = !!context.key;
|
|
1369
|
+
if (verified && auth.verify) {
|
|
1370
|
+
try {
|
|
1371
|
+
verified = await this.authentication(() => auth.verify(identity));
|
|
1372
|
+
}
|
|
1373
|
+
catch {
|
|
1374
|
+
verified = false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (this.closed)
|
|
1378
|
+
return { error: "conversation_closed" };
|
|
1379
|
+
if (!verified && signIn === "required") {
|
|
1380
|
+
this.authBlocked.add(call.id);
|
|
1381
|
+
if (!this.signInPrompts.has(context.channel)) {
|
|
1382
|
+
const prompt = this.authentication(() => auth.signIn(identity)).then((challenge) => {
|
|
1383
|
+
if (!challenge.message?.trim())
|
|
1384
|
+
throw new Error("Empty sign-in challenge");
|
|
1385
|
+
if (challenge.url) {
|
|
1386
|
+
const url = new URL(challenge.url);
|
|
1387
|
+
if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
|
|
1388
|
+
throw new Error("Unsafe sign-in URL");
|
|
1389
|
+
}
|
|
1390
|
+
return challenge;
|
|
1391
|
+
}).catch(() => ({ message: "I couldn't start sign-in. Please try again." }));
|
|
1392
|
+
this.signInPrompts.set(context.channel, prompt);
|
|
1393
|
+
}
|
|
1394
|
+
this.settings.emit(this.id, "auth.required", { tool: call.name, channel: context.channel });
|
|
1395
|
+
return { error: "sign_in_required", executed: false };
|
|
1396
|
+
}
|
|
1397
|
+
if (!verified)
|
|
1398
|
+
context = toolContext({ ...context, key: null, person: `${context.channel}:${identity.address}` }, credentials);
|
|
1399
|
+
}
|
|
1400
|
+
if (this.closed)
|
|
1401
|
+
return { error: "conversation_closed" };
|
|
1402
|
+
return tool.run(parsed.data, context);
|
|
1403
|
+
}
|
|
1404
|
+
finally {
|
|
1405
|
+
r.startedAt = Date.now();
|
|
1406
|
+
authorize();
|
|
1407
|
+
}
|
|
1408
|
+
})
|
|
1285
1409
|
.then((output) => ({ ok: true, text: render(output) }), (error) => ({ ok: false, text: `Failed: ${error instanceof Error ? error.message : String(error)}` })),
|
|
1286
1410
|
};
|
|
1287
1411
|
void r.outcome.then((result) => (r.result = result));
|
|
1288
1412
|
return r;
|
|
1289
1413
|
}
|
|
1414
|
+
/** A failed or unresponsive auth service cannot leave a protected call hanging or let it execute. */
|
|
1415
|
+
async authentication(work) {
|
|
1416
|
+
let timer;
|
|
1417
|
+
try {
|
|
1418
|
+
return await Promise.race([Promise.resolve().then(work), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Sign-in timed out")), 15_000); })]);
|
|
1419
|
+
}
|
|
1420
|
+
finally {
|
|
1421
|
+
clearTimeout(timer);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
/** A login request is a real channel message, recorded as delivered, without another model call. */
|
|
1425
|
+
async sendSignIn(channel, prompt) {
|
|
1426
|
+
const link = this.channels.get(channel);
|
|
1427
|
+
if (!link)
|
|
1428
|
+
return;
|
|
1429
|
+
const outbound = fitMessage(link.info, { type: "message", text: prompt.message, authentication: true, ...(prompt.url ? { link: { label: "Sign in", url: prompt.url } } : {}) });
|
|
1430
|
+
const failure = await this.send(channel, outbound);
|
|
1431
|
+
if (failure) {
|
|
1432
|
+
this.undelivered(prompt.message, failure);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const callId = `auth_${randomUUID()}`;
|
|
1436
|
+
this.history.push({ role: "assistant", content: null, toolCalls: [{ id: callId, name: sendToolName(channel), arguments: JSON.stringify({ text: outbound.text, ...(outbound.link ? { link: outbound.link } : {}) }) }] });
|
|
1437
|
+
this.history.push({ role: "tool", toolCallId: callId, content: JSON.stringify({ delivered: true, channel, authentication: true }) });
|
|
1438
|
+
this.settings.emit(this.id, "message.out", { text: outbound.text, link: outbound.link, channel, authentication: true });
|
|
1439
|
+
this.sent();
|
|
1440
|
+
this.sentThisTurn.add(prompt.message.trim());
|
|
1441
|
+
await this.acknowledged(channel);
|
|
1442
|
+
}
|
|
1290
1443
|
/** The reply stops waiting for this tool: it carries on in the background and its result comes back as a note. */
|
|
1291
1444
|
detach(r, why) {
|
|
1292
1445
|
const { call, startedAt } = r;
|
|
@@ -1362,6 +1515,7 @@ export function transcriptOf(history, only) {
|
|
|
1362
1515
|
kind: "out",
|
|
1363
1516
|
channel: delivered.channel,
|
|
1364
1517
|
text: String(args.text ?? ""),
|
|
1518
|
+
...(delivered.authentication ? { authentication: true } : {}),
|
|
1365
1519
|
at: result.at,
|
|
1366
1520
|
...(typeof args.reply_to === "string" ? { replyTo: args.reply_to } : {}),
|
|
1367
1521
|
...(Array.isArray(args.buttons) && args.buttons.length ? { buttons: args.buttons } : {}),
|
package/dist/src/events.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface FlowEvent {
|
|
2
|
-
type: "message.in" | "message.arrived" | "message.out" | "turn.start" | "turn.context" | "turn.end" | "model.call" | "model.restarted" | "model.error" | "tool.start" | "tool.end" | "tool.detached" | "tool.repeated" | "task.done" | "skill.loaded" | "text.hidden" | "ack.required" | "reaction" | "status" | "message.undelivered" | "reaction.in" | "attachment.failed" | "keyword" | "message.duplicate" | "history.trimmed" | "template.missing";
|
|
2
|
+
type: "message.in" | "message.arrived" | "message.out" | "turn.start" | "turn.context" | "turn.end" | "model.call" | "model.restarted" | "model.error" | "tool.start" | "tool.end" | "tool.detached" | "tool.repeated" | "task.done" | "skill.loaded" | "text.hidden" | "ack.required" | "auth.required" | "reaction" | "status" | "message.undelivered" | "reaction.in" | "attachment.failed" | "keyword" | "message.duplicate" | "history.trimmed" | "template.missing";
|
|
3
3
|
conversationId: string;
|
|
4
4
|
at: number;
|
|
5
5
|
[detail: string]: unknown;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -5,10 +5,10 @@ export { nudgeHandler, peopleHandler, type NudgeHandlerOptions, type PeopleHandl
|
|
|
5
5
|
export { sqlite, type Database } from "./db.ts";
|
|
6
6
|
export { postgres, type PostgresPool } from "./postgres.ts";
|
|
7
7
|
export { jsonlSink, type EventSink, type FlowEvent, type ChinwagEvent } from "./events.ts";
|
|
8
|
-
export { openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
|
|
8
|
+
export { flowModel, flowModelURL, openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
|
|
9
9
|
export { Store, type StoreApi } from "./store.ts";
|
|
10
10
|
export type { AgentFiles } from "./skills.ts";
|
|
11
11
|
export type { TracesOptions } from "./traces.ts";
|
|
12
|
-
export { tool, toSpec, type Tool, type ToolContext } from "./tools.ts";
|
|
12
|
+
export { tool, toSpec, type ChatAuth, type ChatAuthContext, type CredentialProvider, type Credentials, type SignIn, type Tool, type ToolContext, type ToolHints } from "./tools.ts";
|
|
13
13
|
export type { Attachment, ChannelInfo, ChatMessage, Deliver, Incoming, ListRow, Media, Model, ModelRequest, ModelResponse, Outbound, PersonMessage, Template, TemplateButton, TemplateDefinition, ToolCall, ToolSpec, Transcribe } from "./types.ts";
|
|
14
14
|
export { z } from "zod";
|
package/dist/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { nudgeHandler, peopleHandler } from "./http.js";
|
|
|
5
5
|
export { sqlite } from "./db.js";
|
|
6
6
|
export { postgres } from "./postgres.js";
|
|
7
7
|
export { jsonlSink } from "./events.js";
|
|
8
|
-
export { openAICompatible } from "./model.js";
|
|
8
|
+
export { flowModel, flowModelURL, openAICompatible } from "./model.js";
|
|
9
9
|
export { Store } from "./store.js";
|
|
10
10
|
export { tool, toSpec } from "./tools.js";
|
|
11
11
|
export { z } from "zod";
|
package/dist/src/model.d.ts
CHANGED
|
@@ -12,6 +12,27 @@ export interface OpenAICompatibleOptions {
|
|
|
12
12
|
/** Its context window in tokens, so the history sent is kept within it. */
|
|
13
13
|
contextTokens?: number;
|
|
14
14
|
}
|
|
15
|
+
/** Flow's OpenAI-compatible model API, reached with FLOW_KEY. */
|
|
16
|
+
export declare const FLOW_MODEL_URL = "https://flow.engineer/llm/framework/v1";
|
|
17
|
+
/**
|
|
18
|
+
* Where FLOW_KEY is sent for the model: FLOW_MODEL_URL when set, else the origin of the Flow server the CLI
|
|
19
|
+
* signs in to (FLOW_FRAMEWORK_LOGIN_URL) with /llm/framework/v1, else flow.engineer's.
|
|
20
|
+
*/
|
|
21
|
+
export declare function flowModelURL(env?: Record<string, string | undefined>): string;
|
|
22
|
+
/** The .env names a model key may be on, in the order they are read. */
|
|
23
|
+
export declare const MODEL_KEY_NAMES: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* What to tell the owner when no model key is set, said the same way by `chat`, `npm start`,
|
|
26
|
+
* `init`, `setup` and `link`: one sentence, and `next` as a command (`nextLine` is how it is printed).
|
|
27
|
+
*/
|
|
28
|
+
export declare const NO_MODEL_KEY: {
|
|
29
|
+
readonly code: "no_model_key";
|
|
30
|
+
readonly message: `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${string} or ANTHROPIC_API_KEY= (an OpenRouter key is made at https://openrouter.ai/keys).` | `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${string} or FLOW_KEY= (an OpenRouter key is made at https://openrouter.ai/keys).` | `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${string} or GEMINI_API_KEY= (an OpenRouter key is made at https://openrouter.ai/keys).` | `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${string} or OPENAI_API_KEY= (an OpenRouter key is made at https://openrouter.ai/keys).` | `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${string} or OPENROUTER_API_KEY= (an OpenRouter key is made at https://openrouter.ai/keys).`;
|
|
31
|
+
readonly next: "npx @flow-engineer/flow setup";
|
|
32
|
+
readonly nextLine: "Next: run npx @flow-engineer/flow setup to get Flow's free trial credit, or add your own key.";
|
|
33
|
+
};
|
|
34
|
+
/** True when the environment names a model key with a value. */
|
|
35
|
+
export declare const hasModelKey: (env: Record<string, string | undefined>) => boolean;
|
|
15
36
|
/** Which key, address and model an omitted `apiKey` / `baseURL` resolve to. */
|
|
16
37
|
export declare function modelFromKeys(env: Record<string, string | undefined>): {
|
|
17
38
|
apiKey?: string;
|
|
@@ -19,3 +40,9 @@ export declare function modelFromKeys(env: Record<string, string | undefined>):
|
|
|
19
40
|
model: string;
|
|
20
41
|
};
|
|
21
42
|
export declare function openAICompatible(options?: OpenAICompatibleOptions): Model;
|
|
43
|
+
/**
|
|
44
|
+
* The model through Flow: FLOW_KEY (written by `flow setup`) at {@link flowModelURL}, the
|
|
45
|
+
* account's Flow trial credit paying. `model` is one Flow offers (default anthropic/claude-haiku-5.5;
|
|
46
|
+
* GET /models lists them). With no key it fails when made, with a next step.
|
|
47
|
+
*/
|
|
48
|
+
export declare function flowModel(options?: Omit<OpenAICompatibleOptions, "baseURL">): Model;
|
package/dist/src/model.js
CHANGED
|
@@ -1,20 +1,53 @@
|
|
|
1
1
|
// Models over the OpenAI-compatible chat API: OpenRouter by default; OpenAI, Gemini, Anthropic or a
|
|
2
|
-
// local server by changing the base URL
|
|
2
|
+
// local server by changing the base URL; or Flow, with the agent's FLOW_KEY that `flow setup` writes,
|
|
3
|
+
// which needs no provider key and spends the account's Flow trial credit.
|
|
3
4
|
import OpenAI from "openai";
|
|
4
|
-
|
|
5
|
+
import { setting } from "./names.js";
|
|
6
|
+
/** Flow's OpenAI-compatible model API, reached with FLOW_KEY. */
|
|
7
|
+
export const FLOW_MODEL_URL = "https://flow.engineer/llm/framework/v1";
|
|
8
|
+
/**
|
|
9
|
+
* Where FLOW_KEY is sent for the model: FLOW_MODEL_URL when set, else the origin of the Flow server the CLI
|
|
10
|
+
* signs in to (FLOW_FRAMEWORK_LOGIN_URL) with /llm/framework/v1, else flow.engineer's.
|
|
11
|
+
*/
|
|
12
|
+
export function flowModelURL(env = process.env) {
|
|
13
|
+
const named = env.FLOW_MODEL_URL?.trim();
|
|
14
|
+
if (named)
|
|
15
|
+
return named;
|
|
16
|
+
const login = URL.parse(setting("LOGIN_URL", env)?.trim() ?? "");
|
|
17
|
+
return login ? `${login.origin}/llm/framework/v1` : FLOW_MODEL_URL;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The first of these with a value is the agent's model. `MODEL` overrides that provider's default id.
|
|
21
|
+
* Flow's key is last, so a provider key the owner adds wins over the FLOW_KEY setup wrote.
|
|
22
|
+
*/
|
|
5
23
|
const KEYS = [
|
|
6
24
|
{ env: "OPENROUTER_API_KEY", baseURL: "https://openrouter.ai/api/v1", model: "anthropic/claude-haiku-5.5" },
|
|
7
25
|
{ env: "OPENAI_API_KEY", baseURL: "https://api.openai.com/v1", model: "gpt-6-luna" },
|
|
8
26
|
{ env: "ANTHROPIC_API_KEY", baseURL: "https://api.anthropic.com/v1/", model: "claude-haiku-5-5" },
|
|
9
27
|
{ env: "GEMINI_API_KEY", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", model: "gemini-3.8-flash" },
|
|
28
|
+
{ env: "FLOW_KEY", baseURL: FLOW_MODEL_URL, model: "anthropic/claude-haiku-5.5" },
|
|
10
29
|
];
|
|
30
|
+
/** The .env names a model key may be on, in the order they are read. */
|
|
31
|
+
export const MODEL_KEY_NAMES = KEYS.map((k) => k.env);
|
|
32
|
+
/**
|
|
33
|
+
* What to tell the owner when no model key is set, said the same way by `chat`, `npm start`,
|
|
34
|
+
* `init`, `setup` and `link`: one sentence, and `next` as a command (`nextLine` is how it is printed).
|
|
35
|
+
*/
|
|
36
|
+
export const NO_MODEL_KEY = {
|
|
37
|
+
code: "no_model_key",
|
|
38
|
+
message: `This agent has no model key: npx @flow-engineer/flow setup writes FLOW_KEY, the agent's Flow key (with the account's free trial model credit), into .env in the agent's folder, or put your own on one of the lines ${KEYS.slice(0, -2).map((k) => `${k.env}=`).join(", ")} or ${KEYS.at(-2).env}= (an OpenRouter key is made at https://openrouter.ai/keys).`,
|
|
39
|
+
next: "npx @flow-engineer/flow setup",
|
|
40
|
+
nextLine: "Next: run npx @flow-engineer/flow setup to get Flow's free trial credit, or add your own key.",
|
|
41
|
+
};
|
|
42
|
+
/** True when the environment names a model key with a value. */
|
|
43
|
+
export const hasModelKey = (env) => KEYS.some((k) => Boolean(env[k.env]?.trim()));
|
|
11
44
|
/** Which key, address and model an omitted `apiKey` / `baseURL` resolve to. */
|
|
12
45
|
export function modelFromKeys(env) {
|
|
13
46
|
const named = env.MODEL?.trim();
|
|
14
47
|
for (const provider of KEYS) {
|
|
15
48
|
const apiKey = env[provider.env]?.trim();
|
|
16
49
|
if (apiKey)
|
|
17
|
-
return { apiKey, baseURL: provider.baseURL, model: named || provider.model };
|
|
50
|
+
return { apiKey, baseURL: provider.env === "FLOW_KEY" ? flowModelURL(env) : provider.baseURL, model: named || provider.model };
|
|
18
51
|
}
|
|
19
52
|
return { baseURL: KEYS[0].baseURL, model: named || KEYS[0].model };
|
|
20
53
|
}
|
|
@@ -24,8 +57,9 @@ export function openAICompatible(options = {}) {
|
|
|
24
57
|
const baseURL = options.baseURL ?? fromEnv?.baseURL ?? KEYS[0].baseURL;
|
|
25
58
|
const apiKey = options.apiKey ?? fromEnv?.apiKey ?? (process.env.OPENROUTER_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim());
|
|
26
59
|
// A server at a base URL you name (a local one) may need no key; the client still sends one.
|
|
60
|
+
// Code that builds its model itself can also pass apiKey, or baseURL for a server that needs none.
|
|
27
61
|
if (!apiKey && options.baseURL === undefined) {
|
|
28
|
-
throw new Error(
|
|
62
|
+
throw Object.assign(new Error(`${NO_MODEL_KEY.message}\n${NO_MODEL_KEY.nextLine}`), { code: NO_MODEL_KEY.code, next: NO_MODEL_KEY.next });
|
|
29
63
|
}
|
|
30
64
|
const client = new OpenAI({
|
|
31
65
|
apiKey: apiKey || "none",
|
|
@@ -33,18 +67,26 @@ export function openAICompatible(options = {}) {
|
|
|
33
67
|
maxRetries: 1,
|
|
34
68
|
});
|
|
35
69
|
const model = options.model?.trim() || fromEnv?.model || KEYS[0].model;
|
|
70
|
+
// Flow reads PDFs for every model it offers, as OpenRouter (behind it) does.
|
|
71
|
+
const readsPdfs = baseURL.startsWith("https://openrouter.ai/") || baseURL === flowModelURL(process.env);
|
|
36
72
|
return {
|
|
37
|
-
reads: { images: options.images ?? true, pdfs: options.pdfs ??
|
|
73
|
+
reads: { images: options.images ?? true, pdfs: options.pdfs ?? readsPdfs },
|
|
38
74
|
...(options.contextTokens ? { contextTokens: options.contextTokens } : {}),
|
|
39
75
|
async complete(request) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
let completion;
|
|
77
|
+
try {
|
|
78
|
+
completion = await client.chat.completions.create({
|
|
79
|
+
model,
|
|
80
|
+
temperature: options.temperature,
|
|
81
|
+
messages: [{ role: "system", content: request.system }, ...request.messages.map(toWire)],
|
|
82
|
+
...(request.tools.length
|
|
83
|
+
? { tools: request.tools.map((t) => ({ type: "function", function: t })) }
|
|
84
|
+
: {}),
|
|
85
|
+
}, { signal: request.signal });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw flowError(error) ?? error;
|
|
89
|
+
}
|
|
48
90
|
const message = completion.choices[0]?.message;
|
|
49
91
|
return {
|
|
50
92
|
text: message?.content?.trim() || null,
|
|
@@ -57,6 +99,36 @@ export function openAICompatible(options = {}) {
|
|
|
57
99
|
},
|
|
58
100
|
};
|
|
59
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The model through Flow: FLOW_KEY (written by `flow setup`) at {@link flowModelURL}, the
|
|
104
|
+
* account's Flow trial credit paying. `model` is one Flow offers (default anthropic/claude-haiku-5.5;
|
|
105
|
+
* GET /models lists them). With no key it fails when made, with a next step.
|
|
106
|
+
*/
|
|
107
|
+
export function flowModel(options = {}) {
|
|
108
|
+
const apiKey = options.apiKey?.trim() || process.env.FLOW_KEY?.trim();
|
|
109
|
+
if (!apiKey) {
|
|
110
|
+
throw Object.assign(new Error(`FLOW_KEY is not set: npx @flow-engineer/flow setup writes it into .env in the agent's folder.\nNext: run ${NO_MODEL_KEY.next}.`), { code: NO_MODEL_KEY.code, next: NO_MODEL_KEY.next });
|
|
111
|
+
}
|
|
112
|
+
return openAICompatible({ ...options, apiKey, baseURL: flowModelURL(process.env), model: options.model?.trim() || process.env.MODEL?.trim() || KEYS.at(-1).model });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A typed error from Flow's model API ({"error": {message, type, code, next}}: trial_credit_used,
|
|
116
|
+
* invalid_api_key, rate_limit_exceeded, ...) as an Error with its message and next step; undefined
|
|
117
|
+
* for any other error, which is thrown as it came.
|
|
118
|
+
*/
|
|
119
|
+
function flowError(error) {
|
|
120
|
+
if (!(error instanceof OpenAI.APIError))
|
|
121
|
+
return undefined;
|
|
122
|
+
const body = error.error;
|
|
123
|
+
if (typeof body?.next !== "string" || !body.next || typeof body.message !== "string")
|
|
124
|
+
return undefined;
|
|
125
|
+
const sentence = body.message.trim();
|
|
126
|
+
return Object.assign(new Error(`${sentence}\nNext: ${body.next}`, { cause: error }), {
|
|
127
|
+
code: typeof body.code === "string" ? body.code : `http_${error.status}`,
|
|
128
|
+
next: body.next,
|
|
129
|
+
status: error.status,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
60
132
|
function toWire(message) {
|
|
61
133
|
if (message.role === "tool")
|
|
62
134
|
return { role: "tool", tool_call_id: message.toolCallId, content: message.content };
|
package/dist/src/skills.d.ts
CHANGED
|
@@ -9,4 +9,8 @@ export type AgentFiles = Record<string, string>;
|
|
|
9
9
|
export declare function readAgentFolder(dir: string): AgentFiles;
|
|
10
10
|
export declare function loadSkills(files: AgentFiles): Skill[];
|
|
11
11
|
export declare function loadInstructions(files: AgentFiles, skills: Skill[], inline?: boolean): string;
|
|
12
|
+
/** What an MCP server tells a host about its skills when it connects (chinwag/mcp). */
|
|
13
|
+
export declare function mcpInstructions(skills: Skill[]): string | undefined;
|
|
14
|
+
/** The MCP server's way to read a skill: a tool, since every host calls tools. */
|
|
15
|
+
export declare function readSkillTool(skills: Skill[]): Tool;
|
|
12
16
|
export declare function skillTool(skills: Skill[], onLoad: (conversationId: string, name: string) => void): Tool;
|
package/dist/src/skills.js
CHANGED
|
@@ -51,10 +51,33 @@ export function loadInstructions(files, skills, inline = false) {
|
|
|
51
51
|
const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
52
52
|
return `${base}\n\n## Skills\nLoad a skill with load_skill before doing what it covers; call it together with the first other tool you need, not on its own.\n${list}`;
|
|
53
53
|
}
|
|
54
|
+
/** What an MCP server tells a host about its skills when it connects (chinwag/mcp). */
|
|
55
|
+
export function mcpInstructions(skills) {
|
|
56
|
+
if (!skills.length)
|
|
57
|
+
return undefined;
|
|
58
|
+
const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
59
|
+
return `This server's skills are rules for one kind of request each. Read a skill with read_skill before doing what it covers.\n${list}`;
|
|
60
|
+
}
|
|
61
|
+
/** The MCP server's way to read a skill: a tool, since every host calls tools. */
|
|
62
|
+
export function readSkillTool(skills) {
|
|
63
|
+
const names = skills.map((s) => s.name);
|
|
64
|
+
return tool({
|
|
65
|
+
name: "read_skill",
|
|
66
|
+
title: "Read a skill",
|
|
67
|
+
description: "Read one of this server's skills in full. Read it before doing what its description covers.",
|
|
68
|
+
input: z.object({ name: z.enum(names) }),
|
|
69
|
+
signIn: "none",
|
|
70
|
+
hints: { readOnly: true, openWorld: false },
|
|
71
|
+
run({ name }) {
|
|
72
|
+
return skills.find((s) => s.name === name)?.body ?? `No skill named ${name}.`;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
54
76
|
export function skillTool(skills, onLoad) {
|
|
55
77
|
const names = skills.map((s) => s.name);
|
|
56
78
|
return tool({
|
|
57
79
|
name: "load_skill",
|
|
80
|
+
signIn: "none",
|
|
58
81
|
description: "Read a skill's full instructions. Use it before doing what the skill covers.",
|
|
59
82
|
input: z.object({ name: z.enum(names) }),
|
|
60
83
|
deadlineMs: 60_000,
|
package/dist/src/store.d.ts
CHANGED
|
@@ -95,6 +95,8 @@ export declare class Store {
|
|
|
95
95
|
allConversations(): Promise<ConversationRow[]>;
|
|
96
96
|
createConversation(id: string, personKey: string): Promise<void>;
|
|
97
97
|
addIdentity(conversationId: string, channel: string, address: string, verifiedBy: string): Promise<void>;
|
|
98
|
+
/** Bind verified login proof; an identity already owned by another account cannot move. */
|
|
99
|
+
linkIdentity(conversationId: string, channel: string, address: string): Promise<boolean>;
|
|
98
100
|
touchIdentity(channel: string, address: string, at: number): Promise<void>;
|
|
99
101
|
setIdentityVia(channel: string, address: string, via: Via): Promise<void>;
|
|
100
102
|
/**
|