toolroll 0.9.16 β 0.9.17
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/CHANGELOG.md +17 -0
- package/dist/assignment-brief.js +11 -4
- package/dist/chat-actions.d.ts +7 -0
- package/dist/chat-actions.js +40 -4
- package/dist/chat-ask.js +29 -15
- package/dist/chat-channel.js +3 -1
- package/dist/chat-delivery-state.d.ts +2 -0
- package/dist/chat-delivery.d.ts +3 -0
- package/dist/chat-delivery.js +31 -16
- package/dist/chat-warmth.d.ts +21 -0
- package/dist/chat-warmth.js +36 -0
- package/dist/discord-api.d.ts +1 -1
- package/dist/discord-api.js +1 -1
- package/dist/discord-chat.d.ts +3 -2
- package/dist/discord-chat.js +18 -3
- package/dist/lead-about.d.ts +35 -0
- package/dist/lead-about.js +62 -0
- package/dist/lead-capabilities.d.ts +64 -0
- package/dist/lead-capabilities.js +199 -0
- package/dist/lead-commitments.js +13 -7
- package/dist/lead-context.d.ts +5 -3
- package/dist/lead-context.js +29 -7
- package/dist/lead-follow.d.ts +1 -1
- package/dist/lead-follow.js +3 -2
- package/dist/lead-identity.js +1 -1
- package/dist/lead-people.d.ts +55 -0
- package/dist/lead-people.js +128 -0
- package/dist/lead-voice.d.ts +3 -2
- package/dist/lead-voice.js +4 -3
- package/dist/mate-cli.d.ts +3 -0
- package/dist/mate-cli.js +15 -14
- package/dist/mate-contract.d.ts +1 -1
- package/dist/mate-contract.js +6 -4
- package/dist/mate-progress.js +2 -0
- package/dist/mate-tools.d.ts +8 -0
- package/dist/mate-tools.js +112 -6
- package/dist/mate.d.ts +26 -1
- package/dist/mate.js +89 -25
- package/dist/operate.js +2 -0
- package/dist/phase-routing.d.ts +1 -1
- package/dist/provider-auth.d.ts +4 -0
- package/dist/provider-auth.js +1 -1
- package/dist/reply-shape.d.ts +96 -0
- package/dist/reply-shape.js +452 -0
- package/dist/serve.js +53 -28
- package/dist/slack-chat.d.ts +2 -0
- package/dist/slack-chat.js +20 -3
- package/dist/store.d.ts +12 -3
- package/dist/store.js +44 -9
- package/dist/team-runtime.js +1 -1
- package/dist/teams-chat.js +12 -1
- package/dist/telegram-mate.d.ts +2 -0
- package/dist/telegram-mate.js +27 -11
- package/dist/telegram-team.js +6 -3
- package/dist/telegram.js +1 -1
- package/dist/test-temp.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.17 β 2026-10-03
|
|
4
|
+
|
|
5
|
+
- **The lead checks before it promises.** One check covers agents (signed in
|
|
6
|
+
or not), workers, tools and skills, integrations and checks. It won't
|
|
7
|
+
propose work that needs one until it has looked. If something isn't ready,
|
|
8
|
+
it says so with the next step and a link.
|
|
9
|
+
- **Replies read like a person wrote them.** On every channel: no headers,
|
|
10
|
+
links with plain labels, no internal ids, and little bold. A reply that
|
|
11
|
+
says it's attaching something has to attach it. Errors are in plain words,
|
|
12
|
+
and you see a π and typing while it works.
|
|
13
|
+
- **It knows you and your people.** "What your lead knows about you" is a
|
|
14
|
+
short list you confirm line by line, editable in Settings β Lead. The lead
|
|
15
|
+
also keeps an index of the people, teammates and team chats you work with,
|
|
16
|
+
and looks a person up before answering about them.
|
|
17
|
+
- Promises made on Slack, Discord and Teams are delivered there. Long
|
|
18
|
+
Telegram replies split safely.
|
|
19
|
+
|
|
3
20
|
## 0.9.16 β 2026-10-03
|
|
4
21
|
|
|
5
22
|
- **Name your lead.** Settings β Lead takes a name and a short persona. The
|
package/dist/assignment-brief.js
CHANGED
|
@@ -7,13 +7,20 @@ import { publicChatText } from "./chat-display.js";
|
|
|
7
7
|
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
8
8
|
const MAX_BYTES = 16_000;
|
|
9
9
|
const RECENT_MS = 7 * 24 * 60 * 60_000;
|
|
10
|
-
/** A
|
|
11
|
-
const SENTENCE_END = /
|
|
10
|
+
/** A full stop, question or exclamation mark followed by a new sentence. */
|
|
11
|
+
const SENTENCE_END = /[.!?](?=\s+["'β(]?([\p{Lu}\p{N}]))/gu;
|
|
12
|
+
/** An abbreviation's full stop ("e.g.", "E.g.", "etc."), in any case; "No." only before a number ("No. 5"). */
|
|
13
|
+
const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|incl|mr|mrs|ms|dr|st)$/i;
|
|
12
14
|
/** A reason's first sentence, or its first line. */
|
|
13
15
|
export function firstSentenceOf(text) {
|
|
14
16
|
const line = text.split("\n")[0].trim();
|
|
15
|
-
const end
|
|
16
|
-
|
|
17
|
+
for (const end of line.matchAll(SENTENCE_END)) {
|
|
18
|
+
const before = line.slice(0, end.index);
|
|
19
|
+
if (end[0] === "." && (ABBREVIATION.test(before) || /\bno$/i.test(before) && /\p{N}/u.test(end[1])))
|
|
20
|
+
continue;
|
|
21
|
+
return line.slice(0, end.index + 1);
|
|
22
|
+
}
|
|
23
|
+
return line;
|
|
17
24
|
}
|
|
18
25
|
/** A project's active decisions, newest first: each one's title and its reason in one line (first sentence or
|
|
19
26
|
* line). Memory absent on an older database reads as no decisions. */
|
package/dist/chat-actions.d.ts
CHANGED
|
@@ -196,6 +196,11 @@ export declare const CHAT_ACTIONS: {
|
|
|
196
196
|
readonly protected: false;
|
|
197
197
|
readonly password: false;
|
|
198
198
|
};
|
|
199
|
+
readonly lead_about_you: {
|
|
200
|
+
readonly label: "Remember";
|
|
201
|
+
readonly protected: false;
|
|
202
|
+
readonly password: false;
|
|
203
|
+
};
|
|
199
204
|
readonly scope_approve: {
|
|
200
205
|
readonly label: "Approve work";
|
|
201
206
|
readonly protected: true;
|
|
@@ -222,6 +227,8 @@ export type ChatAction = keyof typeof CHAT_ACTIONS;
|
|
|
222
227
|
* yes/cancel challenge instead of the console's secure screen. Password
|
|
223
228
|
* actions and long or redacted terms never qualify. */
|
|
224
229
|
export declare const CHALLENGE_ACTIONS: ReadonlySet<ChatAction>;
|
|
230
|
+
/** Actions about the person themselves, in no project: their card carries no project (repo ""). */
|
|
231
|
+
export declare const OWNER_ACTIONS: ReadonlySet<ChatAction>;
|
|
225
232
|
export declare function isChatAction(value: unknown): value is ChatAction;
|
|
226
233
|
export type SharedAction = {
|
|
227
234
|
operation: ChatAction;
|
package/dist/chat-actions.js
CHANGED
|
@@ -32,6 +32,7 @@ import { kitInstalled, kitOf, KITS, setUpKitNow } from "./kits.js";
|
|
|
32
32
|
import { starterFlowOf, starterOf, startersFor, starterTerms, STARTER_FLOWS, switchOnStarter } from "./flow-starters.js";
|
|
33
33
|
import { answerTeammateQuestion } from "./teammate-work.js";
|
|
34
34
|
import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleWords, setToolRules } from "./teammate-tools.js";
|
|
35
|
+
import { aboutYouOf, checkAboutYouLine, saveAboutYou, withAboutYouLine } from "./lead-about.js";
|
|
35
36
|
/** A tool from the lead's card: a common tool by its id, or the operator's own program or address. */
|
|
36
37
|
function toolSpecFromRequest(input) {
|
|
37
38
|
if (input["catalog"] !== undefined) {
|
|
@@ -107,6 +108,8 @@ export const CHAT_ACTIONS = {
|
|
|
107
108
|
kit_setup: { label: "Set up kit", protected: false, password: false },
|
|
108
109
|
decision_record: { label: "Record decision", protected: false, password: false },
|
|
109
110
|
decision_retire: { label: "Retire decision", protected: false, password: false },
|
|
111
|
+
// What the owner's own lead knows about them: theirs alone, in no project (lead-about.ts).
|
|
112
|
+
lead_about_you: { label: "Remember", protected: false, password: false },
|
|
110
113
|
scope_approve: { label: "Approve work", protected: true, password: true },
|
|
111
114
|
result_accept: { label: "Accept and finish", protected: true, password: false },
|
|
112
115
|
task_cancel: { label: "Cancel task", protected: true, password: false },
|
|
@@ -116,6 +119,8 @@ export const CHAT_ACTIONS = {
|
|
|
116
119
|
* yes/cancel challenge instead of the console's secure screen. Password
|
|
117
120
|
* actions and long or redacted terms never qualify. */
|
|
118
121
|
export const CHALLENGE_ACTIONS = new Set(["result_accept"]);
|
|
122
|
+
/** Actions about the person themselves, in no project: their card carries no project (repo ""). */
|
|
123
|
+
export const OWNER_ACTIONS = new Set(["lead_about_you"]);
|
|
119
124
|
export function isChatAction(value) {
|
|
120
125
|
return typeof value === "string" && Object.hasOwn(CHAT_ACTIONS, value);
|
|
121
126
|
}
|
|
@@ -160,19 +165,21 @@ export const CHAT_ACTION_FIELDS = {
|
|
|
160
165
|
kit_setup: ["repo", "kit"],
|
|
161
166
|
decision_record: ["repo", "claim", "why", "supersedes", "source"],
|
|
162
167
|
decision_retire: ["repo", "decision", "reason"],
|
|
168
|
+
lead_about_you: ["line", "replaces"],
|
|
163
169
|
scope_approve: ["task"],
|
|
164
170
|
result_accept: ["task", "run"],
|
|
165
171
|
task_cancel: ["task"],
|
|
166
172
|
task_resume: ["task", "run"],
|
|
167
173
|
};
|
|
168
174
|
const nonceHash = (value) => createHash("sha256").update(value).digest("hex");
|
|
169
|
-
function requireActor(store, who, repo) {
|
|
175
|
+
function requireActor(store, who, repo, operation) {
|
|
170
176
|
if (!store.schemaCurrent())
|
|
171
177
|
throw Error("The running build changed. Reload before proposing an action.");
|
|
178
|
+
// An action about the person themselves names no project; every other one names one they can reach.
|
|
179
|
+
const owned = repo === "" && operation !== undefined && OWNER_ACTIONS.has(operation);
|
|
172
180
|
if (!isVerifiedApprover(who) ||
|
|
173
181
|
!reproveApprover(store, who).ok ||
|
|
174
|
-
!who.repos.includes(repo) ||
|
|
175
|
-
!store.accountCanAccess(who.name, repo))
|
|
182
|
+
(!owned && (!who.repos.includes(repo) || !store.accountCanAccess(who.name, repo))))
|
|
176
183
|
throw Error("This project is outside your current access.");
|
|
177
184
|
}
|
|
178
185
|
function text(input, key, cap, optional = false) {
|
|
@@ -267,6 +274,28 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
|
|
|
267
274
|
const allowed = CHAT_ACTION_FIELDS[operation];
|
|
268
275
|
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
|
269
276
|
throw Error("This action contains an unsupported field.");
|
|
277
|
+
if (operation === "lead_about_you") {
|
|
278
|
+
// The owner's own note: the card shows the line, and the line it replaces beside it.
|
|
279
|
+
const checked = checkAboutYouLine(input["line"]);
|
|
280
|
+
if (!checked.ok)
|
|
281
|
+
throw Error(checked.message);
|
|
282
|
+
const lines = aboutYouOf(store, who.name);
|
|
283
|
+
const replaces = input["replaces"] === undefined || input["replaces"] === 0 ? 0 : integer(input, "replaces");
|
|
284
|
+
const next = withAboutYouLine(lines, checked.line, replaces);
|
|
285
|
+
if (!next.ok)
|
|
286
|
+
throw Error(next.message);
|
|
287
|
+
requireActor(store, who, "", operation);
|
|
288
|
+
const request = { line: checked.line, ...(replaces === 0 ? {} : { replaces }) };
|
|
289
|
+
const terms = replaces === 0
|
|
290
|
+
? [checked.line, "Your lead keeps this about you in every project. Change it in Settings β Lead."]
|
|
291
|
+
: [`Was: ${lines[replaces - 1]}`, `Now: ${checked.line}`, "Replaces that line in what your lead knows about you. Change it in Settings β Lead."];
|
|
292
|
+
// An added line goes on the end, so it holds whatever else changed; a replacing card holds only while the line
|
|
293
|
+
// it replaces is still that line.
|
|
294
|
+
const state = replaces === 0 ? {} : { was: lines[replaces - 1] };
|
|
295
|
+
const title = replaces === 0 ? "Remember about you" : "Update what your lead knows about you";
|
|
296
|
+
const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
|
|
297
|
+
return { operation, request, repo: "", title, terms, stamp, state };
|
|
298
|
+
}
|
|
270
299
|
const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("teammate_") || operation.startsWith("kit_")
|
|
271
300
|
? null
|
|
272
301
|
: text(input, "task", 64);
|
|
@@ -954,7 +983,7 @@ function savedActionContext(store, who, id, state) {
|
|
|
954
983
|
(!shared && turn?.session !== session.id) ||
|
|
955
984
|
turn?.state !== "answered")
|
|
956
985
|
throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
|
|
957
|
-
requireActor(store, who, payload.repo);
|
|
986
|
+
requireActor(store, who, payload.repo, payload.operation);
|
|
958
987
|
return payload;
|
|
959
988
|
}
|
|
960
989
|
export function mintSharedActionReview(store, who, id, root, now) {
|
|
@@ -1013,6 +1042,13 @@ export function executeSharedAction(store, who, id, payload, now, options) {
|
|
|
1013
1042
|
return store.savepoint(() => {
|
|
1014
1043
|
const task = typeof req["task"] === "string" ? req["task"] : null, repo = payload.repo, actor = who.name;
|
|
1015
1044
|
let taskId = task;
|
|
1045
|
+
if (payload.operation === "lead_about_you") {
|
|
1046
|
+
const next = withAboutYouLine(aboutYouOf(store, actor), String(req["line"]), Number(req["replaces"] ?? 0));
|
|
1047
|
+
if (!next.ok)
|
|
1048
|
+
throw Error(next.message);
|
|
1049
|
+
saveAboutYou(store, actor, next.lines, now);
|
|
1050
|
+
return { ok: true, taskId: null, said: req["replaces"] === undefined ? "Your lead will remember this." : "Updated what your lead knows about you.", href: "/settings/lead" };
|
|
1051
|
+
}
|
|
1016
1052
|
if (payload.operation === "skill_import")
|
|
1017
1053
|
importSkill(store, repo, actor, req["files"], "Chat import", now);
|
|
1018
1054
|
else if (payload.operation === "skill_test")
|
package/dist/chat-ask.js
CHANGED
|
@@ -21,12 +21,21 @@ export function chatAskButtons(state, part, now) {
|
|
|
21
21
|
})
|
|
22
22
|
.filter((one) => one !== null);
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/** How old a part may be and still be sent: older ones are dropped undelivered (slack-chat.ts, discord-chat.ts,
|
|
25
|
+
* teams-chat.ts), so an edit to one would never land. */
|
|
26
|
+
const EDIT_WINDOW_MS = 86_400_000;
|
|
27
|
+
/** Show the question again with what happened, and without its buttons: one edit, under the part's own time. A question
|
|
28
|
+
* too old to edit gets the line once as a new message instead. */
|
|
29
|
+
function repaint(state, part, event, line, now) {
|
|
30
|
+
const row = state.prepare("SELECT payload,created FROM chat_part WHERE id=?").get(part);
|
|
31
|
+
if (row === undefined || new Date(String(row["created"])).getTime() + EDIT_WINDOW_MS <= now.getTime()) {
|
|
32
|
+
state.plan(event.id, [{ text: line }], now);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const before = JSON.parse(String(row["payload"]));
|
|
28
36
|
const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.channel === undefined ? {} : { channel: before.channel }) };
|
|
29
37
|
state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(JSON.stringify(content), part);
|
|
38
|
+
state.finish(event.id);
|
|
30
39
|
}
|
|
31
40
|
/** A tapped ask button, inside the action's transaction; false when the token isn't one. */
|
|
32
41
|
export function applyChatAskTap(options, event, binding, token, now) {
|
|
@@ -41,17 +50,23 @@ export function applyChatAskTap(options, event, binding, token, now) {
|
|
|
41
50
|
return true;
|
|
42
51
|
}
|
|
43
52
|
const turn = Number(action["turn"]), part = Number(action["part"]);
|
|
44
|
-
|
|
53
|
+
// Only the person the lead asked answers it: anyone else's tap changes nothing and says nothing.
|
|
54
|
+
const asker = store.getMateTurn(turn)?.approver;
|
|
55
|
+
if (asker !== undefined && asker !== binding.approver) {
|
|
56
|
+
state.finish(event.id);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const found = asker === undefined ? { state: "expired" } : store.mateAskState(turn, now);
|
|
45
60
|
const expired = found.state === "expired" || String(action["expires"]) <= now.toISOString();
|
|
46
|
-
|
|
61
|
+
// A question already settled was shown so once (or is being): a later tap says nothing more.
|
|
62
|
+
if (action["consumed"] !== null) {
|
|
63
|
+
state.finish(event.id);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (found.state !== "open" || expired) {
|
|
47
67
|
state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
state.finish(event.id);
|
|
51
|
-
}
|
|
52
|
-
// A question this old can no longer be edited in place: the answer to the tap is a new message.
|
|
53
|
-
else
|
|
54
|
-
say("This question expired. If it still matters, send your answer as a message.");
|
|
68
|
+
// The question is shown again without its buttons, so a later tap has nothing to press.
|
|
69
|
+
repaint(state, part, event, found.state === "answered" ? "This question was already answered." : "This question expired. If it still matters, send your answer as a message.", now);
|
|
55
70
|
return true;
|
|
56
71
|
}
|
|
57
72
|
const ask = found.ask;
|
|
@@ -71,7 +86,6 @@ export function applyChatAskTap(options, event, binding, token, now) {
|
|
|
71
86
|
channel: event.channel, member: event.member, ts: event.ts, thread: event.thread,
|
|
72
87
|
payload: JSON.stringify({ text: option, originalLength: option.length }), created: now.toISOString(),
|
|
73
88
|
});
|
|
74
|
-
repaint(state, part, event, `You chose: ${option}
|
|
75
|
-
state.finish(event.id);
|
|
89
|
+
repaint(state, part, event, `You chose: ${option}`, now);
|
|
76
90
|
return true;
|
|
77
91
|
}
|
package/dist/chat-channel.js
CHANGED
|
@@ -513,8 +513,10 @@ export const CHAT_ACTION_PARITY = {
|
|
|
513
513
|
propose_flow: { support: "direct", how: "Creates or changes a flow, adds, moves, approves, sends back or cancels its cards, comments on them (@name pings that person), sets their owner, follows them, saves the project's scripts, and adds, pauses or removes its triggers, through the shared confirm door; a long drawing opens the secure review. Work a card files is an ordinary task under the usual approvals.", gap: "The flow canvas itself is on the console; the phone confirms cards but draws nothing. Webhook addresses and the Linear key are set on the console's Triggers panel." },
|
|
514
514
|
commit_to: { support: "direct", how: "Records what the lead promised to follow up on (a task, attempt, check or time; 7 days at most). The follow pass says one line when it is met, in the shared conversation and on the chat the promise was made on.", gap: null },
|
|
515
515
|
release_commitment: { support: "direct", how: "Stops following up on one of the person's open promises, with the reason.", gap: null },
|
|
516
|
-
remember: { support: "direct", how: "Proposes a correction or lasting preference as a decision
|
|
516
|
+
remember: { support: "direct", how: "Proposes a correction or lasting preference as a decision, project instruction or about-you card, through the shared confirm door; an about-you card shows the line it replaces.", gap: null },
|
|
517
|
+
get_person: { support: "direct", how: "Read during a turn: one person, AI teammate or team chat in full, with their open tasks.", gap: null },
|
|
517
518
|
get_integrations: { support: "direct", how: "Read during a turn: which chat apps, email, GitHub, project tools and monitoring are Connected, Not set up or Broken, as Settings β Integrations shows them.", gap: null },
|
|
519
|
+
get_capabilities: { support: "direct", how: "Read during a turn: which agents are signed in, which workers are online, which tools and skills work, which integrations are connected and what checks the project runs, each with its next step and settings link.", gap: null },
|
|
518
520
|
ask_owner: { support: "direct", how: "One question with 2-4 options and Something else, drawn as buttons under the reply; the tapped option is sent as the person's next message.", gap: null },
|
|
519
521
|
search_project_memory: { support: "direct", how: "Read during a turn: one search over decisions, references, lessons and the conversations the person may read.", gap: null },
|
|
520
522
|
list_tasks: { support: "direct", how: "Read during a turn.", gap: null },
|
|
@@ -100,6 +100,8 @@ export type ChatContent = {
|
|
|
100
100
|
turn: number;
|
|
101
101
|
options: string[];
|
|
102
102
|
};
|
|
103
|
+
/** The lead's own reply, already shaped (reply-shape.ts): the channel renders its bold anchors and labelled links in its own format. */
|
|
104
|
+
voice?: true;
|
|
103
105
|
};
|
|
104
106
|
export declare const chatHash: (text: string) => string;
|
|
105
107
|
export declare class ChatState {
|
package/dist/chat-delivery.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WarmHooks } from "./chat-warmth.js";
|
|
1
2
|
import { type DoorOptions } from "./mate-doors.js";
|
|
2
3
|
import { ChatState, type ChatBinding, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
|
|
3
4
|
import { type Store } from "./store.js";
|
|
@@ -20,6 +21,8 @@ export type ChatDeliveryOptions = {
|
|
|
20
21
|
clock?: () => Date;
|
|
21
22
|
partSize?: number;
|
|
22
23
|
maxProposal?: number;
|
|
24
|
+
/** The app's warm touches for one owner message (a π, typing), where the app and the bot's permissions allow them. */
|
|
25
|
+
warm?: (event: ChatEvent, binding: ChatBinding) => WarmHooks;
|
|
23
26
|
};
|
|
24
27
|
export declare const splitChatText: (text: string, size?: number) => string[];
|
|
25
28
|
export declare function channelAccess(options: ChatDeliveryOptions, binding: ChatBinding, ceiling?: string): Promise<string[]>;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { answerChatQuestionPrompt, applyChatQuestionTap, questionParts } from "./teammate-question.js";
|
|
3
3
|
import { applyChatAskTap, chatAskText } from "./chat-ask.js";
|
|
4
4
|
import { channelRepos, resolveChannelMate, proposalPreview, proposalOutcomeText, confirmedLink, confirmedCardText, replyContextFor, mirrorToTaskChat, focusContextFor, taskInCeiling, tooLongText, } from "./chat-channel.js";
|
|
5
|
-
import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
|
|
5
|
+
import { MATE_MESSAGE_MAX_CHARS, mateFailureText, runMateTurn } from "./mate.js";
|
|
6
|
+
import { shapeReplyParts } from "./reply-shape.js";
|
|
7
|
+
import { warmTurn } from "./chat-warmth.js";
|
|
6
8
|
import { confirmMateProposal, dismissMateProposal, } from "./mate-doors.js";
|
|
7
9
|
import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
8
10
|
import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
|
|
@@ -25,7 +27,8 @@ export const chatObject = (v) => v !== null && typeof v === "object" && !Array.i
|
|
|
25
27
|
: {};
|
|
26
28
|
const object = chatObject;
|
|
27
29
|
const nowOf = (options) => options.clock?.() ?? new Date();
|
|
28
|
-
|
|
30
|
+
const CHAT_PART_SIZE = 2800;
|
|
31
|
+
export const splitChatText = (text, size = CHAT_PART_SIZE) => {
|
|
29
32
|
const parts = [];
|
|
30
33
|
for (let at = 0; at < text.length;) {
|
|
31
34
|
let end = Math.min(at + size, text.length);
|
|
@@ -221,7 +224,7 @@ export async function processChatEvent(options) {
|
|
|
221
224
|
return true;
|
|
222
225
|
}
|
|
223
226
|
if (event.session !== null && event.session !== resolved.session.id) {
|
|
224
|
-
state.plan(event.id, [{ text: "
|
|
227
|
+
state.plan(event.id, [{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
|
|
225
228
|
return true;
|
|
226
229
|
}
|
|
227
230
|
state
|
|
@@ -263,6 +266,9 @@ export async function processChatEvent(options) {
|
|
|
263
266
|
}
|
|
264
267
|
// An unknown surface names no channel rather than a guess.
|
|
265
268
|
const channel = leadChannelOf(options.label);
|
|
269
|
+
// Only ever the owner's own message in their own chat: never the bot's, never a room.
|
|
270
|
+
const warm = warmTurn(options.warm !== undefined && event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel
|
|
271
|
+
? options.warm(event, binding) : {});
|
|
266
272
|
const outcome = await runMateTurn({
|
|
267
273
|
store,
|
|
268
274
|
who: resolved.who,
|
|
@@ -282,19 +288,20 @@ export async function processChatEvent(options) {
|
|
|
282
288
|
evidenceRoot: options.evidenceRoot,
|
|
283
289
|
mediaDelivery: "documents",
|
|
284
290
|
...(channel === undefined ? {} : { channel }),
|
|
291
|
+
onProgress: warm.onProgress,
|
|
285
292
|
revalidate: async () => {
|
|
286
293
|
try {
|
|
287
294
|
await channelAccess(options, binding, resolved.who.ceilingDigest);
|
|
288
295
|
return { ok: true };
|
|
289
296
|
}
|
|
290
|
-
catch {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
};
|
|
297
|
+
catch (error) {
|
|
298
|
+
const code = error instanceof ChatDeliveryError ? error.code : "";
|
|
299
|
+
const reason = code === "Connected projects changed" ? "projects-changed"
|
|
300
|
+
: code.endsWith("account access changed") ? "member-changed" : "access-changed";
|
|
301
|
+
return { ok: false, reason };
|
|
295
302
|
}
|
|
296
303
|
},
|
|
297
|
-
});
|
|
304
|
+
}).finally(warm.stop);
|
|
298
305
|
if (outcome.ok && !outcome.replayed && context?.task)
|
|
299
306
|
mirrorToTaskChat(store, resolved.who, context.task, options.label, text, outcome.reply, nowOf(options));
|
|
300
307
|
if (!state.owns(identity.installation, options.owner, nowOf(options)) ||
|
|
@@ -327,7 +334,7 @@ export async function processChatEvent(options) {
|
|
|
327
334
|
if (turn?.state !== "answered") {
|
|
328
335
|
state.plan(event.id, [
|
|
329
336
|
{
|
|
330
|
-
text:
|
|
337
|
+
text: mateFailureText(turn?.failureReason ?? null),
|
|
331
338
|
},
|
|
332
339
|
], nowOf(options));
|
|
333
340
|
return true;
|
|
@@ -342,7 +349,9 @@ export async function processChatEvent(options) {
|
|
|
342
349
|
.listMateMessages(turn.thread, 200)
|
|
343
350
|
.find((message) => message.turn === turn.id && message.role === "assistant")?.text ?? "The reply is no longer in the saved thread.";
|
|
344
351
|
// The lead's answer about one task names it: a reply to it stays on that task.
|
|
345
|
-
|
|
352
|
+
// Split as written, then shape each part: no cut ever lands inside a link, code or a bold anchor.
|
|
353
|
+
const shapedParts = shapeReplyParts(reply, options.partSize ?? CHAT_PART_SIZE, { asked: text, appOrigin: options.origin() });
|
|
354
|
+
const parts = (shapedParts.length > 0 ? shapedParts : splitChatText("")).map((text) => (about === null ? { text, voice: true } : { text, voice: true, task: about.task, ...(about.run === null ? {} : { run: about.run }) }));
|
|
346
355
|
// The lead's question to its owner: its options as buttons, then "Something else", in their own chat (any thread);
|
|
347
356
|
// in a room a tap is not their message, so the question and its options go out as text.
|
|
348
357
|
const ask = store.mateAsk(turn.id);
|
|
@@ -581,8 +590,13 @@ export async function planRoomMessages(options) {
|
|
|
581
590
|
if (carrier === null)
|
|
582
591
|
break;
|
|
583
592
|
const parts = [];
|
|
584
|
-
if (text !== null)
|
|
585
|
-
|
|
593
|
+
if (text !== null) {
|
|
594
|
+
if (message.role === "assistant")
|
|
595
|
+
for (const part of shapeReplyParts(text, options.partSize ?? CHAT_PART_SIZE, { appOrigin: options.origin() }))
|
|
596
|
+
parts.push({ text: part, voice: true, channel: room.chat });
|
|
597
|
+
else
|
|
598
|
+
parts.push({ text, channel: room.chat });
|
|
599
|
+
}
|
|
586
600
|
if (message.role === "assistant" && message.turn !== null)
|
|
587
601
|
for (const proposal of store.listMateProposals(row.thread, ["pending"]).filter(one => one.turn === message.turn))
|
|
588
602
|
parts.push({ text: "", proposal: proposal.id, channel: room.chat });
|
|
@@ -680,10 +694,11 @@ export async function planChatNotifications(options) {
|
|
|
680
694
|
const personal = notification.recipient !== null;
|
|
681
695
|
if (notification.createdAt >= binding.created &&
|
|
682
696
|
notification.resolvedAt === null &&
|
|
683
|
-
// A promise the lead made on another chat is reported there (lead-commitments.ts)
|
|
697
|
+
// A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
|
|
698
|
+
// this chat is always said here, however quiet the lead's own work is kept.
|
|
684
699
|
(promiseChannelOf(notification) ?? state.channel) === state.channel &&
|
|
685
700
|
// Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
|
|
686
|
-
store.pingAllowed(notification, binding.approver) &&
|
|
701
|
+
(promiseChannelOf(notification) === state.channel || store.pingAllowed(notification, binding.approver)) &&
|
|
687
702
|
// A flow decision for "anyone who approves" reaches every approver who can see the project.
|
|
688
703
|
// A notification addressed to this person reaches them whatever project
|
|
689
704
|
// their channel follows (a sign-in pause is the installation's, v108).
|
|
@@ -697,7 +712,7 @@ export async function planChatNotifications(options) {
|
|
|
697
712
|
const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
|
|
698
713
|
if (notification.kind === LEAD_SAY_KIND) {
|
|
699
714
|
// The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
|
|
700
|
-
const content = { text: leadSayText(store, notification),
|
|
715
|
+
const content = { text: leadSayText(store, notification, binding.approver),
|
|
701
716
|
...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
|
|
702
717
|
const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
|
|
703
718
|
.get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Small warm touches while the lead works on a chat app: once a turn will take more than a few seconds (its first tool
|
|
2
|
+
* step starts), a π on the owner's message and the app's typing indicator until the turn ends. Best effort only: an
|
|
3
|
+
* app or permission that does not allow it is skipped silently, and nothing here can fail, delay or change the turn. */
|
|
4
|
+
import type { MateProgress } from "./mate-progress.js";
|
|
5
|
+
export declare const WARM_EMOJI = "\uD83D\uDC4D";
|
|
6
|
+
/** Telegram and Teams show typing for about five seconds, Discord for ten: refreshed inside the shorter window. */
|
|
7
|
+
export declare const TYPING_REFRESH_MS = 4000;
|
|
8
|
+
export type WarmHooks = {
|
|
9
|
+
/** React to the owner's own message. Absent: the app (or this bot's permission) has no reactions. */
|
|
10
|
+
react?: () => Promise<unknown>;
|
|
11
|
+
/** Show the app's typing indicator once. Absent: the app has none for bots. */
|
|
12
|
+
typing?: () => Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
export type WarmTurn = {
|
|
15
|
+
onProgress: (event: MateProgress) => void;
|
|
16
|
+
stop: () => void;
|
|
17
|
+
};
|
|
18
|
+
/** The turn's progress listener: on the first tool step, react once and keep typing shown until `stop`. */
|
|
19
|
+
export declare function warmTurn(hooks: WarmHooks, options?: {
|
|
20
|
+
refreshMs?: number;
|
|
21
|
+
}): WarmTurn;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const WARM_EMOJI = "π";
|
|
2
|
+
/** Telegram and Teams show typing for about five seconds, Discord for ten: refreshed inside the shorter window. */
|
|
3
|
+
export const TYPING_REFRESH_MS = 4_000;
|
|
4
|
+
/** The turn's progress listener: on the first tool step, react once and keep typing shown until `stop`. */
|
|
5
|
+
export function warmTurn(hooks, options = {}) {
|
|
6
|
+
let started = false;
|
|
7
|
+
let stopped = false;
|
|
8
|
+
let timer = null;
|
|
9
|
+
const quietly = (call) => {
|
|
10
|
+
if (call === undefined || stopped)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
void call().catch(() => undefined);
|
|
14
|
+
}
|
|
15
|
+
catch { /* skipped silently */ }
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
onProgress: event => {
|
|
19
|
+
if (stopped || event.kind !== "tool" || started)
|
|
20
|
+
return;
|
|
21
|
+
started = true;
|
|
22
|
+
quietly(hooks.react);
|
|
23
|
+
quietly(hooks.typing);
|
|
24
|
+
if (hooks.typing !== undefined) {
|
|
25
|
+
timer = setInterval(() => quietly(hooks.typing), options.refreshMs ?? TYPING_REFRESH_MS);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
stop: () => {
|
|
30
|
+
stopped = true;
|
|
31
|
+
if (timer !== null)
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
timer = null;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/discord-api.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const discordId: (v: unknown) => v is string;
|
|
|
5
5
|
export type DiscordCredentials = ChatIdentity & {
|
|
6
6
|
botToken: string;
|
|
7
7
|
};
|
|
8
|
-
export type DiscordApi = (method: "GET" | "POST" | "PATCH", path: string, body?: Record<string, unknown>, file?: {
|
|
8
|
+
export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT", path: string, body?: Record<string, unknown>, file?: {
|
|
9
9
|
bytes: Uint8Array;
|
|
10
10
|
name: string;
|
|
11
11
|
}) => Promise<Record<string, unknown>>;
|
package/dist/discord-api.js
CHANGED
|
@@ -11,7 +11,7 @@ export class DiscordError extends ChatDeliveryError {
|
|
|
11
11
|
export function discordApi(token, fetcher = fetch) {
|
|
12
12
|
return async (method, path, body, file) => {
|
|
13
13
|
// Only relative API routes; never send the bot credential to supplied URLs.
|
|
14
|
-
if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
|
|
14
|
+
if (!/^\/(?:users\/@me|users\/[0-9]+|oauth2\/applications\/@me|channels\/[0-9]+(?:\/typing|\/messages\/[0-9]+\/reactions\/%F0%9F%91%8D\/@me|\/messages(?:\/[0-9]+)?(?:\?limit=100)?)?|interactions\/[0-9]+\/[A-Za-z0-9._-]+\/callback)$/.test(path))
|
|
15
15
|
throw new DiscordError("Invalid Discord request");
|
|
16
16
|
let response;
|
|
17
17
|
const headers = { Authorization: `Bot ${token}` };
|
package/dist/discord-chat.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ChatState, type ChatIdentity } from "./chat-delivery-state.js";
|
|
2
2
|
import { type ChatDeliveryOptions } from "./chat-delivery.js";
|
|
3
|
+
import { discordPlain } from "./reply-shape.js";
|
|
3
4
|
import { type DiscordApi } from "./discord-api.js";
|
|
4
5
|
export type DiscordChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal"> & {
|
|
5
6
|
api: DiscordApi;
|
|
@@ -11,6 +12,6 @@ export declare const planDiscordNotifications: (options: DiscordChatOptions) =>
|
|
|
11
12
|
/** Save only normalized input. Interaction credentials and pairing codes never enter SQLite. */
|
|
12
13
|
export declare function receiveDiscord(state: ChatState, identity: ChatIdentity, type: string, raw: unknown, now: Date): boolean;
|
|
13
14
|
/** Escape Discord's presentation syntax; allowed_mentions also prevents actual pings. */
|
|
14
|
-
export
|
|
15
|
-
export declare function discordCard(text: string, buttons?: Record<string, unknown>[]): Record<string, unknown>;
|
|
15
|
+
export { discordPlain };
|
|
16
|
+
export declare function discordCard(text: string, buttons?: Record<string, unknown>[], voice?: boolean): Record<string, unknown>;
|
|
16
17
|
export declare function deliverDiscordPart(options: DiscordChatOptions): Promise<boolean>;
|
package/dist/discord-chat.js
CHANGED
|
@@ -8,6 +8,8 @@ import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.j
|
|
|
8
8
|
import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, } from "./chat-delivery.js";
|
|
9
9
|
import { chatResultHref } from "./chat-controls.js";
|
|
10
10
|
import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
|
|
11
|
+
import { discordPlain, renderReply } from "./reply-shape.js";
|
|
12
|
+
import { WARM_EMOJI } from "./chat-warmth.js";
|
|
11
13
|
import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
|
|
12
14
|
import { resultImageFileName, safeResultImageCaption, verifyResultImage, } from "./chat-evidence.js";
|
|
13
15
|
import { discordId, discordMember, DiscordError, } from "./discord-api.js";
|
|
@@ -16,6 +18,11 @@ export const discordDelivery = (options) => ({
|
|
|
16
18
|
state: new ChatState(options.store, "discord"),
|
|
17
19
|
label: "Discord",
|
|
18
20
|
member: (member, channel) => discordMember(options.api, member, channel),
|
|
21
|
+
// A π on the owner's message and "typingβ¦" while the lead works; a missing permission is skipped silently.
|
|
22
|
+
warm: (event) => ({
|
|
23
|
+
react: () => options.api("PUT", `/channels/${event.channel}/messages/${event.ts}/reactions/${encodeURIComponent(WARM_EMOJI)}/@me`),
|
|
24
|
+
typing: () => options.api("POST", `/channels/${event.channel}/typing`),
|
|
25
|
+
}),
|
|
19
26
|
partSize: 1800,
|
|
20
27
|
maxProposal: 3400,
|
|
21
28
|
});
|
|
@@ -140,8 +147,16 @@ function link(origin, target) {
|
|
|
140
147
|
}
|
|
141
148
|
}
|
|
142
149
|
/** Escape Discord's presentation syntax; allowed_mentions also prevents actual pings. */
|
|
143
|
-
export
|
|
144
|
-
export function discordCard(text, buttons = []) {
|
|
150
|
+
export { discordPlain };
|
|
151
|
+
export function discordCard(text, buttons = [], voice = false) {
|
|
152
|
+
// The lead's own reply: one description in Discord's Markdown (bold anchors, labelled links), no title line promoted to a header.
|
|
153
|
+
if (voice)
|
|
154
|
+
return {
|
|
155
|
+
content: "",
|
|
156
|
+
embeds: [{ description: renderReply(text, "discord").slice(0, 4096), color: 0x297b70 }],
|
|
157
|
+
components: Array.from({ length: Math.min(5, Math.ceil(buttons.length / 5)) }, (_, row) => ({ type: 1, components: buttons.slice(row * 5, row * 5 + 5) })),
|
|
158
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
159
|
+
};
|
|
145
160
|
const lines = text.split("\n"), first = lines[0] ?? "Toolroll", title = discordPlain(first).length <= 256 &&
|
|
146
161
|
first.length <= 150 &&
|
|
147
162
|
lines.length > 1
|
|
@@ -336,7 +351,7 @@ export async function deliverDiscordPart(options) {
|
|
|
336
351
|
throw new DiscordError("Screenshot evidence changed before upload");
|
|
337
352
|
file.bytes = fresh.bytes;
|
|
338
353
|
}
|
|
339
|
-
const args = discordCard(text, buttons);
|
|
354
|
+
const args = discordCard(text, buttons, content.voice === true && !content.proposal && !content.image);
|
|
340
355
|
if (file)
|
|
341
356
|
args.attachments = [
|
|
342
357
|
{ id: 0, filename: file.name, description: text.slice(0, 1024) },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** What your lead knows about you (Settings β Lead): a short list of the owner's own lasting preferences, the same
|
|
2
|
+
* across projects ("keep copy terse", "I test myself"). Stored beside the lead's name and persona; only lines the
|
|
3
|
+
* owner confirmed (a remember about-you card) or wrote themselves are kept, never a guess. */
|
|
4
|
+
import type { Store } from "./store.js";
|
|
5
|
+
export declare const ABOUT_YOU_MAX_LINES = 20;
|
|
6
|
+
/** Each line is under 200 characters. */
|
|
7
|
+
export declare const ABOUT_YOU_LINE_MAX = 199;
|
|
8
|
+
/** This person's confirmed lines; an older store reads as none. */
|
|
9
|
+
export declare function aboutYouOf(store: Store, account: string | null | undefined): string[];
|
|
10
|
+
/** One line as it is saved, or the problem with it. */
|
|
11
|
+
export declare function checkAboutYouLine(value: unknown): {
|
|
12
|
+
ok: true;
|
|
13
|
+
line: string;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
/** The Settings form's text, one line each: the saved list, or the problem with it. Blank lines and repeats go. */
|
|
19
|
+
export declare function checkAboutYou(text: string): {
|
|
20
|
+
ok: true;
|
|
21
|
+
lines: string[];
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
/** Save the whole list for this person (already checked). Their lead's name and persona are left as they are. */
|
|
27
|
+
export declare function saveAboutYou(store: Store, account: string, lines: readonly string[], now: Date): void;
|
|
28
|
+
/** The list once a confirmed line is added, or once it replaces line `replaces` (1-based). */
|
|
29
|
+
export declare function withAboutYouLine(lines: readonly string[], line: string, replaces: number): {
|
|
30
|
+
ok: true;
|
|
31
|
+
lines: string[];
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
message: string;
|
|
35
|
+
};
|