toolroll 0.9.26 → 0.9.27
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 +11 -0
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/dist/brand-icons.d.ts +168 -0
- package/dist/brand-icons.js +32 -0
- package/dist/brand-mark.d.ts +27 -0
- package/dist/brand-mark.js +49 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +41 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -23
- package/dist/browser-workspace.d.ts +17 -0
- package/dist/chat-delivery.js +3 -3
- package/dist/chat-flow.d.ts +4 -2
- package/dist/chat-flow.js +26 -4
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-items.d.ts +46 -0
- package/dist/flow-items.js +152 -0
- package/dist/flow-send.d.ts +5 -0
- package/dist/flow-send.js +30 -4
- package/dist/flows-ui.js +5 -1
- package/dist/integrations-ui.js +10 -3
- package/dist/integrations.d.ts +2 -0
- package/dist/integrations.js +2 -2
- package/dist/result-shots.d.ts +5 -1
- package/dist/result-shots.js +33 -3
- package/dist/scout.js +1 -1
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +16 -13
- package/dist/setup-guide.d.ts +2 -2
- package/dist/telegram.js +37 -6
- package/dist/tools-ui.d.ts +1 -3
- package/dist/tools-ui.js +5 -4
- package/package.json +3 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* produced by the existing trusted server renderers, never by a model-supplied
|
|
3
3
|
* fragment. These projections do not authenticate, mutate or grant authority. */
|
|
4
4
|
import type { TeamSnapshot } from './team-contract.js';
|
|
5
|
+
import type { BrandIconId } from './brand-icons.js';
|
|
5
6
|
import type { AssignmentSnapshot } from './assignment.js';
|
|
6
7
|
export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
|
|
7
8
|
import type { Store } from './store.js';
|
|
@@ -281,11 +282,13 @@ export type BrowserSettingsView = {
|
|
|
281
282
|
/** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
|
|
282
283
|
updates?: BrowserUpdates | null;
|
|
283
284
|
};
|
|
285
|
+
/** A tile's `brand` is a chat app's logo (brand-mark.ts), drawn in place of its icon. */
|
|
284
286
|
export type BrowserSettingsGroup = {
|
|
285
287
|
title: string;
|
|
286
288
|
tiles: {
|
|
287
289
|
href: string;
|
|
288
290
|
label: string;
|
|
291
|
+
brand?: BrandIconId;
|
|
289
292
|
status?: {
|
|
290
293
|
tone: 'ok' | 'warn' | 'off';
|
|
291
294
|
words: string;
|
|
@@ -822,6 +825,18 @@ export type BrowserFlowStage = {
|
|
|
822
825
|
onFail: string | null;
|
|
823
826
|
};
|
|
824
827
|
/** One card: a piece of work, where it is, what it waits on, what zones said. */
|
|
828
|
+
/** After research, one of the report's items as the message numbered it, with its screenshot when it has one. */
|
|
829
|
+
export type BrowserFlowSentItem = {
|
|
830
|
+
number: number;
|
|
831
|
+
title: string;
|
|
832
|
+
lines: string[];
|
|
833
|
+
source: string;
|
|
834
|
+
url: string;
|
|
835
|
+
image: {
|
|
836
|
+
src: string;
|
|
837
|
+
caption: string;
|
|
838
|
+
} | null;
|
|
839
|
+
};
|
|
825
840
|
export type BrowserFlowCard = {
|
|
826
841
|
id: number;
|
|
827
842
|
title: string;
|
|
@@ -912,6 +927,7 @@ export type BrowserFlowCard = {
|
|
|
912
927
|
label: string;
|
|
913
928
|
href: string;
|
|
914
929
|
}[];
|
|
930
|
+
items?: BrowserFlowSentItem[];
|
|
915
931
|
person: string;
|
|
916
932
|
mine: boolean;
|
|
917
933
|
options: {
|
|
@@ -928,6 +944,7 @@ export type BrowserFlowCard = {
|
|
|
928
944
|
label: string;
|
|
929
945
|
href: string;
|
|
930
946
|
}[];
|
|
947
|
+
items?: BrowserFlowSentItem[];
|
|
931
948
|
person: string;
|
|
932
949
|
at: string;
|
|
933
950
|
} | null;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -840,18 +840,18 @@ export async function planChatNotifications(options) {
|
|
|
840
840
|
}
|
|
841
841
|
else if (notification.kind === "flow-decision") {
|
|
842
842
|
// The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
|
|
843
|
-
const parts = flowDecisionParts(store, notification);
|
|
843
|
+
const parts = flowDecisionParts(store, notification, state.channel);
|
|
844
844
|
if (parts !== null) {
|
|
845
845
|
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
846
846
|
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
847
847
|
state.plan(id, parts, now);
|
|
848
848
|
}
|
|
849
849
|
}
|
|
850
|
-
else if (personal && flowSendParts(store, notification) !== null) {
|
|
850
|
+
else if (personal && flowSendParts(store, notification, state.channel) !== null) {
|
|
851
851
|
// A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
|
|
852
852
|
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
853
853
|
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
854
|
-
state.plan(id, flowSendParts(store, notification), now);
|
|
854
|
+
state.plan(id, flowSendParts(store, notification, state.channel), now);
|
|
855
855
|
}
|
|
856
856
|
else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
|
|
857
857
|
// A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
|
package/dist/chat-flow.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ export type ChatFlowAction = "approve" | "edit" | "send-back";
|
|
|
4
4
|
export declare const CHAT_FLOW_LABELS: Record<ChatFlowAction, string>;
|
|
5
5
|
/** Words kept as written, line breaks and all; only control characters go. */
|
|
6
6
|
export declare function chatFlowText(text: string): string;
|
|
7
|
+
type ChatApp = "slack" | "discord" | "teams";
|
|
7
8
|
/** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
|
|
8
9
|
export declare function flowSendParts(store: Store, notification: {
|
|
9
10
|
dedupeKey: string;
|
|
10
11
|
subject: string;
|
|
11
12
|
body: string;
|
|
12
|
-
}): ChatContent[] | null;
|
|
13
|
+
}, app?: ChatApp): ChatContent[] | null;
|
|
13
14
|
/**
|
|
14
15
|
* A "flow-decision" notice as chat parts, or null when it isn't one (or the
|
|
15
16
|
* card has already moved on, and there is nothing left to decide).
|
|
@@ -19,7 +20,7 @@ export declare function flowDecisionParts(store: Store, notification: {
|
|
|
19
20
|
subject: string;
|
|
20
21
|
body: string;
|
|
21
22
|
link: string | null;
|
|
22
|
-
}): ChatContent[] | null;
|
|
23
|
+
}, app?: ChatApp): ChatContent[] | null;
|
|
23
24
|
/** The live buttons on one part, in the order they were minted: a decision's, a choice's options in their order, or
|
|
24
25
|
* "Use this as your note?" Yes / No. `key` is unique on the part (Slack names each button by it). */
|
|
25
26
|
export declare function chatFlowButtons(state: ChatState, part: number, now: Date): Array<{
|
|
@@ -65,3 +66,4 @@ export declare function answerChatFlowPrompt(options: {
|
|
|
65
66
|
text: string;
|
|
66
67
|
originalLength?: number;
|
|
67
68
|
}, repos: readonly string[], now: Date): boolean;
|
|
69
|
+
export {};
|
package/dist/chat-flow.js
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { keptDraft } from "./flow-draft.js";
|
|
29
29
|
import { decideFlowCard, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
|
|
30
|
-
import { chooseFlowCard, flowChoiceAt, flowSendPaths, FLOW_CHOOSE_KEY, FLOW_SEND_KEY, readFlowSend } from "./flow-send.js";
|
|
30
|
+
import { chooseFlowCard, flowChoiceAt, flowSendPaths, flowSendTail, FLOW_CHOOSE_KEY, FLOW_SEND_KEY, readFlowSend } from "./flow-send.js";
|
|
31
|
+
import { cleanFlowMessage, fitFlowMessage } from "./flow-items.js";
|
|
32
|
+
import { renderReply } from "./reply-shape.js";
|
|
31
33
|
import { deciderOf, replyTarget } from "./flows.js";
|
|
32
34
|
import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
|
|
33
35
|
import { verifyApproverStanding } from "./principal.js";
|
|
@@ -71,13 +73,27 @@ function decisionParts(text, visit, link) {
|
|
|
71
73
|
function sendLinks(paths) {
|
|
72
74
|
return { ...(paths[0] === undefined ? {} : { link: paths[0] }), ...(paths.length > 1 ? { also: paths.slice(1) } : {}) };
|
|
73
75
|
}
|
|
76
|
+
/** The most an item list's part carries here, as the app renders it — a Slack section (2,800), Discord's plain message
|
|
77
|
+
* content (2,000), a Teams message as long as Telegram's — less room for the line a tap adds under it. */
|
|
78
|
+
const ITEMS_LIMIT = { slack: PART_CHARS - 300, discord: 2000 - 300, teams: 4096 - 300 };
|
|
79
|
+
/** After research (flow-items.ts): the message with each item numbered under the summary, its words cleaned as every
|
|
80
|
+
* other flow message's are, in as few parts as the app's limit allows (one, unless head, links and tail alone overflow). */
|
|
81
|
+
function itemsText(content, subject, app) {
|
|
82
|
+
if (app === undefined || content.items === undefined || content.items.length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const message = cleanFlowMessage({ head: subject, summary: content.summary, items: content.items, tail: flowSendTail(content) }, chatFlowText);
|
|
85
|
+
return fitFlowMessage(message, ITEMS_LIMIT[app], shaped => renderReply(shaped, app).length);
|
|
86
|
+
}
|
|
74
87
|
/** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
|
|
75
|
-
export function flowSendParts(store, notification) {
|
|
88
|
+
export function flowSendParts(store, notification, app) {
|
|
76
89
|
const visit = FLOW_SEND_KEY.exec(notification.dedupeKey);
|
|
77
90
|
const kept = visit === null ? null : store.flowSend(Number(visit[1]), Number(visit[2]));
|
|
78
91
|
const content = kept === null ? null : readFlowSend(kept.contentJson);
|
|
79
92
|
if (content === null)
|
|
80
93
|
return null;
|
|
94
|
+
const voiced = itemsText(content, notification.subject, app);
|
|
95
|
+
if (voiced !== null)
|
|
96
|
+
return voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true } : { text, voice: true, ...sendLinks(flowSendPaths(content)) });
|
|
81
97
|
const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
|
|
82
98
|
return all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, ...sendLinks(flowSendPaths(content)) });
|
|
83
99
|
}
|
|
@@ -85,7 +101,7 @@ export function flowSendParts(store, notification) {
|
|
|
85
101
|
* A "flow-decision" notice as chat parts, or null when it isn't one (or the
|
|
86
102
|
* card has already moved on, and there is nothing left to decide).
|
|
87
103
|
*/
|
|
88
|
-
export function flowDecisionParts(store, notification) {
|
|
104
|
+
export function flowDecisionParts(store, notification, app) {
|
|
89
105
|
const choose = FLOW_CHOOSE_KEY.exec(notification.dedupeKey);
|
|
90
106
|
if (choose !== null) {
|
|
91
107
|
const visit = flowChoiceAt(store, Number(choose[1]), Number(choose[2]));
|
|
@@ -94,6 +110,10 @@ export function flowDecisionParts(store, notification) {
|
|
|
94
110
|
if (visit === null || content === null)
|
|
95
111
|
return null;
|
|
96
112
|
const options = (content.options ?? []).filter(one => visit.stage.options?.[one.choice]?.label === one.label);
|
|
113
|
+
const voiced = itemsText(content, notification.subject, app);
|
|
114
|
+
if (voiced !== null)
|
|
115
|
+
return voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true }
|
|
116
|
+
: { text, voice: true, choose: { card: visit.card.id, entry: visit.card.entry, options }, ...sendLinks(flowSendPaths(content)) });
|
|
97
117
|
const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
|
|
98
118
|
return all.map((piece, index) => index < all.length - 1 ? { text: piece }
|
|
99
119
|
: { text: piece, choose: { card: visit.card.id, entry: visit.card.entry, options }, ...sendLinks(flowSendPaths(content)) });
|
|
@@ -124,7 +144,9 @@ const retire = (state, card, entry, now) => {
|
|
|
124
144
|
function repaint(state, part, event, line) {
|
|
125
145
|
const row = state.prepare("SELECT payload FROM chat_part WHERE id=?").get(part);
|
|
126
146
|
const before = row === undefined ? { text: "" } : JSON.parse(String(row["payload"]));
|
|
127
|
-
|
|
147
|
+
// An item list (flow-items.ts) stays whole and keeps its bold titles and labelled links; only plain text is capped.
|
|
148
|
+
const content = { text: before.voice === true ? `${before.text}\n\n${line}` : `${before.text}\n\n${line}`.slice(0, PART_CHARS + 400), edit: event.ts, ...(before.voice === true ? { voice: true } : {}),
|
|
149
|
+
...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
|
|
128
150
|
state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(JSON.stringify(content), part);
|
|
129
151
|
}
|
|
130
152
|
/**
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,7 +125,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
-
version?: never;
|
|
129
128
|
active: boolean;
|
|
130
129
|
phase: string;
|
|
131
130
|
detail: string;
|
|
@@ -139,6 +138,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
139
138
|
wasRunning?: never;
|
|
140
139
|
backupPath?: never;
|
|
141
140
|
updateId?: never;
|
|
141
|
+
version?: never;
|
|
142
142
|
buildId?: never;
|
|
143
143
|
workDir?: never;
|
|
144
144
|
} | {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A research report's items in a flow's message (flow-send.ts): after a report step, each item is listed under the
|
|
3
|
+
* summary, numbered — its title in bold, its why in plain lines, and its link named by its source ("Mobbin", or the
|
|
4
|
+
* site's name). Each screenshot of an item is captioned with the same number, its title and how it plugs in
|
|
5
|
+
* (result-shots.ts), so "2." in the album is item 2 in the text.
|
|
6
|
+
*
|
|
7
|
+
* The message is written in reply-shape.ts's canonical form (`**bold**`, `[label](url)`), which every channel renders
|
|
8
|
+
* in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
|
|
9
|
+
* shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
|
|
10
|
+
*/
|
|
11
|
+
export type FlowSendItem = {
|
|
12
|
+
title: string;
|
|
13
|
+
why: string;
|
|
14
|
+
url: string;
|
|
15
|
+
/** Where the link goes, by name: "Mobbin", "Linear", "Apple". */
|
|
16
|
+
source: string;
|
|
17
|
+
/** One line: how it plugs in (from the why, else the image's own caption). */
|
|
18
|
+
plug: string;
|
|
19
|
+
/** The item's screenshot (its evidence id in the report's run), when it has one. */
|
|
20
|
+
shot: number | null;
|
|
21
|
+
};
|
|
22
|
+
/** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
|
|
23
|
+
export declare function itemSource(url: string): string;
|
|
24
|
+
/** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
|
|
25
|
+
export declare function whyLines(why: string): string[];
|
|
26
|
+
/** How an item plugs in, in one line: the why's last line when it has several, else the image's caption, else the why. */
|
|
27
|
+
export declare function itemPlug(why: string, imageCaption: string | null): string;
|
|
28
|
+
/** A screenshot's caption: its item's number, title and how it plugs in. */
|
|
29
|
+
export declare function itemCaption(number: number, item: Pick<FlowSendItem, "title" | "plug">): string;
|
|
30
|
+
export type FlowMessage = {
|
|
31
|
+
head: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
items: readonly FlowSendItem[];
|
|
34
|
+
tail: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
/** The message's own words through a channel's cleaner — head, summary, each title and why, and tail — before it is fitted. */
|
|
37
|
+
export declare function cleanFlowMessage(message: FlowMessage, clean: (text: string) => string): FlowMessage;
|
|
38
|
+
/**
|
|
39
|
+
* The message in canonical form, as parts within `limit` as `measure` counts each once the channel renders it: one
|
|
40
|
+
* whole part when it fits; else every why shortened to the same length; then the summary; then the titles. When the
|
|
41
|
+
* head, links and tail alone are over the limit, the whole message goes in several parts instead, split between
|
|
42
|
+
* blocks (else between lines). Every link stays.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fitFlowMessage(message: FlowMessage, limit: number, measure: (shaped: string) => number): string[];
|
|
45
|
+
/** Items read back from a kept content; anything malformed is left out. */
|
|
46
|
+
export declare function readFlowItems(raw: unknown): FlowSendItem[];
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A research report's items in a flow's message (flow-send.ts): after a report step, each item is listed under the
|
|
3
|
+
* summary, numbered — its title in bold, its why in plain lines, and its link named by its source ("Mobbin", or the
|
|
4
|
+
* site's name). Each screenshot of an item is captioned with the same number, its title and how it plugs in
|
|
5
|
+
* (result-shots.ts), so "2." in the album is item 2 in the text.
|
|
6
|
+
*
|
|
7
|
+
* The message is written in reply-shape.ts's canonical form (`**bold**`, `[label](url)`), which every channel renders
|
|
8
|
+
* in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
|
|
9
|
+
* shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
|
|
10
|
+
*/
|
|
11
|
+
const PLUG_CHARS = 160;
|
|
12
|
+
const NAMES = {
|
|
13
|
+
"mobbin.com": "Mobbin", "github.com": "GitHub", "figma.com": "Figma", "dribbble.com": "Dribbble", "behance.net": "Behance",
|
|
14
|
+
"youtube.com": "YouTube", "linkedin.com": "LinkedIn", "producthunt.com": "Product Hunt", "pageflows.com": "Page Flows",
|
|
15
|
+
};
|
|
16
|
+
const cutTo = (text, cap) => cap <= 0 ? "" : text.length <= cap ? text : `${text.slice(0, Math.max(0, cap - 1)).trimEnd()}…`;
|
|
17
|
+
const oneLine = (text) => text.replace(/\s+/g, " ").trim();
|
|
18
|
+
/** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
|
|
19
|
+
export function itemSource(url) {
|
|
20
|
+
let host;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "Link";
|
|
26
|
+
}
|
|
27
|
+
for (const [site, name] of Object.entries(NAMES))
|
|
28
|
+
if (host === site || host.endsWith(`.${site}`))
|
|
29
|
+
return name;
|
|
30
|
+
const labels = host.split(".");
|
|
31
|
+
if (labels.length < 2 || /^[0-9.]+$/.test(host) || host.includes(":"))
|
|
32
|
+
return host;
|
|
33
|
+
// "bbc.co.uk" is the BBC's; "developer.apple.com" is Apple's.
|
|
34
|
+
const short = labels.length >= 3 && labels[labels.length - 1].length === 2 && labels[labels.length - 2].length <= 3;
|
|
35
|
+
const name = labels[labels.length - (short ? 3 : 2)];
|
|
36
|
+
return name === "" ? host : `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
37
|
+
}
|
|
38
|
+
/** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
|
|
39
|
+
export function whyLines(why) {
|
|
40
|
+
const lines = why.split(/\n+/).map(one => one.trim()).filter(one => one !== "");
|
|
41
|
+
if (lines.length !== 1)
|
|
42
|
+
return lines;
|
|
43
|
+
const parts = lines[0].split(/;\s+/).map(one => one.trim()).filter(one => one !== "");
|
|
44
|
+
return parts.length >= 2 && parts.length <= 4 ? parts.map(one => `${one.charAt(0).toUpperCase()}${one.slice(1)}`) : lines;
|
|
45
|
+
}
|
|
46
|
+
/** How an item plugs in, in one line: the why's last line when it has several, else the image's caption, else the why. */
|
|
47
|
+
export function itemPlug(why, imageCaption) {
|
|
48
|
+
const lines = whyLines(why);
|
|
49
|
+
const words = lines.length >= 2 ? lines[lines.length - 1] : imageCaption !== null && imageCaption.trim() !== "" ? imageCaption : lines.join(" ");
|
|
50
|
+
return cutTo(oneLine(words), PLUG_CHARS);
|
|
51
|
+
}
|
|
52
|
+
/** A screenshot's caption: its item's number, title and how it plugs in. */
|
|
53
|
+
export function itemCaption(number, item) {
|
|
54
|
+
return item.plug === "" ? `${number}. ${item.title}` : `${number}. ${item.title} · ${item.plug}`;
|
|
55
|
+
}
|
|
56
|
+
/** Plain words can't become bold or a link by accident. */
|
|
57
|
+
const inert = (text) => text.replace(/\*\*/g, "*").replace(/\]\(/g, "] (");
|
|
58
|
+
/** A link the canonical form keeps whole: the characters it stops at are written percent-encoded, as browsers read them. */
|
|
59
|
+
const linkable = (url) => url.replace(/[()[\]<>"'`\s]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
60
|
+
/** The message's own words through a channel's cleaner — head, summary, each title and why, and tail — before it is fitted. */
|
|
61
|
+
export function cleanFlowMessage(message, clean) {
|
|
62
|
+
return { head: clean(message.head), summary: clean(message.summary), items: message.items.map(item => ({ ...item, title: clean(item.title), why: clean(item.why) })),
|
|
63
|
+
tail: message.tail.map(clean) };
|
|
64
|
+
}
|
|
65
|
+
function blocks(message, whyCap, summaryCap, titleCap) {
|
|
66
|
+
const items = message.items.map((item, index) => {
|
|
67
|
+
const title = cutTo(oneLine(item.title).replace(/\*/g, ""), titleCap);
|
|
68
|
+
const why = cutTo(whyLines(item.why).join("\n"), whyCap);
|
|
69
|
+
return [`${index + 1}. ${title === "" ? "" : `**${title}**`}`.trimEnd(), ...(why === "" ? [] : [inert(why)]), `[${item.source.replace(/[[\]]/g, "")}](${linkable(item.url)})`].join("\n");
|
|
70
|
+
});
|
|
71
|
+
return [inert(message.head), inert(cutTo(message.summary, summaryCap)), ...items, ...message.tail.map(inert)].filter(one => one.trim() !== "");
|
|
72
|
+
}
|
|
73
|
+
const compose = (message, whyCap, summaryCap, titleCap) => blocks(message, whyCap, summaryCap, titleCap).join("\n\n");
|
|
74
|
+
/** The largest n in [0, high] that fits, or -1. */
|
|
75
|
+
function largest(high, fits) {
|
|
76
|
+
if (!fits(0))
|
|
77
|
+
return -1;
|
|
78
|
+
let low = 0;
|
|
79
|
+
while (low < high) {
|
|
80
|
+
const mid = Math.ceil((low + high) / 2);
|
|
81
|
+
if (fits(mid))
|
|
82
|
+
low = mid;
|
|
83
|
+
else
|
|
84
|
+
high = mid - 1;
|
|
85
|
+
}
|
|
86
|
+
return low;
|
|
87
|
+
}
|
|
88
|
+
/** Whole pieces packed into as few parts as fit, joined by `glue`; a piece too long alone is broken by `smaller`. */
|
|
89
|
+
function pack(pieces, glue, fits, smaller) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
let open = "";
|
|
92
|
+
for (const piece of pieces.flatMap(one => fits(one) ? [one] : smaller(one))) {
|
|
93
|
+
const joined = open === "" ? piece : `${open}${glue}${piece}`;
|
|
94
|
+
if (open === "" || fits(joined))
|
|
95
|
+
open = joined;
|
|
96
|
+
else {
|
|
97
|
+
parts.push(open);
|
|
98
|
+
open = piece;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return open === "" ? parts : [...parts, open];
|
|
102
|
+
}
|
|
103
|
+
/** A line longer than a whole part, in the longest prefixes that fit (never splitting a surrogate pair). */
|
|
104
|
+
function chop(line, fits) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let rest = line; rest !== "";) {
|
|
107
|
+
let at = Math.max(1, largest(rest.length, n => fits(rest.slice(0, n))));
|
|
108
|
+
if (at < rest.length && /[\uD800-\uDBFF]/.test(rest[at - 1]))
|
|
109
|
+
at = at > 1 ? at - 1 : at + 1;
|
|
110
|
+
out.push(rest.slice(0, at));
|
|
111
|
+
rest = rest.slice(at);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The message in canonical form, as parts within `limit` as `measure` counts each once the channel renders it: one
|
|
117
|
+
* whole part when it fits; else every why shortened to the same length; then the summary; then the titles. When the
|
|
118
|
+
* head, links and tail alone are over the limit, the whole message goes in several parts instead, split between
|
|
119
|
+
* blocks (else between lines). Every link stays.
|
|
120
|
+
*/
|
|
121
|
+
export function fitFlowMessage(message, limit, measure) {
|
|
122
|
+
const fits = (text) => measure(text) <= limit;
|
|
123
|
+
const full = compose(message, Infinity, Infinity, Infinity);
|
|
124
|
+
if (fits(full))
|
|
125
|
+
return [full];
|
|
126
|
+
const longest = Math.max(0, ...message.items.map(one => whyLines(one.why).join("\n").length));
|
|
127
|
+
const why = largest(longest, n => fits(compose(message, n, Infinity, Infinity)));
|
|
128
|
+
if (why >= 0)
|
|
129
|
+
return [compose(message, why, Infinity, Infinity)];
|
|
130
|
+
const summary = largest(message.summary.length, n => fits(compose(message, 0, n, Infinity)));
|
|
131
|
+
if (summary >= 0)
|
|
132
|
+
return [compose(message, 0, summary, Infinity)];
|
|
133
|
+
const title = largest(Math.max(0, ...message.items.map(one => one.title.length)), n => fits(compose(message, 0, 0, n)));
|
|
134
|
+
if (title >= 0)
|
|
135
|
+
return [compose(message, 0, 0, title)];
|
|
136
|
+
return pack(blocks(message, Infinity, Infinity, Infinity), "\n\n", fits, block => pack(block.split("\n"), "\n", fits, line => chop(line, fits)));
|
|
137
|
+
}
|
|
138
|
+
/** Items read back from a kept content; anything malformed is left out. */
|
|
139
|
+
export function readFlowItems(raw) {
|
|
140
|
+
if (!Array.isArray(raw))
|
|
141
|
+
return [];
|
|
142
|
+
return raw.flatMap((one) => {
|
|
143
|
+
if (typeof one !== "object" || one === null)
|
|
144
|
+
return [];
|
|
145
|
+
const item = one;
|
|
146
|
+
const text = (key) => typeof item[key] === "string" ? item[key] : null;
|
|
147
|
+
const [title, why, url, source, plug] = [text("title"), text("why"), text("url"), text("source"), text("plug")];
|
|
148
|
+
if (title === null || why === null || url === null || source === null || plug === null || !/^https?:\/\//.test(url))
|
|
149
|
+
return [];
|
|
150
|
+
return [{ title, why, url, source, plug, shot: typeof item["shot"] === "number" && Number.isSafeInteger(item["shot"]) ? item["shot"] : null }];
|
|
151
|
+
});
|
|
152
|
+
}
|
package/dist/flow-send.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FlowDecision } from "./flow-engine.js";
|
|
2
|
+
import { type FlowSendItem } from "./flow-items.js";
|
|
2
3
|
import { type FlowDefinition, type FlowStage } from "./flows.js";
|
|
3
4
|
import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
|
|
4
5
|
/** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
|
|
@@ -26,6 +27,8 @@ export type FlowSendContent = {
|
|
|
26
27
|
taskId: string;
|
|
27
28
|
run: number;
|
|
28
29
|
} | null;
|
|
30
|
+
/** After research: the report's items, numbered in this order in every message and screenshot caption (flow-items.ts). */
|
|
31
|
+
items?: FlowSendItem[];
|
|
29
32
|
/** choose: the options as they were offered, and whether a reply is taken. */
|
|
30
33
|
options?: {
|
|
31
34
|
choice: number;
|
|
@@ -52,6 +55,8 @@ export declare function sendStep(store: Store, flow: FlowRow, definition: FlowDe
|
|
|
52
55
|
export declare function chooseStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
|
|
53
56
|
/** The words above a choice's buttons: what was done, then what to do. */
|
|
54
57
|
export declare function flowChooseBody(content: FlowSendContent): string;
|
|
58
|
+
/** What a sent visit's message ends with, after its summary (and items): its pull request, and for a choice what to do. */
|
|
59
|
+
export declare function flowSendTail(content: FlowSendContent): string[];
|
|
55
60
|
export type FlowChoiceVisit = {
|
|
56
61
|
card: FlowCardRow;
|
|
57
62
|
flow: FlowRow;
|
package/dist/flow-send.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
import { assignmentOf } from "./assignment.js";
|
|
17
17
|
import { withActor } from "./actor.js";
|
|
18
18
|
import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
|
|
19
|
+
import { itemPlug, itemSource, readFlowItems } from "./flow-items.js";
|
|
19
20
|
import { notifyPeople } from "./flow-people.js";
|
|
21
|
+
import { readVerifiedReport } from "./evidence.js";
|
|
20
22
|
import { FLOW_END, replyTarget } from "./flows.js";
|
|
21
23
|
import { FLOW_SHOTS_KIND } from "./store.js";
|
|
22
24
|
/** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
|
|
@@ -63,6 +65,18 @@ function reportShotsOf(store, taskId) {
|
|
|
63
65
|
const run = store.runsFor(ref.id).find(one => one.finishedAt !== null && one.role === "scout");
|
|
64
66
|
return run !== undefined && run.outcome === "built" && store.artifactsFor(run.id).some(one => one.kind === "screenshot") ? { taskId, run: run.id } : null;
|
|
65
67
|
}
|
|
68
|
+
/** A research task's report items, each with its source, how it plugs in, and its screenshot from that run. */
|
|
69
|
+
function reportItemsOf(store, taskId, shots, evidenceRoot) {
|
|
70
|
+
const ref = store.lookupRef(taskId);
|
|
71
|
+
const view = ref === null || evidenceRoot === undefined ? null : readVerifiedReport(store, evidenceRoot, ref.id);
|
|
72
|
+
if (view === null || !view.ok)
|
|
73
|
+
return [];
|
|
74
|
+
return view.report.items.map(item => {
|
|
75
|
+
const image = item.image === null ? undefined : view.report.images.find(one => one.file === item.image);
|
|
76
|
+
const shot = item.image === null || shots?.run !== view.run ? null : view.shots.find(one => one.file === item.image)?.artifactId ?? null;
|
|
77
|
+
return { title: plain(item.title), why: plain(item.why), url: item.url, source: itemSource(item.url), plug: plain(itemPlug(item.why, image?.caption ?? null)), shot };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
66
80
|
/** What a visit sends its person, worked out from the card as it is now. */
|
|
67
81
|
export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
68
82
|
const before = stepBefore(store, definition, card);
|
|
@@ -96,10 +110,12 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
|
|
|
96
110
|
links.push({ label: "Report", path: `/t/${encodeURIComponent(report)}` });
|
|
97
111
|
links.push({ label: "Card", path: flowCardHref(flow.id, card.id) });
|
|
98
112
|
const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
|
|
113
|
+
// After research, its own screenshots are what that step produced; otherwise the build's.
|
|
114
|
+
const shots = (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null;
|
|
115
|
+
const items = report === null ? [] : reportItemsOf(store, report, shots?.taskId === report ? shots : null, evidenceRoot);
|
|
99
116
|
return {
|
|
100
117
|
title: cut(plain(before === null ? card.title : `${card.title} · ${before.title}`), 200), ...(before === null ? {} : { from: before.title }),
|
|
101
|
-
|
|
102
|
-
summary: plain(lines.join("\n\n")), links, shots: (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null,
|
|
118
|
+
summary: plain(lines.join("\n\n")), links, shots, ...(items.length === 0 ? {} : { items }),
|
|
103
119
|
...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
|
|
104
120
|
};
|
|
105
121
|
}
|
|
@@ -107,7 +123,13 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
|
|
|
107
123
|
export function readFlowSend(json) {
|
|
108
124
|
try {
|
|
109
125
|
const raw = JSON.parse(json);
|
|
110
|
-
|
|
126
|
+
if (typeof raw.title !== "string" || typeof raw.summary !== "string" || !Array.isArray(raw.links))
|
|
127
|
+
return null;
|
|
128
|
+
if (raw.items === undefined)
|
|
129
|
+
return raw;
|
|
130
|
+
const items = readFlowItems(raw.items);
|
|
131
|
+
const { items: _, ...rest } = raw;
|
|
132
|
+
return items.length === 0 ? rest : { ...rest, items };
|
|
111
133
|
}
|
|
112
134
|
catch {
|
|
113
135
|
return null;
|
|
@@ -168,8 +190,12 @@ export function chooseStep(store, flow, definition, stage, card, now, evidenceRo
|
|
|
168
190
|
}
|
|
169
191
|
/** The words above a choice's buttons: what was done, then what to do. */
|
|
170
192
|
export function flowChooseBody(content) {
|
|
193
|
+
return [content.summary, ...flowSendTail(content)].join("\n\n");
|
|
194
|
+
}
|
|
195
|
+
/** What a sent visit's message ends with, after its summary (and items): its pull request, and for a choice what to do. */
|
|
196
|
+
export function flowSendTail(content) {
|
|
171
197
|
const pull = content.links.find((one) => "url" in one);
|
|
172
|
-
return [
|
|
198
|
+
return [...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), ...(content.options === undefined ? [] : [content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."])];
|
|
173
199
|
}
|
|
174
200
|
/** The card, if it is still at that visit, waiting for its person's choice. */
|
|
175
201
|
export function flowChoiceAt(store, cardId, entry) {
|
package/dist/flows-ui.js
CHANGED
|
@@ -7,6 +7,7 @@ import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions
|
|
|
7
7
|
import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
|
|
8
8
|
import { flowFingerprint } from "./flow-live.js";
|
|
9
9
|
import { flowChoiceAt, readFlowSend } from "./flow-send.js";
|
|
10
|
+
import { itemCaption, whyLines } from "./flow-items.js";
|
|
10
11
|
import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
|
|
11
12
|
import { googleConnected } from "./google-mail.js";
|
|
12
13
|
import { mailboxReady } from "./mailbox.js";
|
|
@@ -130,7 +131,10 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
130
131
|
// On the card itself: where it came from (the card's title is right above), and only the links the panel doesn't already have.
|
|
131
132
|
const own = new Set([`/flows/${flow.id}?card=${card.id}`, ...(task === null ? [] : [`/t/${encodeURIComponent(task)}`])]);
|
|
132
133
|
const sentView = (content) => ({ title: content.from === undefined ? "What was done" : `From ${content.from}`, summary: content.summary,
|
|
133
|
-
links: content.links.map(one => ({ label: one.label, href: "url" in one ? one.url : one.path })).filter(one => !own.has(one.href))
|
|
134
|
+
links: content.links.map(one => ({ label: one.label, href: "url" in one ? one.url : one.path })).filter(one => !own.has(one.href)),
|
|
135
|
+
// After research: the same numbered items the message carried, each beside its screenshot.
|
|
136
|
+
...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
|
|
137
|
+
image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
|
|
134
138
|
return {
|
|
135
139
|
id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
|
|
136
140
|
task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },
|
package/dist/integrations-ui.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { STATE_WORDS } from "./integrations.js";
|
|
8
8
|
import { whenUtc } from "./when-html.js";
|
|
9
|
+
import { brandMarkHtml } from "./brand-mark.js";
|
|
9
10
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
10
11
|
const when = (at) => whenUtc(at);
|
|
11
12
|
export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.integrations h2{margin:24px 0 6px;font-size:.9375rem}` +
|
|
12
13
|
`.integrations .integration{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:12px 0;border-top:1px solid var(--so-line);min-width:0}` +
|
|
13
14
|
`.integrations .integration>:not(.integration-action),.integrations .integration-body>*{grid-column:1}.integrations .integration-body{display:contents}.integrations .integration:first-of-type{border-top:0}.integrations .integration-head{margin:0;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}` +
|
|
14
|
-
`.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
|
|
15
|
+
`.integrations .integration .integration-body>*{margin-left:calc(var(--brand-mark) + 10px)}.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
|
|
15
16
|
`.integrations .integration-action{grid-column:2;grid-row:1 / span 3;align-self:center;margin:0}.integrations .integration-action button,.integrations .integration-action .button-link{min-height:44px;white-space:nowrap}` +
|
|
16
17
|
`.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
|
|
17
18
|
`.integrations .integration-fix{color:var(--so-danger)}.integrations code{overflow-wrap:anywhere}.integrations details{margin-top:4px;font-size:.8125rem}.integrations summary{cursor:pointer;padding:4px 0}` +
|
|
@@ -21,7 +22,7 @@ export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.inte
|
|
|
21
22
|
`.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
|
|
22
23
|
// A phone: the action sits at the right of the name line, and the facts under it run on as one line.
|
|
23
24
|
`@media (max-width:760px){.integrations h2{margin:16px 0 0}.integrations .integration{gap:0 12px;padding:6px 0 8px}.integrations .integration-head{min-height:44px}` +
|
|
24
|
-
`.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35}` +
|
|
25
|
+
`.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35;padding-left:calc(var(--brand-mark) + 10px)}.integrations .integration .integration-body>*{margin-left:0}` +
|
|
25
26
|
`.integrations .integration-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
|
|
26
27
|
`.integrations .integration-body>:not(.meta){margin-top:4px}.integrations .integration-body>:not(.meta)+p.meta{display:block;margin-top:2px}.integrations details{margin-top:2px}}`;
|
|
27
28
|
const GROUPS = [
|
|
@@ -39,6 +40,12 @@ function action(one, csrf) {
|
|
|
39
40
|
return `<p class="integration-action"><a class="button-link${a.kind === "setup" ? " integration-quiet" : ""}" href="${e(a.href)}">${a.label}</a></p>`;
|
|
40
41
|
return "";
|
|
41
42
|
}
|
|
43
|
+
/** Which mark a row shows: email is Gmail's only when the account is a Gmail address; a custom tool is a letter. */
|
|
44
|
+
function markFor(one) {
|
|
45
|
+
if (one.custom)
|
|
46
|
+
return null;
|
|
47
|
+
return one.key === "email" && /@(gmail|googlemail)\.com$/i.test(one.account ?? "") ? "gmail" : one.key;
|
|
48
|
+
}
|
|
42
49
|
function row(one, csrf) {
|
|
43
50
|
const badge = !one.checked && one.state === "connected"
|
|
44
51
|
? `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
|
|
@@ -53,7 +60,7 @@ function row(one, csrf) {
|
|
|
53
60
|
? `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
|
|
54
61
|
: `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${e(one.lastError)}</p></details>`;
|
|
55
62
|
return `<div class="integration" data-integration="${e(one.key)}" data-state="${one.state}">` +
|
|
56
|
-
`<p class="integration-head"
|
|
63
|
+
`<p class="integration-head">${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<strong>${e(one.name)}</strong> ${badge}</p>` +
|
|
57
64
|
action(one, csrf) +
|
|
58
65
|
`<div class="integration-body">${facts === "" ? "" : `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
|
|
59
66
|
}
|
package/dist/integrations.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type Integration = {
|
|
|
36
36
|
lastErrorAt: string | null;
|
|
37
37
|
usedBy: string[];
|
|
38
38
|
action: IntegrationAction;
|
|
39
|
+
/** A project's own MCP tool: it shows a letter, never a logo matched by name. */
|
|
40
|
+
custom?: true;
|
|
39
41
|
};
|
|
40
42
|
export declare const STATE_WORDS: Record<IntegrationState, string>;
|
|
41
43
|
export type ProbeResult = {
|
package/dist/integrations.js
CHANGED
|
@@ -383,7 +383,7 @@ function targets(io) {
|
|
|
383
383
|
const href = `/settings/tools?repo=${encodeURIComponent(repo)}#tool-${encodeURIComponent(tool.name)}`;
|
|
384
384
|
const test = tool.lastTest;
|
|
385
385
|
list.push({
|
|
386
|
-
key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo),
|
|
386
|
+
key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo), ...(tool.source === "custom" ? { custom: true } : {}),
|
|
387
387
|
setup: place(href), fix: place(href), usedBy: [`Tasks in ${basename(repo)}`],
|
|
388
388
|
activity: test === null ? NO_ACTIVITY : test.ok ? { okAt: test.at, error: null, errorAt: null, failing: false } : { okAt: null, error: test.problem, errorAt: test.at, failing: false },
|
|
389
389
|
probe: async (check) => {
|
|
@@ -432,7 +432,7 @@ function targets(io) {
|
|
|
432
432
|
}
|
|
433
433
|
// ---------------------------------------------------------------- the list
|
|
434
434
|
function combine(target, row) {
|
|
435
|
-
const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy };
|
|
435
|
+
const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy, ...(target.custom ? { custom: true } : {}) };
|
|
436
436
|
const checked = target.probe === null || row !== undefined;
|
|
437
437
|
const lastSuccessAt = later(row?.okAt ?? null, target.activity.okAt);
|
|
438
438
|
const checkError = row?.error ?? null, checkErrorAt = row?.errorAt ?? null;
|
package/dist/result-shots.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ResultShot = {
|
|
|
15
15
|
caption: string;
|
|
16
16
|
/** Within Telegram's photo limits, so it shows an inline preview. */
|
|
17
17
|
photo: boolean;
|
|
18
|
+
/** Captioned with its item's number (flow-items.ts): an album shows it on this photo, not only on the first. */
|
|
19
|
+
numbered?: true;
|
|
18
20
|
};
|
|
19
21
|
export type ResultShotsPlan =
|
|
20
22
|
/** Nothing to send and nothing to say: off, pruned by retention, none saved, or all already sent. */
|
|
@@ -38,7 +40,9 @@ export type ResultShotsPlan =
|
|
|
38
40
|
* Every byte returned was verified this instant; a channel uploads them with
|
|
39
41
|
* no wait in between, or asks again.
|
|
40
42
|
*/
|
|
41
|
-
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run"
|
|
43
|
+
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run"> & {
|
|
44
|
+
dedupeKey?: string;
|
|
45
|
+
}, sent?: ReadonlySet<number>): ResultShotsPlan;
|
|
42
46
|
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
43
47
|
export declare function isShotsKind(kind: string): boolean;
|
|
44
48
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|