toolroll 0.9.95 → 0.9.97
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 +13 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/card-render.d.ts +18 -1
- package/dist/card-render.js +40 -3
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/teardown-git.js +33 -3
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -13,6 +13,8 @@ import { codeMapRemoteChanged, codeMapShapeProblem, forgetCodeMap, V152_ADDED_CO
|
|
|
13
13
|
import { readStamps, stampDigest, stampRefusal, V154_SCHEMA } from "./check-stamp.js";
|
|
14
14
|
import { FINDING_SOURCES, FINDING_SOURCES_BEFORE_LEAD, V155_SCHEMA, V155_STEPS } from "./fleet-schema.js";
|
|
15
15
|
import { V156_SCHEMA, V156_STEPS } from "./fallback-schema.js";
|
|
16
|
+
import { V158_ADDED_COLUMNS, V158_SCHEMA, V158_STEPS, LEAD_FAILURE_DETAILS, LEAD_ROUTES } from "./lead-latency-schema.js";
|
|
17
|
+
import { V157_SCHEMA, V157_STEPS } from "./chat-live-schema.js";
|
|
16
18
|
import { refArchived, taskArchived } from "./task-archive.js";
|
|
17
19
|
export { refArchived, taskArchived } from "./task-archive.js";
|
|
18
20
|
import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
|
|
@@ -1142,7 +1144,11 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
1142
1144
|
// v155 adds, and changes no saved row (docs/plans/fleet-follow-through.md): findings and their answers, check repairs,
|
|
1143
1145
|
// before-and-after evidence and provider captures (V155_SCHEMA, new tables only; fleet-schema.ts).
|
|
1144
1146
|
// v156 adds fallback chains (V156_SCHEMA, new tables only; fallback-schema.ts), after v155.
|
|
1145
|
-
|
|
1147
|
+
// v157 is the chat apps' live placeholders (chat-apps slice; merged separately).
|
|
1148
|
+
// v158 adds, and changes no saved row (docs/plans/responsive-chat.md, slice 5): the tools each lead step asked for, an
|
|
1149
|
+
// exact failure detail on a step and a reply, which model answered a reply (mate_turn.route), and the quick-answer
|
|
1150
|
+
// switch (lead_quick_setting). V158_SCHEMA, V158_ADDED_COLUMNS (lead-latency-schema.ts): saved rows read NULL.
|
|
1151
|
+
export const SCHEMA_VERSION = 158;
|
|
1146
1152
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
1147
1153
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
1148
1154
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -1275,7 +1281,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
1275
1281
|
* unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
|
|
1276
1282
|
* (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
|
|
1277
1283
|
*/
|
|
1278
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156]);
|
|
1284
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158]);
|
|
1279
1285
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
1280
1286
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
1281
1287
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -1936,6 +1942,8 @@ export const V153_STEPS = Object.freeze(["plain-tables", "plain-columns"]);
|
|
|
1936
1942
|
export const V154_STEPS = Object.freeze(["check-stamps"]);
|
|
1937
1943
|
export { V155_STEPS };
|
|
1938
1944
|
export { V156_STEPS };
|
|
1945
|
+
export { V157_STEPS };
|
|
1946
|
+
export { V158_STEPS };
|
|
1939
1947
|
/**
|
|
1940
1948
|
* v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
|
|
1941
1949
|
* release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
|
|
@@ -5274,6 +5282,17 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
5274
5282
|
// v156: fallback chains (new tables only). Idempotent, as v155's steps are.
|
|
5275
5283
|
db.exec(V156_SCHEMA);
|
|
5276
5284
|
step("fallback-tables");
|
|
5285
|
+
// v157: live replies in Slack, Discord and Teams (new tables only). Idempotent, as v156's step is.
|
|
5286
|
+
db.exec(V157_SCHEMA);
|
|
5287
|
+
step("chat-live-tables");
|
|
5288
|
+
// v158: the lead's latency records and its quick-answer switch (V158_SCHEMA, V158_ADDED_COLUMNS: saved rows read
|
|
5289
|
+
// NULL). Idempotent, as v156's and v157's steps are.
|
|
5290
|
+
db.exec(V158_SCHEMA);
|
|
5291
|
+
step("lead-quick-setting");
|
|
5292
|
+
for (const [table, columns] of Object.entries(V158_ADDED_COLUMNS))
|
|
5293
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
5294
|
+
addColumn(db, table, column, definition);
|
|
5295
|
+
step("lead-latency-columns");
|
|
5277
5296
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
5278
5297
|
// nothing to reclaim.
|
|
5279
5298
|
if (!fresh)
|
|
@@ -13210,9 +13229,9 @@ export class Store {
|
|
|
13210
13229
|
const changed = this.db
|
|
13211
13230
|
.prepare(`UPDATE chat_turn SET state = ?, failure_reason = ?, tokens_in = ?, tokens_out = ?,
|
|
13212
13231
|
settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, cache_read_tokens = ?, cache_write_tokens = ?,
|
|
13213
|
-
model_reported = ?, provider_turns = ?, reported_cost_usd = ?, billing_reported = ?, finished_at = ?
|
|
13232
|
+
model_reported = ?, provider_turns = ?, reported_cost_usd = ?, billing_reported = ?, tools = ?, failure_detail = ?, finished_at = ?
|
|
13214
13233
|
WHERE id = ? AND generation = ? AND state IN ('queued','running')`)
|
|
13215
|
-
.run(outcome.state, outcome.failureReason ?? null, outcome.tokensIn ?? null, outcome.tokensOut ?? null, outcome.settledMicrousd, outcome.unknownSpend === true ? 1 : 0, outcome.replyBytes ?? null, outcome.candidateCount ?? null, outcome.cacheReadTokens ?? null, outcome.cacheWriteTokens ?? null, reportedModelOf(outcome.modelReported), providerTurnsOf(outcome.providerTurns), reportedCostUsdOf(outcome.reportedCostUsd), outcome.billingReported === "subscription" || outcome.billingReported === "api-key" ? outcome.billingReported : null, now.toISOString(), id, generation);
|
|
13234
|
+
.run(outcome.state, outcome.failureReason ?? null, outcome.tokensIn ?? null, outcome.tokensOut ?? null, outcome.settledMicrousd, outcome.unknownSpend === true ? 1 : 0, outcome.replyBytes ?? null, outcome.candidateCount ?? null, outcome.cacheReadTokens ?? null, outcome.cacheWriteTokens ?? null, reportedModelOf(outcome.modelReported), providerTurnsOf(outcome.providerTurns), reportedCostUsdOf(outcome.reportedCostUsd), outcome.billingReported === "subscription" || outcome.billingReported === "api-key" ? outcome.billingReported : null, outcome.tools === undefined ? null : JSON.stringify(outcome.tools.filter(name => /^[a-z_]{1,64}$/.test(name)).slice(0, 32)), outcome.failureDetail != null && LEAD_FAILURE_DETAILS.includes(outcome.failureDetail) ? outcome.failureDetail : null, now.toISOString(), id, generation);
|
|
13216
13235
|
return Number(changed.changes) === 1;
|
|
13217
13236
|
}
|
|
13218
13237
|
/**
|
|
@@ -18016,9 +18035,9 @@ export class Store {
|
|
|
18016
18035
|
return false;
|
|
18017
18036
|
const settled = outcome.unknownSpend === true ? Math.max(Number(before["reserved"]), outcome.settledMicrousd) : outcome.settledMicrousd;
|
|
18018
18037
|
const changed = this.db
|
|
18019
|
-
.prepare(`UPDATE mate_turn SET state = ?, settled_microusd = ?, tokens_in = ?, tokens_out = ?, failure_reason = ?, finished_at = ?
|
|
18038
|
+
.prepare(`UPDATE mate_turn SET state = ?, settled_microusd = ?, tokens_in = ?, tokens_out = ?, failure_reason = ?, failure_detail = ?, finished_at = ?
|
|
18020
18039
|
WHERE id = ? AND generation = ? AND state IN ('queued','running')`)
|
|
18021
|
-
.run(outcome.state, settled, outcome.tokensIn, outcome.tokensOut, outcome.failureReason ?? null, now.toISOString(), id, generation);
|
|
18040
|
+
.run(outcome.state, settled, outcome.tokensIn, outcome.tokensOut, outcome.failureReason ?? null, outcome.failureDetail != null && LEAD_FAILURE_DETAILS.includes(outcome.failureDetail) ? outcome.failureDetail : null, now.toISOString(), id, generation);
|
|
18022
18041
|
if (Number(changed.changes) !== 1)
|
|
18023
18042
|
return false;
|
|
18024
18043
|
this.db.prepare("UPDATE lead_session SET spent_microusd = spent_microusd + ? WHERE id = ?").run(settled, Number(before["session"]));
|
|
@@ -21235,6 +21254,8 @@ function readLeadTurn(row) {
|
|
|
21235
21254
|
tokensIn: maybeN("tokens_in"),
|
|
21236
21255
|
tokensOut: maybeN("tokens_out"),
|
|
21237
21256
|
failureReason: maybe("failure_reason"),
|
|
21257
|
+
failureDetail: LEAD_FAILURE_DETAILS.includes(row["failure_detail"]) ? row["failure_detail"] : null,
|
|
21258
|
+
route: LEAD_ROUTES.includes(row["route"]) ? row["route"] : null,
|
|
21238
21259
|
};
|
|
21239
21260
|
}
|
|
21240
21261
|
function readVerifyCommand(row) {
|
|
@@ -23173,3 +23194,35 @@ function widenFindingSources(db) {
|
|
|
23173
23194
|
const columns = db.prepare("PRAGMA table_info(finding)").all().map(one => String(one["name"]));
|
|
23174
23195
|
rebuildExact(db, "finding", named(stored), named(stored.replace(FINDING_SOURCES_BEFORE_LEAD, FINDING_SOURCES)), columns);
|
|
23175
23196
|
}
|
|
23197
|
+
/** A thread's newest lead turn, whatever its state; null before its first. What a surface reads back to say how the
|
|
23198
|
+
* last reply ended after a restart (docs/plans/responsive-chat.md §3.4): the saved row, never process memory. */
|
|
23199
|
+
export function latestLeadTurnOfThread(store, thread) {
|
|
23200
|
+
const row = store.handle.prepare("SELECT * FROM mate_turn WHERE thread = ? ORDER BY id DESC LIMIT 1").get(thread);
|
|
23201
|
+
return row === undefined ? null : readLeadTurn(row);
|
|
23202
|
+
}
|
|
23203
|
+
/** Settings → Lead's "Quick answers use a faster model" (v158): absent reads off, the shipped default
|
|
23204
|
+
* (docs/plans/responsive-chat.md §8: on only once the lead-answer eval passes). */
|
|
23205
|
+
export function leadQuickAnswersOn(store) {
|
|
23206
|
+
const row = store.handle.prepare("SELECT enabled FROM lead_quick_setting WHERE id = 1").get();
|
|
23207
|
+
return row !== undefined && Number(row["enabled"]) === 1;
|
|
23208
|
+
}
|
|
23209
|
+
/** Turn quick answers on or off, recording who and when (the caller proved the person may change the lead's settings). */
|
|
23210
|
+
export function setLeadQuickAnswers(store, enabled, by, now) {
|
|
23211
|
+
store.handle.prepare(`INSERT INTO lead_quick_setting (id, enabled, updated_by, updated_at) VALUES (1, ?, ?, ?)
|
|
23212
|
+
ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, updated_by = excluded.updated_by, updated_at = excluded.updated_at`).run(enabled ? 1 : 0, by, now.toISOString());
|
|
23213
|
+
}
|
|
23214
|
+
/** Which model answers a running reply (v158): written at admission and when a quick step hands over to the main model.
|
|
23215
|
+
* Only the turn's own running generation may write it. */
|
|
23216
|
+
export function setLeadTurnRoute(store, turn, generation, route) {
|
|
23217
|
+
if (!LEAD_ROUTES.includes(route))
|
|
23218
|
+
return false;
|
|
23219
|
+
return Number(store.handle.prepare("UPDATE mate_turn SET route = ? WHERE id = ? AND generation = ? AND state = 'running'").run(route, turn, generation).changes) === 1;
|
|
23220
|
+
}
|
|
23221
|
+
/** Whether a new message in this thread may be the answer to the lead's own question (ask_owner): the thread's newest
|
|
23222
|
+
* reply asked one, less than LEAD_ASK_TTL_MS ago. Conservative on purpose: the quick-answer router (§8) keeps such a
|
|
23223
|
+
* message on the main model. */
|
|
23224
|
+
export function leadAskAwaitsAnswer(store, thread, now) {
|
|
23225
|
+
const row = store.handle.prepare(`SELECT a.created_at AS at FROM lead_message m JOIN lead_ask a ON a.turn = m.turn JOIN mate_turn t ON t.id = m.turn
|
|
23226
|
+
WHERE m.id = (SELECT MAX(id) FROM lead_message WHERE thread = ? AND role = 'assistant') AND t.state = 'answered'`).get(thread);
|
|
23227
|
+
return row !== undefined && Date.parse(String(row["at"])) + LEAD_ASK_TTL_MS > now.getTime();
|
|
23228
|
+
}
|
|
@@ -27,6 +27,7 @@ export type SubscriptionLeadRunner = (request: SubscriptionLeadRequest) => Promi
|
|
|
27
27
|
} | {
|
|
28
28
|
ok: false;
|
|
29
29
|
problem: string;
|
|
30
|
+
quiet?: boolean;
|
|
30
31
|
}>;
|
|
31
32
|
/** The model-visible request. Tool results are already redacted by leadView. */
|
|
32
33
|
export declare function composeSubscriptionLeadPrompt(request: Omit<SubscriptionLeadRequest, "provider" | "model" | "timeoutMs" | "signal">): string;
|
|
@@ -49,4 +50,5 @@ export declare function performSubscriptionLeadRequest(request: SubscriptionLead
|
|
|
49
50
|
} | {
|
|
50
51
|
ok: false;
|
|
51
52
|
problem: string;
|
|
53
|
+
quiet?: boolean;
|
|
52
54
|
}>;
|
|
@@ -260,8 +260,10 @@ export async function performSubscriptionLeadRequest(request, runner = run) {
|
|
|
260
260
|
processGroup: true,
|
|
261
261
|
...(streaming ? { onStdout: claudeStreamReader(request.onText) } : {}),
|
|
262
262
|
});
|
|
263
|
+
// Whether the harness wrote anything at all before its deadline (v158, responsive-chat.md §7.5): a silent one never
|
|
264
|
+
// started answering; one with output was answering slowly. Recorded, never shown.
|
|
263
265
|
if (result.timedOut)
|
|
264
|
-
return { ok: false, problem: "timeout" };
|
|
266
|
+
return { ok: false, problem: "timeout", quiet: result.stdout.trim() === "" };
|
|
265
267
|
if (result.notFound)
|
|
266
268
|
return { ok: false, problem: "not-found" };
|
|
267
269
|
// v105: a streamed Claude turn says its plan's usage windows, and (once it answered) how this computer's Claude bills
|
package/dist/teams-chat.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { ChatState, type ChatIdentity } from "./chat-delivery-state.js";
|
|
1
|
+
import { ChatState, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
|
|
2
|
+
import { type ChatAckHooks, type ChatLiveTransport, type LiveReplies } from "./chat-live.js";
|
|
2
3
|
import { type ChatDeliveryOptions } from "./chat-delivery.js";
|
|
3
4
|
import { type TeamsApi } from "./teams-api.js";
|
|
4
|
-
export type TeamsChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal"> & {
|
|
5
|
+
export type TeamsChatOptions = Omit<ChatDeliveryOptions, "state" | "label" | "member" | "partSize" | "maxProposal" | "live"> & {
|
|
5
6
|
api: TeamsApi;
|
|
7
|
+
/** The live "Working on it…" message (chat-live.ts): on in the worker. */
|
|
8
|
+
liveReplies?: LiveReplies;
|
|
6
9
|
};
|
|
7
10
|
export declare const teamsUserId: (v: unknown) => v is string;
|
|
8
11
|
/** A conversation id; a channel post's carries its thread as ";messageid=<first message>" (v89). */
|
|
@@ -27,3 +30,7 @@ export declare function teamsCard(text: string, actions: Record<string, unknown>
|
|
|
27
30
|
export declare function teamsMessages(text: string, markdown: boolean, last: (text: string) => Record<string, unknown>): Record<string, unknown>[];
|
|
28
31
|
/** Send the next pending part: a plain message, an edit, or a card with its tokens. */
|
|
29
32
|
export declare function deliverTeamsPart(options: TeamsChatOptions): Promise<boolean>;
|
|
33
|
+
/** The live message for one owner message: a plain Markdown activity, replaced whole (PUT) on every edit. */
|
|
34
|
+
export declare function teamsLive(options: Pick<TeamsChatOptions, "api" | "identity">, state: ChatState, event: ChatEvent, spacingMs?: number): ChatLiveTransport | null;
|
|
35
|
+
/** Teams gives bots no reactions: the arrival ack is "typing…", with nothing to take off. */
|
|
36
|
+
export declare function teamsAckHooks(options: Pick<TeamsChatOptions, "api" | "identity">, state: ChatState): ChatAckHooks;
|
package/dist/teams-chat.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resultShotsPruned } from "./result-shots.js";
|
|
|
4
4
|
import { chatQuestionButtons } from "./subagent-question.js";
|
|
5
5
|
import { chatAskButtons } from "./chat-ask.js";
|
|
6
6
|
import { ChatDeliveryError, ChatState, chatHash, partContent } from "./chat-delivery-state.js";
|
|
7
|
+
import { isLiveMessage, liveSpacingOf, underCooldown } from "./chat-live.js";
|
|
7
8
|
import { channelAccess, chatObject as object, heldForWatch, planChatNotifications, planRoomMessages, processChatEvent, splitChatText } from "./chat-delivery.js";
|
|
8
9
|
import { PLATFORM_LIMITS } from "./text-limits.js";
|
|
9
10
|
import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
|
|
@@ -14,6 +15,9 @@ import { admitChatEvent } from "./chat-admission.js";
|
|
|
14
15
|
import { chatResultHref } from "./chat-controls.js";
|
|
15
16
|
import { TeamsError } from "./teams-api.js";
|
|
16
17
|
import { readTeamsSubmit } from "./contracts/teams-callback.js";
|
|
18
|
+
import { readChatPart } from "./contracts/chat-content.js";
|
|
19
|
+
const TEAMS_GONE = "Teams refused the request (404)";
|
|
20
|
+
const TEAMS_SLOW = "Teams asked to slow down";
|
|
17
21
|
const serviceKey = (conversation) => `serviceUrl:${conversation}`;
|
|
18
22
|
export const teamsUserId = (v) => typeof v === "string" && /^29:[A-Za-z0-9_=-]{10,200}$/.test(v);
|
|
19
23
|
/** A conversation id; a channel post's carries its thread as ";messageid=<first message>" (v89). */
|
|
@@ -43,6 +47,7 @@ export async function teamsMember(api, state, installation, member, channel) {
|
|
|
43
47
|
return false;
|
|
44
48
|
}
|
|
45
49
|
}
|
|
50
|
+
const teamsRateLimited = (error) => error instanceof TeamsError && error.code === TEAMS_SLOW ? error.retryMs : null;
|
|
46
51
|
export const teamsDelivery = (options) => {
|
|
47
52
|
const state = new ChatState(options.store, "teams");
|
|
48
53
|
return {
|
|
@@ -52,12 +57,13 @@ export const teamsDelivery = (options) => {
|
|
|
52
57
|
member: (member, channel) => teamsMember(options.api, state, options.identity.installation, member, channel),
|
|
53
58
|
// "Typing…" while the lead works; Teams gives bots no reactions, so there is no 👍 here.
|
|
54
59
|
warm: (event) => ({
|
|
55
|
-
typing: async () => {
|
|
60
|
+
typing: underCooldown(state, options.identity.installation, teamsRateLimited, () => options.clock?.() ?? new Date(), async () => {
|
|
56
61
|
const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
|
|
57
62
|
if (serviceUrl !== null)
|
|
58
63
|
await options.api("POST", serviceUrl, `/v3/conversations/${encodeURIComponent(event.channel)}/activities`, { type: "typing" });
|
|
59
|
-
},
|
|
64
|
+
}),
|
|
60
65
|
}),
|
|
66
|
+
...(options.liveReplies ? { live: (event) => teamsLive(options, state, event, liveSpacingOf(options.liveReplies)) } : {}),
|
|
61
67
|
partSize: 3500,
|
|
62
68
|
maxProposal: 6000,
|
|
63
69
|
};
|
|
@@ -246,8 +252,50 @@ export async function deliverTeamsPart(options) {
|
|
|
246
252
|
state.dropPart(row.id, error.message);
|
|
247
253
|
return true;
|
|
248
254
|
}
|
|
255
|
+
// The live message the reply was to replace is gone: the reply goes out as a new message.
|
|
256
|
+
const saved = readChatPart(row.payload), content = saved.ok ? saved.value : null;
|
|
257
|
+
if (content?.edit !== undefined && error instanceof TeamsError && error.code === TEAMS_GONE && isLiveMessage(state, row.event, content.edit)) {
|
|
258
|
+
const { edit: _edit, ...asNew } = content;
|
|
259
|
+
state.repaint(row.id, asNew);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
249
262
|
const problem = error instanceof TeamsError ? error : new TeamsError("Teams delivery failed", 15_000, true);
|
|
250
263
|
state.partFailed(row.id, { problem: problem.message, until: new Date(now.getTime() + problem.retryMs).toISOString(), uncertain: problem.uncertain }, 20);
|
|
251
264
|
return true;
|
|
252
265
|
}
|
|
253
266
|
}
|
|
267
|
+
/** The live message for one owner message: a plain Markdown activity, replaced whole (PUT) on every edit. */
|
|
268
|
+
export function teamsLive(options, state, event, spacingMs) {
|
|
269
|
+
const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
|
|
270
|
+
if (serviceUrl === null)
|
|
271
|
+
return null;
|
|
272
|
+
const activities = `/v3/conversations/${encodeURIComponent(event.channel)}/activities`;
|
|
273
|
+
const body = (text) => ({ type: "message", text, textFormat: "markdown" });
|
|
274
|
+
return {
|
|
275
|
+
platform: "teams",
|
|
276
|
+
...(spacingMs === undefined ? {} : { spacingMs }),
|
|
277
|
+
limit: PLATFORM_LIMITS.teams - 200,
|
|
278
|
+
create: async (text) => {
|
|
279
|
+
const answer = await options.api("POST", serviceUrl, activities, body(text));
|
|
280
|
+
if (typeof answer.id !== "string" || answer.id === "")
|
|
281
|
+
throw new TeamsError("Teams did not confirm the message", 15_000, true);
|
|
282
|
+
return answer.id;
|
|
283
|
+
},
|
|
284
|
+
edit: async (message, text) => { await options.api("PUT", serviceUrl, `${activities}/${encodeURIComponent(message)}`, body(text)); },
|
|
285
|
+
rateLimited: error => error instanceof TeamsError && error.code === TEAMS_SLOW ? error.retryMs : null,
|
|
286
|
+
uncertain: error => !(error instanceof TeamsError) || error.uncertain,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** Teams gives bots no reactions: the arrival ack is "typing…", with nothing to take off. */
|
|
290
|
+
export function teamsAckHooks(options, state) {
|
|
291
|
+
return {
|
|
292
|
+
typing: async (event) => {
|
|
293
|
+
const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
|
|
294
|
+
if (serviceUrl !== null)
|
|
295
|
+
await options.api("POST", serviceUrl, `/v3/conversations/${encodeURIComponent(event.channel)}/activities`, { type: "typing" });
|
|
296
|
+
},
|
|
297
|
+
refused: () => false,
|
|
298
|
+
settled: () => false,
|
|
299
|
+
rateLimited: error => error instanceof TeamsError && error.code === TEAMS_SLOW ? error.retryMs : null,
|
|
300
|
+
};
|
|
301
|
+
}
|
package/dist/teams.js
CHANGED
|
@@ -8,7 +8,8 @@ import { abortablePause, workChatQueue } from "./chat-follow.js";
|
|
|
8
8
|
import { ChatState } from "./chat-delivery-state.js";
|
|
9
9
|
import { chatObject as object } from "./chat-delivery.js";
|
|
10
10
|
import { loadTeamsCredentials, teamsAccessToken, teamsApi, verifyTeamsToken, TeamsError } from "./teams-api.js";
|
|
11
|
-
import { deliverTeamsPart, planTeamsNotifications, planTeamsRooms, processTeamsEvent, receiveTeams } from "./teams-chat.js";
|
|
11
|
+
import { deliverTeamsPart, planTeamsNotifications, planTeamsRooms, processTeamsEvent, receiveTeams, teamsAckHooks } from "./teams-chat.js";
|
|
12
|
+
import { runChatAcks } from "./chat-live.js";
|
|
12
13
|
import { limitWords } from "./request-budget.js";
|
|
13
14
|
export const TEAMS_ACTIVITY_BYTES = 64 * 1024;
|
|
14
15
|
export const TEAMS_MESSAGES_PATH = "/teams/messages";
|
|
@@ -95,7 +96,7 @@ export async function followTeams(options) {
|
|
|
95
96
|
options.signal.addEventListener("abort", stop, { once: true });
|
|
96
97
|
const heartbeat = setInterval(() => { if (!same() || !state.lease(credentials.installation, owner, new Date()))
|
|
97
98
|
stop(); }, 10_000);
|
|
98
|
-
const chat = { ...options, identity: credentials, owner, api: teamsApi(credentials, options.fetcher), canNotify: options.notifications, current: same };
|
|
99
|
+
const chat = { ...options, identity: credentials, owner, api: teamsApi(credentials, options.fetcher), canNotify: options.notifications, current: same, liveReplies: true };
|
|
99
100
|
try {
|
|
100
101
|
// Connected means the app can sign in; inbound reachability is the operator's endpoint to verify.
|
|
101
102
|
try {
|
|
@@ -107,6 +108,8 @@ export async function followTeams(options) {
|
|
|
107
108
|
await pause(30_000);
|
|
108
109
|
continue;
|
|
109
110
|
}
|
|
111
|
+
// "typing…" on each owner message as it arrives (the receiver saves it, maybe in another process), beside the queue.
|
|
112
|
+
runChatAcks({ state, installation: credentials.installation, owner, current: same, hooks: teamsAckHooks(chat, state), same, signal: options.signal });
|
|
110
113
|
await workChatQueue({
|
|
111
114
|
same,
|
|
112
115
|
ready: () => !(state.retryAt(credentials.installation) > new Date().toISOString()),
|
package/dist/teardown-git.js
CHANGED
|
@@ -737,8 +737,8 @@ const WORKTREE_STATE_DIRS = ["sequencer", "rebase-merge", "rebase-apply"];
|
|
|
737
737
|
* state, a split index's shared files); and the object store (the common `objects`, and a submodule git directory's
|
|
738
738
|
* own under modules/): its fan-out folders' entries for links, `info` and `pack` all the way down, but not each loose
|
|
739
739
|
* object (a big repository has very many, and hard-linked objects are legitimate). Any link is refused, so is a folder
|
|
740
|
-
* that can't be listed, and so is any file walked deep with a second hard link (git appends to a log in place)
|
|
741
|
-
*
|
|
740
|
+
* that can't be listed, and so is any file walked deep with a second hard link (git appends to a log in place), but for
|
|
741
|
+
* another checkout's index (siblingIndexes: still never a link). The exemptions are by place only: an `objects` anywhere else (under refs/, logs/, worktrees/) is walked like the rest. For each one found, where it leads (for an incident's record).
|
|
742
742
|
*
|
|
743
743
|
* Checked when the view is made and again after git ran; between the two, a process that outlived the agent's turn
|
|
744
744
|
* can still swap one of these for a link and have git write through it mid-command. That window is an accepted
|
|
@@ -749,6 +749,7 @@ function pinned(dirs) {
|
|
|
749
749
|
const seen = new Set();
|
|
750
750
|
let budget = 400_000;
|
|
751
751
|
const stores = objectStores(dirs.common);
|
|
752
|
+
const siblings = siblingIndexes(dirs);
|
|
752
753
|
/** `deep`: every entry all the way down, each file held to one hard link. `shallow`: the folder's own entries
|
|
753
754
|
* checked for links only. `store`: an object store (objects/: its fan-out folders shallow, `info` and `pack` deep
|
|
754
755
|
* without the hard-link test). A link, or a folder that can't be listed, is swapped. */
|
|
@@ -776,7 +777,10 @@ function pinned(dirs) {
|
|
|
776
777
|
swapped.push({ path, leadsTo: real });
|
|
777
778
|
return;
|
|
778
779
|
}
|
|
779
|
-
|
|
780
|
+
// Another checkout's own index, by exact place (siblingIndexes): held to being no link, but a second name is not
|
|
781
|
+
// counted. A tool in that checkout may hard-link it for a moment (Claude Code's security-guidance plugin does, to
|
|
782
|
+
// diff against a copy), and git never writes another checkout's index, nor any index in place.
|
|
783
|
+
if (held.isFile() && mode === "deep" && held.nlink > 1 && !siblings.has(path)) {
|
|
780
784
|
swapped.push({ path, leadsTo: null });
|
|
781
785
|
return;
|
|
782
786
|
}
|
|
@@ -886,6 +890,32 @@ function objectStores(common) {
|
|
|
886
890
|
visit(join(common, "modules"), 0);
|
|
887
891
|
return stores;
|
|
888
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* The index files of the repository's other checkouts, by exact place, enumerated before the walk: `index` directly in
|
|
895
|
+
* each worktree folder of the common `worktrees` that isn't this checkout's own git directory, and, for a linked
|
|
896
|
+
* checkout, the main checkout's `index` in the common folder. The walk still refuses any of them that is a link; it
|
|
897
|
+
* only doesn't count a second hard link to one (pinned). The view links only this checkout's own index, and git writes
|
|
898
|
+
* an index only by renaming a new file over it, so a hard link at another checkout's can't take a write of the plane's
|
|
899
|
+
* anywhere. Measured (Nov 2026): Claude Code's security-guidance plugin hard-links the index of the checkout a session
|
|
900
|
+
* runs in to a temporary copy for up to seconds at a time, and a release gate that ran meanwhile in another checkout of
|
|
901
|
+
* the repository was stopped as an incident naming that checkout's index.
|
|
902
|
+
*/
|
|
903
|
+
function siblingIndexes(dirs) {
|
|
904
|
+
const indexes = new Set();
|
|
905
|
+
if (dirs.linked)
|
|
906
|
+
indexes.add(join(dirs.common, "index"));
|
|
907
|
+
let ids = [];
|
|
908
|
+
try {
|
|
909
|
+
ids = readdirSync(join(dirs.common, "worktrees"));
|
|
910
|
+
}
|
|
911
|
+
catch { /* none (an unlistable folder is the walk's to refuse) */ }
|
|
912
|
+
for (const id of ids) {
|
|
913
|
+
const folder = join(dirs.common, "worktrees", id);
|
|
914
|
+
if (folder !== dirs.gitdir)
|
|
915
|
+
indexes.add(join(folder, "index"));
|
|
916
|
+
}
|
|
917
|
+
return indexes;
|
|
918
|
+
}
|
|
889
919
|
function anchorOf(path) {
|
|
890
920
|
const anchor = [];
|
|
891
921
|
for (let at = path;; at = dirname(at)) {
|
package/dist/telegram-lead.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type PhoneLink } from "./chat-channel.js";
|
|
|
2
2
|
export { channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps, type PhoneLink, type ResolvedLead, type ParitySupport } from "./chat-channel.js";
|
|
3
3
|
import { type LeadAsk, type Store, type TelegramBinding } from "./store.js";
|
|
4
4
|
import type { SubscriptionLeadRunner } from "./subscription-chat.js";
|
|
5
|
+
import { type TelegramLiveTiming } from "./telegram-live.js";
|
|
5
6
|
import type { TelegramTransport } from "./telegram.js";
|
|
6
7
|
import { type TelegramCallbackButton, type TelegramUpdate } from "./contracts/telegram-callback.js";
|
|
7
8
|
/** How long one claimed turn may go without a heartbeat before another poller may take it over. */
|
|
@@ -39,6 +40,8 @@ export type TelegramConversationOptions = {
|
|
|
39
40
|
ok: false;
|
|
40
41
|
message: string;
|
|
41
42
|
}>;
|
|
43
|
+
/** Injected by tests: the live reply's timings (telegram-live.ts); production uses its defaults. */
|
|
44
|
+
liveTiming?: TelegramLiveTiming;
|
|
42
45
|
};
|
|
43
46
|
/** One Bot API call, the shape telegram.ts injects. */
|
|
44
47
|
export type Transport = TelegramTransport;
|
package/dist/telegram-lead.js
CHANGED
|
@@ -31,7 +31,7 @@ import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
|
31
31
|
import { LEAD_ASK_OTHER } from "./store.js";
|
|
32
32
|
import { phoneText } from "./telegram-status.js";
|
|
33
33
|
import { shapeReplyParts, telegramReply } from "./reply-shape.js";
|
|
34
|
-
import {
|
|
34
|
+
import { createTelegramLiveDraft, forgetWaitingDraft, telegramAck, telegramWaitingDraft } from "./telegram-live.js";
|
|
35
35
|
import { TeamLeads } from "./team-leads.js";
|
|
36
36
|
import { chatResultHref } from "./chat-controls.js";
|
|
37
37
|
import { resultImageFileName, safeResultImageCaption, verifyResultImage } from "./chat-evidence.js";
|
|
@@ -373,8 +373,26 @@ const ANSWERED_SENDING = "answered:sending";
|
|
|
373
373
|
*/
|
|
374
374
|
async function runTelegramConversation(row, args) {
|
|
375
375
|
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
376
|
-
|
|
376
|
+
/** The row's live binding as it arrived (pairing alone: a reaction or a waiting draft carries no project data). */
|
|
377
|
+
const stillPaired = () => {
|
|
378
|
+
const live = store.liveTelegramBindingById(row.binding);
|
|
379
|
+
return live !== null && live.botId === botId && live.approverGeneration === row.approverGeneration && store.accountOf(live.approver)?.role === "approver";
|
|
380
|
+
};
|
|
381
|
+
const finish = (result) => {
|
|
382
|
+
const done = store.finishTelegramConversation(row.id, owner, result, clock());
|
|
383
|
+
// Answered (every part sent) or failed (its notice tried): the 👀 comes off, after what was delivered. Never awaited.
|
|
384
|
+
if (done && (result.state === "done" || result.state === "failed")) {
|
|
385
|
+
forgetWaitingDraft(botId, row.id);
|
|
386
|
+
void telegramAck({ store, botId, transport, clock, chatId: row.chatId, messageId: row.messageId, on: false, paired: stillPaired, ...(options.liveTiming?.ackGapMs === undefined ? {} : { gapMs: options.liveTiming.ackGapMs }) });
|
|
387
|
+
}
|
|
388
|
+
return done;
|
|
389
|
+
};
|
|
377
390
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
391
|
+
/** The claim, read (a draft never renews it: only the turn's own heartbeat and durable steps do). */
|
|
392
|
+
const claimed = () => {
|
|
393
|
+
const now = store.getTelegramConversation(row.id);
|
|
394
|
+
return now !== null && now.claimOwner === owner && now.claimExpiresAt !== null && now.claimExpiresAt > clock().toISOString();
|
|
395
|
+
};
|
|
378
396
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
379
397
|
const requeue = async (outcome) => {
|
|
380
398
|
// A turn already admitted for it waits for that turn's own outcome (bounded by the turn's deadline); waiting for the
|
|
@@ -390,6 +408,10 @@ async function runTelegramConversation(row, args) {
|
|
|
390
408
|
// Behind the person's own earlier messages: said once, then it waits quietly.
|
|
391
409
|
if (outcome === "backlog" && row.outcome !== "backlog")
|
|
392
410
|
await notify("Queued behind your earlier messages. It goes to the lead once they're answered.");
|
|
411
|
+
// Waiting for the lead: a draft that says so, refreshed while it waits (never over this chat's running turn).
|
|
412
|
+
if (outcome === "busy" || outcome === "backlog") {
|
|
413
|
+
await telegramWaitingDraft({ store, botId, transport, clock, chatId: row.chatId, binding: row.binding, draftId: row.id, allowed: () => stillPaired() && claimed(), ...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }) });
|
|
414
|
+
}
|
|
393
415
|
finish({ state: "queued", outcome, nextAttemptAt: new Date(clock().getTime() + CONVERSATION_RETRY_MS).toISOString() });
|
|
394
416
|
};
|
|
395
417
|
// 1. The exact binding the message arrived under must still be the live one.
|
|
@@ -677,7 +699,7 @@ async function runTelegramConversation(row, args) {
|
|
|
677
699
|
return;
|
|
678
700
|
}
|
|
679
701
|
if (turn.state !== "answered") {
|
|
680
|
-
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id)));
|
|
702
|
+
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id), turn.failureDetail ?? null));
|
|
681
703
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
682
704
|
report.refused++;
|
|
683
705
|
return;
|
|
@@ -777,10 +799,41 @@ async function runTelegramConversation(row, args) {
|
|
|
777
799
|
// crash gets the original turn back, never a second dispatch.
|
|
778
800
|
const heartbeat = setInterval(() => { held(); }, 30_000);
|
|
779
801
|
heartbeat.unref?.();
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
802
|
+
// The live reply: one draft in this private chat with the status line and the reply as it is written
|
|
803
|
+
// (telegram-live.ts). A draft with words goes out only after the full fence: this exact binding, approver and the
|
|
804
|
+
// ceiling the turn opened under (the registry read at most every 5 s), the turn still running under the generation it
|
|
805
|
+
// first ran with, and this row's claim, re-checked after the read.
|
|
806
|
+
let draftGeneration = null;
|
|
807
|
+
let draftRegistry = null;
|
|
808
|
+
const turnStillRunning = (turnId) => {
|
|
809
|
+
const turn = store.getLeadTurn(turnId);
|
|
810
|
+
if (turn === null || turn.state !== "running" || turn.session !== session.id || turn.thread !== thread.id)
|
|
811
|
+
return false;
|
|
812
|
+
draftGeneration ??= turn.generation;
|
|
813
|
+
return turn.generation === draftGeneration;
|
|
814
|
+
};
|
|
815
|
+
const draftStanding = async (turnId) => {
|
|
816
|
+
if (!turnStillRunning(turnId) || !claimed())
|
|
817
|
+
return false;
|
|
818
|
+
if (draftRegistry === null || clock().getTime() - draftRegistry.at > 5_000) {
|
|
819
|
+
try {
|
|
820
|
+
draftRegistry = { at: clock().getTime(), repos: await readProjects() };
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
draftRegistry = null;
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const read = draftRegistry.repos;
|
|
828
|
+
const problem = await telegramChannelProblem(store, { botId, bindingId: binding.id, approverGeneration: binding.approverGeneration, repos: who.repos }, async () => read);
|
|
829
|
+
return problem === null && turnStillRunning(turnId) && claimed();
|
|
830
|
+
};
|
|
831
|
+
const live = createTelegramLiveDraft({
|
|
832
|
+
store, botId, transport, clock, chatId, binding: binding.id, draftId: row.id,
|
|
833
|
+
// Shaped as the final reply is: for this message, with the phone's own trusted origin.
|
|
834
|
+
shape: { asked: row.text, appOrigin: options.phoneOrigin?.() ?? null },
|
|
835
|
+
standing: draftStanding, paired: () => pairingProblem() === null,
|
|
836
|
+
...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }),
|
|
784
837
|
});
|
|
785
838
|
const replyImage = row.replyTo === null ? undefined : store.telegramMessageBindings(binding, row.replyTo).find(one => one.taskId === row.taskId && one.run === row.sourceRun && one.image !== undefined)?.image;
|
|
786
839
|
let outcome;
|
|
@@ -790,12 +843,13 @@ async function runTelegramConversation(row, args) {
|
|
|
790
843
|
...(row.context === null ? {} : { context: row.context }),
|
|
791
844
|
...(replyImage === undefined ? {} : { replyImage }),
|
|
792
845
|
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
793
|
-
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress:
|
|
846
|
+
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress: live.onProgress,
|
|
794
847
|
});
|
|
795
848
|
}
|
|
796
849
|
finally {
|
|
797
850
|
clearInterval(heartbeat);
|
|
798
|
-
|
|
851
|
+
// Closed, and its draft in flight settled, before anything durable: no draft can land after the answer or a notice.
|
|
852
|
+
await live.close();
|
|
799
853
|
}
|
|
800
854
|
const receipt = store.leadRequestReceipt(session.id, row.request);
|
|
801
855
|
if (receipt !== null)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paired phone's live reply (docs/plans/responsive-chat.md §3.1): a 👀 on the owner's message as soon as it is
|
|
3
|
+
* saved, and while the lead works one Bot API message draft (`sendMessageDraft`, private chats only) that shows the
|
|
4
|
+
* status line and the reply as it is written.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is durable or authoritative. A draft is Telegram's "temporary 30-second preview": it disappears when the
|
|
7
|
+
* bot sends a message or when it is not refreshed, so a draft that is never replaced is harmless. The answer itself is
|
|
8
|
+
* still the planned, receipted `sendMessage` parts (telegram-lead.ts), sent only after this turn's sender has closed.
|
|
9
|
+
* Drafts carry words only: no keyboard, no card, no token. Every draft that carries more than Telegram's own empty
|
|
10
|
+
* "Thinking…" placeholder is sent only after the caller's full standing check (live pairing, approver, the project
|
|
11
|
+
* ceiling the turn opened under, the turn still running under its generation, the row's claim).
|
|
12
|
+
*
|
|
13
|
+
* Rate: Telegram's own retry_after is shared with every other send (`telegramRetryAt` / `deferTelegram`). While it
|
|
14
|
+
* holds, an ack waits and a draft is dropped (the answer and failure notices go first, acks next, drafts last); a
|
|
15
|
+
* draft that is asked to pause widens this turn's spacing.
|
|
16
|
+
*/
|
|
17
|
+
import type { LeadProgress } from "./lead-progress.js";
|
|
18
|
+
import { type LiveView } from "./live-reply.js";
|
|
19
|
+
import { type ShapeOptions, type TelegramEntity } from "./reply-shape.js";
|
|
20
|
+
import type { Store } from "./store.js";
|
|
21
|
+
import type { TelegramTransport } from "./telegram.js";
|
|
22
|
+
/** The acknowledgement on the owner's message: "seen, working on it". Removed once the answer or the failure notice is
|
|
23
|
+
* delivered; never replaced by a "done" mark (an answer isn't finished work). */
|
|
24
|
+
export declare const ACK_EMOJI = "\uD83D\uDC40";
|
|
25
|
+
/** At most one reaction change per chat this often; a burst waits its turn, in order. */
|
|
26
|
+
export declare const ACK_GAP_MS = 1000;
|
|
27
|
+
/** Drafts start this far apart and widen (doubling, up to the cap) whenever Telegram asks for a pause. */
|
|
28
|
+
export declare const DRAFT_SPACING_MS = 1200;
|
|
29
|
+
/** Telegram keeps a draft about 30 seconds: one that has not changed is sent again after this long. */
|
|
30
|
+
export declare const DRAFT_KEEPALIVE_MS = 25000;
|
|
31
|
+
/** Telegram's message limit, counted after entities (UTF-16 units, as JS strings are). */
|
|
32
|
+
export declare const TELEGRAM_TEXT_MAX = 4096;
|
|
33
|
+
/** Test seams for the timings above (production uses the defaults). */
|
|
34
|
+
export type TelegramLiveTiming = {
|
|
35
|
+
ackGapMs?: number;
|
|
36
|
+
draftSpacingMs?: number;
|
|
37
|
+
keepAliveMs?: number;
|
|
38
|
+
};
|
|
39
|
+
export type DraftEntity = TelegramEntity | {
|
|
40
|
+
type: "italic";
|
|
41
|
+
offset: number;
|
|
42
|
+
length: number;
|
|
43
|
+
};
|
|
44
|
+
export type DraftPayload = {
|
|
45
|
+
text: string;
|
|
46
|
+
entities: DraftEntity[];
|
|
47
|
+
};
|
|
48
|
+
/** The body of a writing draft within `budget` characters once rendered: cut at a safe place in the shaped text first
|
|
49
|
+
* (never inside code, a link, a bold anchor or an emoji), rendered, measured, and cut smaller if it still doesn't fit.
|
|
50
|
+
* A body that can't fit (a single link longer than the budget) shows nothing. */
|
|
51
|
+
export declare function draftBody(shaped: string, budget: number): DraftPayload;
|
|
52
|
+
/**
|
|
53
|
+
* One live view as a draft: the status line in italics, then (once text exists) the partial reply as text plus
|
|
54
|
+
* entities, never Markdown. Plain "Thinking…" is Telegram's own empty-text placeholder. Closed views send nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function telegramDraftOf(view: LiveView): DraftPayload | null;
|
|
57
|
+
/**
|
|
58
|
+
* Put 👀 on the owner's message (`on`) or take it off. Best effort and never awaited by the caller's durable work: a
|
|
59
|
+
* missing pairing, a failure or a long pause skips it silently. `paired` is re-checked at the moment of sending; a
|
|
60
|
+
* reaction carries no project data, so the pairing alone fences it. Resolves once this change was tried.
|
|
61
|
+
*/
|
|
62
|
+
export declare function telegramAck(args: {
|
|
63
|
+
store: Store;
|
|
64
|
+
botId: string;
|
|
65
|
+
transport: TelegramTransport;
|
|
66
|
+
clock: () => Date;
|
|
67
|
+
chatId: string;
|
|
68
|
+
messageId: string;
|
|
69
|
+
on: boolean;
|
|
70
|
+
paired: () => boolean;
|
|
71
|
+
gapMs?: number;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
export type TelegramLiveDraft = {
|
|
74
|
+
/** The turn's progress listener (runLeadTurn's `onProgress`). */
|
|
75
|
+
onProgress: (event: LeadProgress) => void;
|
|
76
|
+
/** Stop: nothing more is sent, and the draft in flight has settled when this resolves. Durable delivery comes after. */
|
|
77
|
+
close: () => Promise<void>;
|
|
78
|
+
/** The turn the drafts follow, once it started. */
|
|
79
|
+
readonly turn: number | null;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* One running turn's draft. `standing` is the caller's full proof for a draft that carries words (it may await; the
|
|
83
|
+
* close is re-checked after it); `paired` is enough for Telegram's empty "Thinking…". A draft Telegram refuses outright
|
|
84
|
+
* (not a pause, not a lost answer) turns this turn's drafts off; previews are best effort.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createTelegramLiveDraft(args: {
|
|
87
|
+
store: Store;
|
|
88
|
+
botId: string;
|
|
89
|
+
transport: TelegramTransport;
|
|
90
|
+
clock: () => Date;
|
|
91
|
+
chatId: string;
|
|
92
|
+
binding: number;
|
|
93
|
+
draftId: number;
|
|
94
|
+
shape: ShapeOptions;
|
|
95
|
+
standing: (turn: number) => Promise<boolean>;
|
|
96
|
+
paired: () => boolean;
|
|
97
|
+
timing?: TelegramLiveTiming;
|
|
98
|
+
}): TelegramLiveDraft;
|
|
99
|
+
/**
|
|
100
|
+
* A message waiting for the lead (busy, or behind the owner's earlier messages): "Waiting for your earlier messages…"
|
|
101
|
+
* as a draft, refreshed only after the keep-alive interval, and never while this chat's running turn has its own
|
|
102
|
+
* draft. Carries no project data: the pairing and the claim fence it.
|
|
103
|
+
*/
|
|
104
|
+
export declare function telegramWaitingDraft(args: {
|
|
105
|
+
store: Store;
|
|
106
|
+
botId: string;
|
|
107
|
+
transport: TelegramTransport;
|
|
108
|
+
clock: () => Date;
|
|
109
|
+
chatId: string;
|
|
110
|
+
binding: number;
|
|
111
|
+
draftId: number;
|
|
112
|
+
allowed: () => boolean;
|
|
113
|
+
timing?: TelegramLiveTiming;
|
|
114
|
+
}): Promise<void>;
|
|
115
|
+
/** The message is answered or failed: its waiting draft is forgotten. */
|
|
116
|
+
export declare function forgetWaitingDraft(botId: string, draftId: number): void;
|