toolroll 0.8.2 → 0.9.1
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 +49 -0
- package/dist/agent-onboard.d.ts +24 -1
- package/dist/agent-onboard.js +53 -1
- package/dist/backup-ui.js +4 -2
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +33 -32
- package/dist/browser-workspace.d.ts +22 -2
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +43 -3
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/flow-engine.js +13 -1
- package/dist/flow-pull-request.d.ts +11 -0
- package/dist/flow-pull-request.js +123 -0
- package/dist/flow-starters-ui.d.ts +20 -0
- package/dist/flow-starters-ui.js +35 -0
- package/dist/flow-starters.d.ts +55 -0
- package/dist/flow-starters.js +104 -0
- package/dist/flow-steps.js +14 -0
- package/dist/flows-ui.d.ts +1 -1
- package/dist/flows-ui.js +4 -3
- package/dist/flows.d.ts +30 -6
- package/dist/flows.js +110 -6
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +70 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +1 -1
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +18 -5
- package/dist/models-ui.js +3 -2
- package/dist/monitoring-ui.js +2 -1
- package/dist/operate.d.ts +5 -2
- package/dist/operate.js +169 -4
- package/dist/policy-ui.js +2 -1
- package/dist/project-delete.js +1 -0
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +172 -0
- package/dist/pull-request-flow.js +475 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +371 -58
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +2 -1
- package/dist/store.d.ts +61 -0
- package/dist/store.js +144 -3
- package/dist/surface.js +3 -1
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/when-html.d.ts +8 -0
- package/dist/when-html.js +36 -0
- package/package.json +1 -1
package/dist/setup-guide.d.ts
CHANGED
|
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
|
|
|
15
15
|
} | null;
|
|
16
16
|
/** Browser installation may write only this known file inside its project. */
|
|
17
17
|
export declare function previewProjectInstructions(repo: string): {
|
|
18
|
-
content?: never;
|
|
19
18
|
ok: false;
|
|
20
19
|
message: string;
|
|
21
20
|
plan?: never;
|
|
21
|
+
content?: never;
|
|
22
22
|
fingerprint?: never;
|
|
23
23
|
installed?: never;
|
|
24
24
|
} | {
|
|
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
|
|
|
30
30
|
installed: boolean;
|
|
31
31
|
};
|
|
32
32
|
export declare function addProjectInstructions(repo: string, fingerprint: string): {
|
|
33
|
-
content?: never;
|
|
34
33
|
ok: false;
|
|
35
34
|
message: string;
|
|
36
35
|
plan?: never;
|
|
36
|
+
content?: never;
|
|
37
37
|
fingerprint?: never;
|
|
38
38
|
installed?: never;
|
|
39
39
|
} | import("./skills.js").InstallResult;
|
package/dist/skills-ui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { whenUtc } from "./when-html.js";
|
|
2
3
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3
4
|
const hidden = (data) => Object.entries(data)
|
|
4
5
|
.map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`)
|
|
@@ -41,7 +42,7 @@ export function skillsHtml(view, csrf, canManage, options = {}) {
|
|
|
41
42
|
? `<details class="card"${options.error && options.draft?.["method"] ? " open" : ""}><summary>Add skill</summary><form method="post" action="/settings/skills/import" data-skill-import>${hidden(base)}<label>Add from<select name="method" data-skill-method><option value="paste"${d["method"] === "paste" ? " selected" : ""}>Paste SKILL.md</option><option value="github"${d["method"] === "github" ? " selected" : ""}>GitHub folder</option><option value="folder"${d["method"] === "folder" ? " selected" : ""}>Local folder</option></select></label><div data-skill-input="paste"><label>SKILL.md<textarea name="content" rows="8" maxlength="24576" placeholder="--- name: design-review description: Review interface copy and layout. --- Write your instructions here.">${e(d["content"])}</textarea></label></div><div data-skill-input="github"><label>Public GitHub folder<input name="url" type="url" value="${e(d["url"])}" placeholder="https://github.com/owner/repo/tree/main/skills/review"></label></div><div data-skill-input="folder"><label>Skill folder<input type="file" webkitdirectory multiple data-skill-folder></label><input type="hidden" name="files" value=""><p class="meta">Choose the folder containing SKILL.md. Up to 64 files, 1 MB total.</p><noscript>Folder uploads require JavaScript. You can paste SKILL.md or use a GitHub folder.</noscript></div><p class="meta">Added to your library first. Review its source and requirements before enabling it.</p><p data-skill-error role="alert" hidden></p><button>Add to library</button></form></details>`
|
|
42
43
|
: "";
|
|
43
44
|
const history = view.history.length
|
|
44
|
-
? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${
|
|
45
|
+
? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${whenUtc(h.at)}</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
|
|
45
46
|
: "";
|
|
46
47
|
return `<section class="skills">${options.error ? `<p class="problem" role="alert">${e(options.error)}</p>` : ""}<p>${active ? `${active} skill${active === 1 ? "" : "s"} enabled for new tasks.` : "No skills enabled for this project."} Active tasks keep their saved versions.</p>${add}${search}${cards || "<p>Your library is empty. Add a skill to review and enable it here.</p>"}${history}</section>`;
|
|
47
48
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
|
19
19
|
import { type CheckoutCleanup } from "./storage.js";
|
|
20
20
|
import { type LedgerChainReport } from "./ledger-chain.js";
|
|
21
21
|
import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
|
|
22
|
+
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
23
|
+
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
24
|
+
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
25
|
+
export declare const INTEGRATIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS integration_check (\n key TEXT PRIMARY KEY,\n outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),\n account TEXT,\n detail TEXT,\n problem TEXT,\n checked_at TEXT NOT NULL,\n ok_at TEXT,\n error TEXT,\n error_at TEXT\n);\n";
|
|
22
26
|
export type BackupSettings = {
|
|
23
27
|
enabled: boolean;
|
|
24
28
|
everyHours: number;
|
|
@@ -944,6 +948,26 @@ export type TelegramDelivery = Notification & {
|
|
|
944
948
|
destination: string;
|
|
945
949
|
claimGeneration: number;
|
|
946
950
|
};
|
|
951
|
+
/** How chats reach one person (quiet chat): only when they are needed, or every step. */
|
|
952
|
+
export type NotificationMode = "quiet" | "all";
|
|
953
|
+
export type NotificationPreference = {
|
|
954
|
+
mode: NotificationMode;
|
|
955
|
+
digestAt: string | null;
|
|
956
|
+
digestOn: string | null;
|
|
957
|
+
updatedAt: string | null;
|
|
958
|
+
};
|
|
959
|
+
/** Tasks filed this close together share one chat message. */
|
|
960
|
+
export declare const QUIET_GROUP_MS = 60000;
|
|
961
|
+
export declare function isDigestTime(value: string): boolean;
|
|
962
|
+
/** The one chat message a destination keeps for a task, or for several tasks filed together. */
|
|
963
|
+
export type ChatCard = {
|
|
964
|
+
id: number;
|
|
965
|
+
destination: string;
|
|
966
|
+
anchorAt: string;
|
|
967
|
+
message: string | null;
|
|
968
|
+
digest: string | null;
|
|
969
|
+
tasks: number[];
|
|
970
|
+
};
|
|
947
971
|
/**
|
|
948
972
|
* What one recovery walk over a gone runner's work settled (P0.1a): the run
|
|
949
973
|
* ids it finished as failed/interrupted and the built-in task ids it returned
|
|
@@ -1544,6 +1568,11 @@ export type PublicationGrant = {
|
|
|
1544
1568
|
merge?: boolean;
|
|
1545
1569
|
mergeMethod?: "squash" | "merge" | "rebase" | null;
|
|
1546
1570
|
mergeDeleteBranch?: boolean;
|
|
1571
|
+
/** When a pull request is owed: every finished build ("build", the original grant), or only when a person
|
|
1572
|
+
* chooses "Complete and open a pull request" ("complete", the one-step project setup). */
|
|
1573
|
+
publishOn?: "build" | "complete";
|
|
1574
|
+
/** The project's optional "Merge when checks pass", off by default (complete-mode grants only). */
|
|
1575
|
+
mergeWhenGreen?: boolean;
|
|
1547
1576
|
};
|
|
1548
1577
|
/** One run's road to a PR, durable at every phase. */
|
|
1549
1578
|
export type Publication = {
|
|
@@ -5876,6 +5905,8 @@ export declare class Store {
|
|
|
5876
5905
|
merge?: boolean;
|
|
5877
5906
|
mergeMethod?: "squash" | "merge" | "rebase" | null;
|
|
5878
5907
|
mergeDeleteBranch?: boolean;
|
|
5908
|
+
publishOn?: "build" | "complete";
|
|
5909
|
+
mergeWhenGreen?: boolean;
|
|
5879
5910
|
}, now: Date): void;
|
|
5880
5911
|
/**
|
|
5881
5912
|
* Reserve the next observation generation for a PR BEFORE the network
|
|
@@ -7256,6 +7287,8 @@ export declare class Store {
|
|
|
7256
7287
|
/** A confirmed network message remains history even if authority changed
|
|
7257
7288
|
* while it was in flight. It only binds the OLD destination, never new work. */
|
|
7258
7289
|
recordTelegramMessage(row: TelegramDelivery, binding: TelegramBinding, messageId: string, now: Date): void;
|
|
7290
|
+
/** The newest confirmed message this notification became at one destination, if any. */
|
|
7291
|
+
telegramMessageOf(notification: number, destination: string): string | null;
|
|
7259
7292
|
finalizeTelegramDelivery(row: TelegramDelivery, binding: TelegramBinding, owner: string, outcome: {
|
|
7260
7293
|
ok: true;
|
|
7261
7294
|
receipt: string | null;
|
|
@@ -7287,6 +7320,34 @@ export declare class Store {
|
|
|
7287
7320
|
/** Set (or clear, with null) the cadence. Turning it on starts the
|
|
7288
7321
|
* window NOW — nothing held before the choice is dumped at once. */
|
|
7289
7322
|
setTelegramDigest(everyMs: number | null, by: string, now: Date): void;
|
|
7323
|
+
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
7324
|
+
notificationPreference(account: string): NotificationPreference;
|
|
7325
|
+
/** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
|
|
7326
|
+
setNotificationPreference(account: string, change: {
|
|
7327
|
+
mode?: NotificationMode;
|
|
7328
|
+
digestAt?: string | null;
|
|
7329
|
+
}, by: string, now: Date): NotificationPreference;
|
|
7330
|
+
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
7331
|
+
eveningDigestPeople(): {
|
|
7332
|
+
account: string;
|
|
7333
|
+
digestAt: string;
|
|
7334
|
+
digestOn: string | null;
|
|
7335
|
+
}[];
|
|
7336
|
+
/** Claim one local day's digest for one person: true once, false if it was already considered. */
|
|
7337
|
+
claimEveningDigestDay(account: string, day: string): boolean;
|
|
7338
|
+
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
7339
|
+
* a minute of it (up to eight tasks), or starts its own. */
|
|
7340
|
+
chatCardFor(destination: string, taskRef: number, filedAt: string, now: Date): ChatCard;
|
|
7341
|
+
chatCard(id: number): ChatCard | null;
|
|
7342
|
+
/** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
|
|
7343
|
+
latestTaskFact(taskRef: number): {
|
|
7344
|
+
kind: string;
|
|
7345
|
+
createdAt: string;
|
|
7346
|
+
} | null;
|
|
7347
|
+
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
7348
|
+
taskFactsSince(since: string, limit?: number): Notification[];
|
|
7349
|
+
/** The message now showing this card, and what it shows. */
|
|
7350
|
+
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7290
7351
|
markTelegramDigestSent(now: Date): void;
|
|
7291
7352
|
/** Every batched row finalized delivered AND the anchor moved, in ONE
|
|
7292
7353
|
* transaction (v4 review, finding 5): a digest is either recorded whole
|
package/dist/store.js
CHANGED
|
@@ -71,6 +71,7 @@ import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storag
|
|
|
71
71
|
import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
|
|
72
72
|
import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
|
|
73
73
|
import { configBase, envValue, namedPath } from "./names.js";
|
|
74
|
+
import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
|
|
74
75
|
// v60 fenced older readers before the chat action cards; v61 adds notification
|
|
75
76
|
// provenance and Telegram destination receipts, and readers below v61 refuse it.
|
|
76
77
|
// v62 adds the durable Telegram conversation queue and proposal-card tokens,
|
|
@@ -131,6 +132,54 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
|
|
|
131
132
|
held_until TEXT
|
|
132
133
|
);
|
|
133
134
|
`;
|
|
135
|
+
/** Quiet chat: each person's choice of how chats reach them — only when they are needed (the default, no row),
|
|
136
|
+
* every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
|
|
137
|
+
* digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
|
|
138
|
+
* for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
|
|
139
|
+
* part id, `digest` the text last shown, so an unchanged card is never re-sent. */
|
|
140
|
+
const QUIET_CHAT_SCHEMA = `
|
|
141
|
+
CREATE TABLE IF NOT EXISTS notification_preference (
|
|
142
|
+
account TEXT PRIMARY KEY,
|
|
143
|
+
mode TEXT NOT NULL DEFAULT 'quiet' CHECK (mode IN ('quiet', 'all')),
|
|
144
|
+
digest_at TEXT CHECK (digest_at IS NULL OR (length(digest_at) = 5 AND digest_at GLOB '[0-2][0-9]:[0-5][0-9]')),
|
|
145
|
+
digest_on TEXT,
|
|
146
|
+
updated_at TEXT NOT NULL,
|
|
147
|
+
updated_by TEXT NOT NULL
|
|
148
|
+
);
|
|
149
|
+
CREATE TABLE IF NOT EXISTS chat_card (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
destination TEXT NOT NULL,
|
|
152
|
+
anchor_at TEXT NOT NULL,
|
|
153
|
+
message TEXT,
|
|
154
|
+
digest TEXT,
|
|
155
|
+
created_at TEXT NOT NULL
|
|
156
|
+
);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS chat_card_destination ON chat_card(destination, anchor_at);
|
|
158
|
+
CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
159
|
+
destination TEXT NOT NULL,
|
|
160
|
+
task_ref INTEGER NOT NULL,
|
|
161
|
+
card INTEGER NOT NULL REFERENCES chat_card(id),
|
|
162
|
+
joined_at TEXT NOT NULL,
|
|
163
|
+
PRIMARY KEY (destination, task_ref)
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
166
|
+
`;
|
|
167
|
+
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
168
|
+
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
169
|
+
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
170
|
+
export const INTEGRATIONS_SCHEMA = `
|
|
171
|
+
CREATE TABLE IF NOT EXISTS integration_check (
|
|
172
|
+
key TEXT PRIMARY KEY,
|
|
173
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),
|
|
174
|
+
account TEXT,
|
|
175
|
+
detail TEXT,
|
|
176
|
+
problem TEXT,
|
|
177
|
+
checked_at TEXT NOT NULL,
|
|
178
|
+
ok_at TEXT,
|
|
179
|
+
error TEXT,
|
|
180
|
+
error_at TEXT
|
|
181
|
+
);
|
|
182
|
+
`;
|
|
134
183
|
/** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
|
|
135
184
|
* folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
|
|
136
185
|
* and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
|
|
@@ -582,6 +631,11 @@ const LIFECYCLE_HOLD_WORDS = {
|
|
|
582
631
|
revision: "a plan revision awaiting approval",
|
|
583
632
|
stop: "a stopped attempt that has not been resumed",
|
|
584
633
|
};
|
|
634
|
+
/** Tasks filed this close together share one chat message. */
|
|
635
|
+
export const QUIET_GROUP_MS = 60_000;
|
|
636
|
+
export function isDigestTime(value) {
|
|
637
|
+
return /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(value);
|
|
638
|
+
}
|
|
585
639
|
/** The bounded activity vocabulary. Machine-authored — a model's prose never becomes one of these. */
|
|
586
640
|
export const RUN_PHASES = [
|
|
587
641
|
"agent-running",
|
|
@@ -3690,6 +3744,7 @@ function initializeStore(db, file) {
|
|
|
3690
3744
|
db.exec(APPROVAL_SCHEMA);
|
|
3691
3745
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3692
3746
|
db.exec(MONITORING_SCHEMA);
|
|
3747
|
+
db.exec(INTEGRATIONS_SCHEMA);
|
|
3693
3748
|
db.exec(SPEND_SCHEMA);
|
|
3694
3749
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
3695
3750
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
@@ -3701,6 +3756,7 @@ function initializeStore(db, file) {
|
|
|
3701
3756
|
// Sprint 8: the organisation policy (one row, or none: nothing restricted).
|
|
3702
3757
|
db.exec(POLICY_SCHEMA);
|
|
3703
3758
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3759
|
+
db.exec(QUIET_CHAT_SCHEMA);
|
|
3704
3760
|
mergeCheckTables(db);
|
|
3705
3761
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
3706
3762
|
// v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
|
|
@@ -3709,6 +3765,13 @@ function initializeStore(db, file) {
|
|
|
3709
3765
|
addColumn(db, "run_spend", "billing", "TEXT NOT NULL DEFAULT 'subscription' CHECK (billing IN ('subscription', 'api-key'))");
|
|
3710
3766
|
addColumn(db, "run_spend", "billing_fixed", "INTEGER NOT NULL DEFAULT 0");
|
|
3711
3767
|
migrate(db, preflight === null ? null : Math.abs(preflight));
|
|
3768
|
+
// Complete → pull request → merge: when a grant publishes, the project's "Merge when checks pass", and each
|
|
3769
|
+
// followed pull request's CI revisions and merge record (no version bump: additive only).
|
|
3770
|
+
addColumn(db, "publication_grant", "publish_on", "TEXT NOT NULL DEFAULT 'build' CHECK (publish_on IN ('build', 'complete'))");
|
|
3771
|
+
addColumn(db, "publication_grant", "merge_when_green", "INTEGER NOT NULL DEFAULT 0 CHECK (merge_when_green IN (0, 1))");
|
|
3772
|
+
db.exec(PULL_REQUEST_SCHEMA);
|
|
3773
|
+
// A pull request a flow's Pull request zone opened: the zone follows its CI, so Complete's follower leaves it alone.
|
|
3774
|
+
addColumn(db, "pull_request_follow", "flow_card", "INTEGER REFERENCES flow_card(id)");
|
|
3712
3775
|
addColumn(db, "flow_card", "source_json", "TEXT");
|
|
3713
3776
|
addColumn(db, "flow_card", "owner", "TEXT");
|
|
3714
3777
|
addColumn(db, "flow", "owner", "TEXT");
|
|
@@ -18108,9 +18171,9 @@ export class Store {
|
|
|
18108
18171
|
this.db
|
|
18109
18172
|
.prepare(`INSERT INTO publication_grant
|
|
18110
18173
|
(repo, github_repo, remote, head_prefix, base, capabilities, selector, draft, granted_by, granted_at,
|
|
18111
|
-
merge, merge_method, merge_delete_branch)
|
|
18112
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
18113
|
-
.run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0);
|
|
18174
|
+
merge, merge_method, merge_delete_branch, publish_on, merge_when_green)
|
|
18175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
18176
|
+
.run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0, grant.publishOn ?? "build", grant.mergeWhenGreen === true ? 1 : 0);
|
|
18114
18177
|
});
|
|
18115
18178
|
}
|
|
18116
18179
|
// ---- the merge grant's machinery (v21) ----------------------------------
|
|
@@ -21522,6 +21585,11 @@ export class Store {
|
|
|
21522
21585
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
21523
21586
|
.run(binding.id, binding.botId, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
|
|
21524
21587
|
}
|
|
21588
|
+
/** The newest confirmed message this notification became at one destination, if any. */
|
|
21589
|
+
telegramMessageOf(notification, destination) {
|
|
21590
|
+
const row = this.db.prepare("SELECT message_id FROM telegram_outbound_message WHERE notification = ? AND destination = ? ORDER BY CAST(message_id AS INTEGER) DESC LIMIT 1").get(notification, destination);
|
|
21591
|
+
return row === undefined ? null : String(row["message_id"]);
|
|
21592
|
+
}
|
|
21525
21593
|
finalizeTelegramDelivery(row, binding, owner, outcome, now) {
|
|
21526
21594
|
return this.transact(() => {
|
|
21527
21595
|
if (!this.telegramClaimHeld(row, owner, now) || row.destination !== this.telegramDestination(binding))
|
|
@@ -21615,6 +21683,77 @@ export class Store {
|
|
|
21615
21683
|
.prepare("UPDATE telegram_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE id = 1")
|
|
21616
21684
|
.run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
|
|
21617
21685
|
}
|
|
21686
|
+
// ---- quiet chat: each person's notification choice and each task's one message ----
|
|
21687
|
+
/** How chats reach this person: only when they are needed unless they chose every step. */
|
|
21688
|
+
notificationPreference(account) {
|
|
21689
|
+
const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at FROM notification_preference WHERE account = ?").get(account);
|
|
21690
|
+
return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null }
|
|
21691
|
+
: { mode: row["mode"] === "all" ? "all" : "quiet", digestAt: row["digest_at"] == null ? null : String(row["digest_at"]),
|
|
21692
|
+
digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]) };
|
|
21693
|
+
}
|
|
21694
|
+
/** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
|
|
21695
|
+
setNotificationPreference(account, change, by, now) {
|
|
21696
|
+
if (change.digestAt != null && !isDigestTime(change.digestAt))
|
|
21697
|
+
throw new Error("digest time must be HH:MM");
|
|
21698
|
+
const current = this.notificationPreference(account);
|
|
21699
|
+
const mode = change.mode ?? current.mode;
|
|
21700
|
+
const digestAt = change.digestAt === undefined ? current.digestAt : change.digestAt;
|
|
21701
|
+
this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)
|
|
21702
|
+
ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
|
|
21703
|
+
.run(account, mode, digestAt, current.digestOn, now.toISOString(), by);
|
|
21704
|
+
return this.notificationPreference(account);
|
|
21705
|
+
}
|
|
21706
|
+
/** Everyone who asked for an evening digest, with the local day theirs was last considered. */
|
|
21707
|
+
eveningDigestPeople() {
|
|
21708
|
+
return this.db.prepare("SELECT account, digest_at, digest_on FROM notification_preference WHERE digest_at IS NOT NULL ORDER BY account").all()
|
|
21709
|
+
.map(row => ({ account: String(row["account"]), digestAt: String(row["digest_at"]), digestOn: row["digest_on"] == null ? null : String(row["digest_on"]) }));
|
|
21710
|
+
}
|
|
21711
|
+
/** Claim one local day's digest for one person: true once, false if it was already considered. */
|
|
21712
|
+
claimEveningDigestDay(account, day) {
|
|
21713
|
+
return Number(this.db.prepare("UPDATE notification_preference SET digest_on = ? WHERE account = ? AND digest_at IS NOT NULL AND (digest_on IS NULL OR digest_on < ?)")
|
|
21714
|
+
.run(day, account, day).changes) === 1;
|
|
21715
|
+
}
|
|
21716
|
+
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
21717
|
+
* a minute of it (up to eight tasks), or starts its own. */
|
|
21718
|
+
chatCardFor(destination, taskRef, filedAt, now) {
|
|
21719
|
+
return this.transact(() => {
|
|
21720
|
+
const known = this.db.prepare("SELECT card FROM chat_card_task WHERE destination = ? AND task_ref = ?").get(destination, taskRef);
|
|
21721
|
+
if (known !== undefined)
|
|
21722
|
+
return this.chatCard(Number(known["card"]));
|
|
21723
|
+
const at = Date.parse(filedAt);
|
|
21724
|
+
const low = new Date(at - QUIET_GROUP_MS).toISOString(), high = new Date(at + QUIET_GROUP_MS).toISOString();
|
|
21725
|
+
const together = Number.isFinite(at) ? this.db.prepare(`SELECT id FROM chat_card c WHERE destination = ? AND anchor_at BETWEEN ? AND ?
|
|
21726
|
+
AND (SELECT COUNT(*) FROM chat_card_task t WHERE t.card = c.id) < 8 ORDER BY id DESC LIMIT 1`).get(destination, low, high) : undefined;
|
|
21727
|
+
const card = together !== undefined ? Number(together["id"])
|
|
21728
|
+
: Number(this.db.prepare("INSERT INTO chat_card (destination, anchor_at, created_at) VALUES (?, ?, ?)")
|
|
21729
|
+
.run(destination, Number.isFinite(at) ? new Date(at).toISOString() : now.toISOString(), now.toISOString()).lastInsertRowid);
|
|
21730
|
+
this.db.prepare("INSERT INTO chat_card_task (destination, task_ref, card, joined_at) VALUES (?, ?, ?, ?)").run(destination, taskRef, card, now.toISOString());
|
|
21731
|
+
return this.chatCard(card);
|
|
21732
|
+
});
|
|
21733
|
+
}
|
|
21734
|
+
chatCard(id) {
|
|
21735
|
+
const row = this.db.prepare("SELECT * FROM chat_card WHERE id = ?").get(id);
|
|
21736
|
+
if (row === undefined)
|
|
21737
|
+
return null;
|
|
21738
|
+
const tasks = this.db.prepare("SELECT task_ref FROM chat_card_task WHERE card = ? ORDER BY joined_at, task_ref").all(id).map(one => Number(one["task_ref"]));
|
|
21739
|
+
return { id, destination: String(row["destination"]), anchorAt: String(row["anchor_at"]), message: row["message"] == null ? null : String(row["message"]),
|
|
21740
|
+
digest: row["digest"] == null ? null : String(row["digest"]), tasks };
|
|
21741
|
+
}
|
|
21742
|
+
/** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
|
|
21743
|
+
latestTaskFact(taskRef) {
|
|
21744
|
+
const row = this.db.prepare("SELECT kind, created_at FROM notification WHERE task_ref = ? AND substr(dedupe_key, 1, ?) = ? ORDER BY id DESC LIMIT 1")
|
|
21745
|
+
.get(taskRef, LIFECYCLE_KEY_PREFIX.length, LIFECYCLE_KEY_PREFIX);
|
|
21746
|
+
return row === undefined ? null : { kind: String(row["kind"]), createdAt: String(row["created_at"]) };
|
|
21747
|
+
}
|
|
21748
|
+
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
21749
|
+
taskFactsSince(since, limit = 2000) {
|
|
21750
|
+
return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
|
|
21751
|
+
.all(since, limit).map(readNotification).reverse();
|
|
21752
|
+
}
|
|
21753
|
+
/** The message now showing this card, and what it shows. */
|
|
21754
|
+
setChatCardMessage(id, message, digest) {
|
|
21755
|
+
this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
|
|
21756
|
+
}
|
|
21618
21757
|
markTelegramDigestSent(now) {
|
|
21619
21758
|
this.db.prepare("UPDATE telegram_digest SET last_sent_at = ? WHERE id = 1").run(now.toISOString());
|
|
21620
21759
|
}
|
|
@@ -22430,6 +22569,8 @@ function readPublicationGrant(row) {
|
|
|
22430
22569
|
? null
|
|
22431
22570
|
: String(row["merge_method"]),
|
|
22432
22571
|
mergeDeleteBranch: Number(row["merge_delete_branch"] ?? 0) === 1,
|
|
22572
|
+
publishOn: row["publish_on"] === "complete" ? "complete" : "build",
|
|
22573
|
+
mergeWhenGreen: Number(row["merge_when_green"] ?? 0) === 1,
|
|
22433
22574
|
};
|
|
22434
22575
|
}
|
|
22435
22576
|
function readPublication(row) {
|
package/dist/surface.js
CHANGED
|
@@ -118,6 +118,8 @@ export const COMMAND_GUIDE = [
|
|
|
118
118
|
...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
|
|
119
119
|
// ---- the queue (agent surface) ----
|
|
120
120
|
{ invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
121
|
+
{ invocation: "integrations", synopsis: "every integration as Connected, Not set up or Broken, with its last success and error, what uses it and what to do; --saved skips new checks; never a secret", audience: "agent", agentMayInvoke: true, mutation: "none",
|
|
122
|
+
flags: [jsonFlag, dbFlag, { name: "saved", takesValue: false, meaning: "show the last checks without checking again" }] },
|
|
121
123
|
{ invocation: "ready", synopsis: "what could be dispatched right now (rows carry reservedFor)", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
122
124
|
{ invocation: "brief", synopsis: "with a saved central profile, --lead <id> --conversation <id> reads messages, proposals and saved work; --request-id inspects your saved receipt without resending; --local uses local DB catch-up, where --history selects the older operational report", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
123
125
|
{ invocation: "gaps", synopsis: "requirement gaps blocking dispatch", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag, repoFlag] },
|
|
@@ -349,6 +351,6 @@ export const COMMAND_GUIDE = [
|
|
|
349
351
|
operator("serve", "the operations console (HTTP)"),
|
|
350
352
|
operator("watch", "the unattended loop, kept running"),
|
|
351
353
|
operator("up", "console + worker + browser, one command"),
|
|
352
|
-
operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), and print the handoff"),
|
|
354
|
+
operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
|
|
353
355
|
operator("daemon", "install the loop under the OS service manager"),
|
|
354
356
|
];
|
|
@@ -123,7 +123,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
123
123
|
...(next ? ["", next] : []),
|
|
124
124
|
...(acceptance === null ? [] : [acceptance]),
|
|
125
125
|
...(delivery === null ? [] : ["", delivery]), "", `${projectLabel(project)} · #${run.id}`].join("\n");
|
|
126
|
-
return { text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
126
|
+
return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
127
127
|
link: { label, path: operatorHold || accessBlocked || (!built && run.outcome !== null)
|
|
128
128
|
? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, checksFailed ? "checks" : "summary") : chatControlHref("task", taskId) } };
|
|
129
129
|
}
|
package/dist/telegram.js
CHANGED
|
@@ -29,6 +29,7 @@ import { join } from "node:path";
|
|
|
29
29
|
import { validateNote } from "./decision.js";
|
|
30
30
|
import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
|
+
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
|
|
32
33
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
33
34
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
34
35
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -519,6 +520,8 @@ async function processConversations(store, botId, transport, owner, clock, repor
|
|
|
519
520
|
function factLinkLabel(path) {
|
|
520
521
|
if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
|
|
521
522
|
return "Inspect result";
|
|
523
|
+
if (/^\/t\/[^?#]+#merge$/.test(path))
|
|
524
|
+
return "Merge";
|
|
522
525
|
if (/^\/chat\?task=[^&]+&result=/.test(path))
|
|
523
526
|
return "Open result";
|
|
524
527
|
if (/^\/(?:chat\?task=|t\/)/.test(path))
|
|
@@ -563,6 +566,13 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
|
|
|
563
566
|
}
|
|
564
567
|
const digest = store.telegramDigest();
|
|
565
568
|
const now = clock();
|
|
569
|
+
// An evening digest someone asked for is due: record it as their own notification, delivered below.
|
|
570
|
+
try {
|
|
571
|
+
enqueueEveningDigests(store, now, evidenceRoot);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
report.problems.push("evening digest could not be prepared");
|
|
575
|
+
}
|
|
566
576
|
const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
|
|
567
577
|
// Every paired person is a destination of their own: each binding claims
|
|
568
578
|
// and settles its own rows under its own ceiling, in turn.
|
|
@@ -571,13 +581,16 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
|
|
|
571
581
|
}
|
|
572
582
|
async function deliverOutboxTo(store, botId, binding, transport, owner, clock, report, digest, digestDue, readProjects, canDeliver, phoneOrigin, evidenceRoot) {
|
|
573
583
|
const now = clock();
|
|
574
|
-
|
|
584
|
+
// Only when I'm needed (the default): a task's facts edit its one card and only what needs this person makes a
|
|
585
|
+
// new message, so the installation's routine-fact cadence does not apply. Every step keeps today's behaviour.
|
|
586
|
+
const quiet = store.notificationPreference(binding.approver).mode === "quiet";
|
|
587
|
+
const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, quiet || digestDue ? "all" : "urgent");
|
|
575
588
|
// Preserve ID order, flushing earlier routine facts before an urgent task
|
|
576
589
|
// update. An earlier failed/retrying row also fences later rows for that task.
|
|
577
590
|
const groups = [];
|
|
578
591
|
for (const row of claimed) {
|
|
579
592
|
const last = groups[groups.length - 1];
|
|
580
|
-
if (digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
|
|
593
|
+
if (!quiet && digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
|
|
581
594
|
last.push(row);
|
|
582
595
|
else
|
|
583
596
|
groups.push([row]);
|
|
@@ -638,12 +651,12 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
638
651
|
}
|
|
639
652
|
return null;
|
|
640
653
|
};
|
|
641
|
-
const sender = async (text, keyboard, messageRows = rows, entities) => {
|
|
654
|
+
const sender = async (text, keyboard, messageRows = rows, entities, silent) => {
|
|
642
655
|
// Finish every await before the synchronous fence and transport call.
|
|
643
656
|
const problem = (await readAccess()) ?? fence();
|
|
644
657
|
if (problem !== null)
|
|
645
658
|
return { ok: false, error: problem };
|
|
646
|
-
const sent = await send(transport, binding.chatId, text, keyboard, entities);
|
|
659
|
+
const sent = await send(transport, binding.chatId, text, keyboard, entities, silent);
|
|
647
660
|
if (!sent.ok) {
|
|
648
661
|
if (sent.retryAfter !== undefined)
|
|
649
662
|
store.deferTelegram(botId, new Date(clock().getTime() + sent.retryAfter * 1_000).toISOString());
|
|
@@ -694,7 +707,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
694
707
|
const after = (await readAccess()) ?? fence();
|
|
695
708
|
return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
|
|
696
709
|
};
|
|
697
|
-
const batched = digest.everyMs !== null && !isUrgent(rows[0]);
|
|
710
|
+
const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
|
|
698
711
|
const row = rows[0];
|
|
699
712
|
const progressRun = batched ? null : store.telegramProgressRun(row);
|
|
700
713
|
const progress = progressRun !== null && isTelegramProgressNotification(row);
|
|
@@ -731,15 +744,80 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
731
744
|
const after = (await readAccess()) ?? fence();
|
|
732
745
|
return after === null ? edited : { ok: false, error: after };
|
|
733
746
|
};
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
747
|
+
/** Quiet mode: the task's one card (or its group's), created silently and then only ever edited. */
|
|
748
|
+
const updateCard = async (fact) => {
|
|
749
|
+
const problem = (await readAccess()) ?? fence();
|
|
750
|
+
if (problem !== null)
|
|
751
|
+
return { ok: false, error: problem };
|
|
752
|
+
const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
|
|
753
|
+
const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
|
|
754
|
+
if (view === null)
|
|
755
|
+
return { ok: true, messageId: card.message };
|
|
756
|
+
let button = null;
|
|
757
|
+
try {
|
|
758
|
+
button = phoneLinkButton(phoneOrigin?.() ?? null, view.link);
|
|
759
|
+
}
|
|
760
|
+
catch { /* No trusted origin. */ }
|
|
761
|
+
const keyboard = button === null ? [] : [button];
|
|
762
|
+
const shown = createHash("sha256").update(JSON.stringify([view.text, view.entities, keyboard])).digest("hex");
|
|
763
|
+
if (card.message !== null && card.digest === shown) {
|
|
764
|
+
store.recordTelegramMessage(fact, binding, card.message, clock());
|
|
765
|
+
return { ok: true, messageId: card.message };
|
|
766
|
+
}
|
|
767
|
+
if (card.message !== null) {
|
|
768
|
+
const edited = await editProgress(transport, binding.chatId, card.message, view.text, keyboard, view.entities);
|
|
769
|
+
if (edited.ok) {
|
|
770
|
+
store.setChatCardMessage(card.id, card.message, shown);
|
|
771
|
+
store.recordTelegramMessage(fact, binding, card.message, clock());
|
|
772
|
+
const after = (await readAccess()) ?? fence();
|
|
773
|
+
return after === null ? edited : { ok: false, error: after };
|
|
774
|
+
}
|
|
775
|
+
if (edited.retryAfter !== undefined)
|
|
776
|
+
store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
|
|
777
|
+
// Only Telegram's definitive missing/uneditable answer permits a replacement card.
|
|
778
|
+
if (!edited.replace)
|
|
779
|
+
return edited;
|
|
780
|
+
}
|
|
781
|
+
const sent = await sender(view.text, keyboard, [fact], view.entities, true);
|
|
782
|
+
// A card that reached the chat is kept even if a later fence refused the acknowledgement: never a second card.
|
|
783
|
+
const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
|
|
784
|
+
if (placed !== null)
|
|
785
|
+
store.setChatCardMessage(card.id, placed, shown);
|
|
786
|
+
return sent;
|
|
787
|
+
};
|
|
788
|
+
const quietOutcome = async (fact) => {
|
|
789
|
+
const card = await updateCard(fact);
|
|
790
|
+
if (!card.ok)
|
|
791
|
+
return card;
|
|
792
|
+
if (!needsPerson(fact))
|
|
793
|
+
return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
|
|
794
|
+
const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
|
|
795
|
+
if (readyRun !== null) {
|
|
796
|
+
const ping = readyPingView(store, store.getRun(readyRun.id), fact.taskId, fact.project, clock(), evidenceRoot);
|
|
797
|
+
let button = null;
|
|
798
|
+
try {
|
|
799
|
+
button = phoneLinkButton(phoneOrigin?.() ?? null, ping.link);
|
|
800
|
+
}
|
|
801
|
+
catch { /* No trusted origin. */ }
|
|
802
|
+
const sent = await sender(ping.text, button === null ? undefined : [button], [fact], ping.entities);
|
|
803
|
+
return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
|
|
804
|
+
}
|
|
805
|
+
return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
|
|
806
|
+
};
|
|
807
|
+
let outcome;
|
|
808
|
+
if (quiet && rows.length === 1 && isTaskFact(row))
|
|
809
|
+
outcome = await quietOutcome(row);
|
|
810
|
+
else {
|
|
811
|
+
// A failure or decision still gets its own alert. Refresh an existing
|
|
812
|
+
// card first so it does not keep saying the build is running.
|
|
813
|
+
if (!progress && progressRun !== null)
|
|
814
|
+
await updateProgress(true);
|
|
815
|
+
const updated = progress ? await updateProgress(false) : null;
|
|
816
|
+
outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
|
|
817
|
+
: rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
|
|
818
|
+
? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
|
|
819
|
+
: await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
|
|
820
|
+
}
|
|
743
821
|
const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
|
|
744
822
|
const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
|
|
745
823
|
// A skipped screenshot settles its row but nothing reached the phone.
|
|
@@ -934,7 +1012,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
934
1012
|
}
|
|
935
1013
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) };
|
|
936
1014
|
}
|
|
937
|
-
async function send(transport, chatId, text, keyboard, entities) {
|
|
1015
|
+
async function send(transport, chatId, text, keyboard, entities, silent) {
|
|
938
1016
|
// No markup parsing. Only machine-selected heading ranges may be bold;
|
|
939
1017
|
// agent text remains literal and URLs never trigger link previews.
|
|
940
1018
|
let answer;
|
|
@@ -944,6 +1022,8 @@ async function send(transport, chatId, text, keyboard, entities) {
|
|
|
944
1022
|
text,
|
|
945
1023
|
...(entities === undefined ? {} : { entities }),
|
|
946
1024
|
link_preview_options: { is_disabled: true },
|
|
1025
|
+
// A new quiet card arrives without a sound; only a ping should buzz.
|
|
1026
|
+
...(silent === true ? { disable_notification: true } : {}),
|
|
947
1027
|
...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
|
|
948
1028
|
});
|
|
949
1029
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
|
|
2
|
+
export declare function shortWhen(iso: string, now?: Date): string;
|
|
3
|
+
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
4
|
+
export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
|
|
5
|
+
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
6
|
+
export declare const utcMinute: (iso: string) => string;
|
|
7
|
+
/** Both the desk and phone forms of a UTC stamp. */
|
|
8
|
+
export declare const whenUtc: (iso: string | null, now?: Date) => string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
|
|
3
|
+
* one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
|
|
4
|
+
* UTC to the minute, so the short form is counted in UTC too. The CSS that picks one lives in the shared page
|
|
5
|
+
* stylesheet (`.so-when-*` in serve.ts).
|
|
6
|
+
*/
|
|
7
|
+
const escape = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
/** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
|
|
11
|
+
export function shortWhen(iso, now = new Date()) {
|
|
12
|
+
const at = new Date(iso);
|
|
13
|
+
if (Number.isNaN(at.getTime()))
|
|
14
|
+
return iso;
|
|
15
|
+
const clock = iso.length >= 16 && iso[10] === "T" ? iso.slice(11, 16) : at.toISOString().slice(11, 16);
|
|
16
|
+
const days = Math.floor(at.getTime() / DAY_MS) - Math.floor(now.getTime() / DAY_MS);
|
|
17
|
+
if (days === 0)
|
|
18
|
+
return clock;
|
|
19
|
+
if (days === -1)
|
|
20
|
+
return `Yesterday ${clock}`;
|
|
21
|
+
if (days === 1)
|
|
22
|
+
return `Tomorrow ${clock}`;
|
|
23
|
+
const date = `${MONTHS[at.getUTCMonth()]} ${at.getUTCDate()}`;
|
|
24
|
+
return at.getUTCFullYear() === now.getUTCFullYear() ? date : `${date} ${at.getUTCFullYear()}`;
|
|
25
|
+
}
|
|
26
|
+
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
27
|
+
export function whenHtml(iso, full, now = new Date()) {
|
|
28
|
+
if (iso === null || iso === "")
|
|
29
|
+
return "";
|
|
30
|
+
const words = escape(full);
|
|
31
|
+
return `<time datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
|
|
32
|
+
}
|
|
33
|
+
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
34
|
+
export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
|
|
35
|
+
/** Both the desk and phone forms of a UTC stamp. */
|
|
36
|
+
export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ? "" : whenHtml(iso, utcMinute(iso), now);
|