toolroll 0.9.96 → 0.9.98
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 +18 -0
- package/dist/approval-authority.d.ts +10 -1
- package/dist/approval-authority.js +18 -2
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +6 -3
- package/dist/chat-actions.d.ts +40 -5
- package/dist/chat-actions.js +124 -13
- package/dist/chat-card.js +4 -1
- package/dist/chat-channel.d.ts +3 -0
- package/dist/chat-channel.js +24 -7
- package/dist/chat-decide.js +4 -2
- 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/chat-task-actions.js +1 -1
- package/dist/chat-terms.d.ts +8 -0
- package/dist/chat-terms.js +14 -0
- package/dist/chat-voice.js +1 -1
- package/dist/contracts/chat-actions.d.ts +6 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +6 -0
- package/dist/contracts/cli.js +3 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/lead-tools.d.ts +17 -0
- package/dist/contracts/lead-tools.js +4 -1
- package/dist/contracts/store-json.d.ts +1 -0
- 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/fast-start.d.ts +83 -6
- package/dist/fast-start.js +112 -15
- package/dist/home-groups.d.ts +39 -0
- package/dist/home-groups.js +33 -0
- package/dist/lead-answer-eval.d.ts +93 -0
- package/dist/lead-answer-eval.js +259 -0
- package/dist/lead-capabilities.js +1 -1
- 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-context.js +2 -2
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +10 -7
- package/dist/lead-doors.d.ts +32 -0
- package/dist/lead-doors.js +80 -14
- 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-status.d.ts +3 -0
- package/dist/lead-status.js +8 -3
- package/dist/lead-tools.d.ts +20 -0
- package/dist/lead-tools.js +66 -3
- package/dist/lead-voice.js +1 -1
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +97 -28
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/local-owner.js +2 -0
- package/dist/one-step-chat-schema.d.ts +22 -0
- package/dist/one-step-chat-schema.js +37 -0
- package/dist/operate.d.ts +27 -1
- package/dist/operate.js +245 -33
- package/dist/project-delete.js +9 -0
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +1 -1
- package/dist/pull-request-flow.d.ts +4 -0
- package/dist/pull-request-flow.js +16 -1
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/reply-voice.d.ts +23 -0
- package/dist/reply-voice.js +37 -0
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +3 -2
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +22 -10
- package/dist/server/chrome.js +2 -2
- package/dist/server/home.d.ts +4 -3
- package/dist/server/home.js +33 -28
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +15 -8
- package/dist/server/render-pages.js +2 -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/setup-guide.d.ts +2 -2
- 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 +33 -2
- package/dist/store.js +92 -16
- 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/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +132 -14
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram-status.js +9 -4
- package/dist/telegram.d.ts +12 -0
- package/dist/telegram.js +142 -10
- package/dist/toolroll-update.js +6 -1
- package/dist/tui-contract.d.ts +2 -0
- package/dist/tui-source.js +3 -0
- package/dist/tui.js +41 -41
- package/dist/typed-start.d.ts +51 -0
- package/dist/typed-start.js +98 -0
- package/dist/wake-hub.d.ts +72 -0
- package/dist/wake-hub.js +151 -0
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -13,6 +13,9 @@ 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";
|
|
18
|
+
import { V159_ADDED_COLUMNS, V159_SCHEMA, V159_STEPS } from "./one-step-chat-schema.js";
|
|
16
19
|
import { refArchived, taskArchived } from "./task-archive.js";
|
|
17
20
|
export { refArchived, taskArchived } from "./task-archive.js";
|
|
18
21
|
import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
|
|
@@ -1142,7 +1145,14 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
1142
1145
|
// v155 adds, and changes no saved row (docs/plans/fleet-follow-through.md): findings and their answers, check repairs,
|
|
1143
1146
|
// before-and-after evidence and provider captures (V155_SCHEMA, new tables only; fleet-schema.ts).
|
|
1144
1147
|
// v156 adds fallback chains (V156_SCHEMA, new tables only; fallback-schema.ts), after v155.
|
|
1145
|
-
|
|
1148
|
+
// v157 is the chat apps' live placeholders (chat-apps slice; merged separately).
|
|
1149
|
+
// v158 adds, and changes no saved row (docs/plans/responsive-chat.md, slice 5): the tools each lead step asked for, an
|
|
1150
|
+
// exact failure detail on a step and a reply, which model answered a reply (mate_turn.route), and the quick-answer
|
|
1151
|
+
// switch (lead_quick_setting). V158_SCHEMA, V158_ADDED_COLUMNS (lead-latency-schema.ts): saved rows read NULL.
|
|
1152
|
+
// v159 adds, and changes no saved row (docs/plans/one-step-chat.md): the lead turn a chat start's preview was prepared
|
|
1153
|
+
// in (fast_preview.turn) and a typed yes's record (chat_typed_start, a new table). V159_SCHEMA, V159_ADDED_COLUMNS
|
|
1154
|
+
// (one-step-chat-schema.ts): saved rows read NULL.
|
|
1155
|
+
export const SCHEMA_VERSION = 159;
|
|
1146
1156
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
1147
1157
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
1148
1158
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -1275,7 +1285,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
1275
1285
|
* unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
|
|
1276
1286
|
* (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
|
|
1277
1287
|
*/
|
|
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]);
|
|
1288
|
+
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, 159]);
|
|
1279
1289
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
1280
1290
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
1281
1291
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -1936,6 +1946,9 @@ export const V153_STEPS = Object.freeze(["plain-tables", "plain-columns"]);
|
|
|
1936
1946
|
export const V154_STEPS = Object.freeze(["check-stamps"]);
|
|
1937
1947
|
export { V155_STEPS };
|
|
1938
1948
|
export { V156_STEPS };
|
|
1949
|
+
export { V157_STEPS };
|
|
1950
|
+
export { V158_STEPS };
|
|
1951
|
+
export { V159_STEPS };
|
|
1939
1952
|
/**
|
|
1940
1953
|
* v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
|
|
1941
1954
|
* release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
|
|
@@ -5274,6 +5287,24 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
5274
5287
|
// v156: fallback chains (new tables only). Idempotent, as v155's steps are.
|
|
5275
5288
|
db.exec(V156_SCHEMA);
|
|
5276
5289
|
step("fallback-tables");
|
|
5290
|
+
// v157: live replies in Slack, Discord and Teams (new tables only). Idempotent, as v156's step is.
|
|
5291
|
+
db.exec(V157_SCHEMA);
|
|
5292
|
+
step("chat-live-tables");
|
|
5293
|
+
// v158: the lead's latency records and its quick-answer switch (V158_SCHEMA, V158_ADDED_COLUMNS: saved rows read
|
|
5294
|
+
// NULL). Idempotent, as v156's and v157's steps are.
|
|
5295
|
+
db.exec(V158_SCHEMA);
|
|
5296
|
+
step("lead-quick-setting");
|
|
5297
|
+
for (const [table, columns] of Object.entries(V158_ADDED_COLUMNS))
|
|
5298
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
5299
|
+
addColumn(db, table, column, definition);
|
|
5300
|
+
step("lead-latency-columns");
|
|
5301
|
+
// v159: one-step start from chat (V159_SCHEMA, V159_ADDED_COLUMNS: saved rows read NULL). Idempotent, as v158's are.
|
|
5302
|
+
db.exec(V159_SCHEMA);
|
|
5303
|
+
step("one-step-chat-tables");
|
|
5304
|
+
for (const [table, columns] of Object.entries(V159_ADDED_COLUMNS))
|
|
5305
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
5306
|
+
addColumn(db, table, column, definition);
|
|
5307
|
+
step("one-step-chat-columns");
|
|
5277
5308
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
5278
5309
|
// nothing to reclaim.
|
|
5279
5310
|
if (!fresh)
|
|
@@ -8225,6 +8256,9 @@ export class Store {
|
|
|
8225
8256
|
throw new ReleaseSealRefused();
|
|
8226
8257
|
}
|
|
8227
8258
|
if (changed.changes > 0) {
|
|
8259
|
+
// A real seal is work a builder can take now: wake the loops (live-wakeups.md). Inside the seal's own
|
|
8260
|
+
// transaction, so a seal that rolls back wakes nothing. The claim re-proves the approval regardless.
|
|
8261
|
+
this.bumpWake();
|
|
8228
8262
|
// The project's tools this yes saw (v80): a later addition needs a later yes.
|
|
8229
8263
|
this.sealProjectTools(taskId, String(working["digest"]), now);
|
|
8230
8264
|
// A fresh yes lifts the stale-approval hold and closes its page
|
|
@@ -11735,17 +11769,23 @@ export class Store {
|
|
|
11735
11769
|
* enough, and neither is a stale yes).
|
|
11736
11770
|
*/
|
|
11737
11771
|
markCapability(repo, kind, name, outcome, now, expiresAt = null) {
|
|
11738
|
-
|
|
11739
|
-
|
|
11772
|
+
return this.transact(() => {
|
|
11773
|
+
// The status before this answer, read in the same transaction: only a gap that fills wakes the loop. A
|
|
11774
|
+
// repeated passing probe refreshes the timestamp and says nothing new, so it must not wake (live-wakeups.md).
|
|
11775
|
+
const prior = this.db.prepare("SELECT status FROM capability WHERE repo = ? AND kind = ? AND name = ?").get(repo, kind, name);
|
|
11776
|
+
const { changes } = this.db
|
|
11777
|
+
.prepare(`UPDATE capability SET status = ?, last_verified_at = ?, verified_by = ?, last_result = ?, expires_at = COALESCE(?, expires_at)
|
|
11740
11778
|
WHERE repo = ? AND kind = ? AND name = ?`)
|
|
11741
|
-
|
|
11742
|
-
|
|
11743
|
-
|
|
11744
|
-
|
|
11745
|
-
|
|
11746
|
-
|
|
11747
|
-
|
|
11748
|
-
|
|
11779
|
+
.run(outcome.status, outcome.status === "verified" ? now.toISOString() : null, outcome.by, outcome.detail ?? null, expiresAt, repo, kind, name);
|
|
11780
|
+
// A gap that just filled ends its notification episode: the next failure
|
|
11781
|
+
// is a new fact, and must be allowed to say so.
|
|
11782
|
+
if (Number(changes) > 0 && outcome.status === "verified") {
|
|
11783
|
+
this.clearGapEpisode(repo, kind, name);
|
|
11784
|
+
if (prior?.["status"] !== "verified")
|
|
11785
|
+
this.bumpWake();
|
|
11786
|
+
}
|
|
11787
|
+
return Number(changes) > 0;
|
|
11788
|
+
});
|
|
11749
11789
|
}
|
|
11750
11790
|
/** Set what a task needs, as `kind:name` keys, replacing what it needed before. */
|
|
11751
11791
|
setRequirements(taskRef, keys, mutation = {}) {
|
|
@@ -13210,9 +13250,9 @@ export class Store {
|
|
|
13210
13250
|
const changed = this.db
|
|
13211
13251
|
.prepare(`UPDATE chat_turn SET state = ?, failure_reason = ?, tokens_in = ?, tokens_out = ?,
|
|
13212
13252
|
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 = ?
|
|
13253
|
+
model_reported = ?, provider_turns = ?, reported_cost_usd = ?, billing_reported = ?, tools = ?, failure_detail = ?, finished_at = ?
|
|
13214
13254
|
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);
|
|
13255
|
+
.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
13256
|
return Number(changed.changes) === 1;
|
|
13217
13257
|
}
|
|
13218
13258
|
/**
|
|
@@ -16433,6 +16473,8 @@ export class Store {
|
|
|
16433
16473
|
generation = generation + 1, last_error = NULL
|
|
16434
16474
|
WHERE id = ? AND state = 'waiting-human'`)
|
|
16435
16475
|
.run(id);
|
|
16476
|
+
// The person's yes makes the merge actionable now: wake the loops (live-wakeups.md). The firing CAS re-proves it.
|
|
16477
|
+
this.bumpWake();
|
|
16436
16478
|
return { ok: true };
|
|
16437
16479
|
});
|
|
16438
16480
|
}
|
|
@@ -18016,9 +18058,9 @@ export class Store {
|
|
|
18016
18058
|
return false;
|
|
18017
18059
|
const settled = outcome.unknownSpend === true ? Math.max(Number(before["reserved"]), outcome.settledMicrousd) : outcome.settledMicrousd;
|
|
18018
18060
|
const changed = this.db
|
|
18019
|
-
.prepare(`UPDATE mate_turn SET state = ?, settled_microusd = ?, tokens_in = ?, tokens_out = ?, failure_reason = ?, finished_at = ?
|
|
18061
|
+
.prepare(`UPDATE mate_turn SET state = ?, settled_microusd = ?, tokens_in = ?, tokens_out = ?, failure_reason = ?, failure_detail = ?, finished_at = ?
|
|
18020
18062
|
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);
|
|
18063
|
+
.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
18064
|
if (Number(changed.changes) !== 1)
|
|
18023
18065
|
return false;
|
|
18024
18066
|
this.db.prepare("UPDATE lead_session SET spent_microusd = spent_microusd + ? WHERE id = ?").run(settled, Number(before["session"]));
|
|
@@ -21235,6 +21277,8 @@ function readLeadTurn(row) {
|
|
|
21235
21277
|
tokensIn: maybeN("tokens_in"),
|
|
21236
21278
|
tokensOut: maybeN("tokens_out"),
|
|
21237
21279
|
failureReason: maybe("failure_reason"),
|
|
21280
|
+
failureDetail: LEAD_FAILURE_DETAILS.includes(row["failure_detail"]) ? row["failure_detail"] : null,
|
|
21281
|
+
route: LEAD_ROUTES.includes(row["route"]) ? row["route"] : null,
|
|
21238
21282
|
};
|
|
21239
21283
|
}
|
|
21240
21284
|
function readVerifyCommand(row) {
|
|
@@ -23173,3 +23217,35 @@ function widenFindingSources(db) {
|
|
|
23173
23217
|
const columns = db.prepare("PRAGMA table_info(finding)").all().map(one => String(one["name"]));
|
|
23174
23218
|
rebuildExact(db, "finding", named(stored), named(stored.replace(FINDING_SOURCES_BEFORE_LEAD, FINDING_SOURCES)), columns);
|
|
23175
23219
|
}
|
|
23220
|
+
/** A thread's newest lead turn, whatever its state; null before its first. What a surface reads back to say how the
|
|
23221
|
+
* last reply ended after a restart (docs/plans/responsive-chat.md §3.4): the saved row, never process memory. */
|
|
23222
|
+
export function latestLeadTurnOfThread(store, thread) {
|
|
23223
|
+
const row = store.handle.prepare("SELECT * FROM mate_turn WHERE thread = ? ORDER BY id DESC LIMIT 1").get(thread);
|
|
23224
|
+
return row === undefined ? null : readLeadTurn(row);
|
|
23225
|
+
}
|
|
23226
|
+
/** Settings → Lead's "Quick answers use a faster model" (v158): absent reads off, the shipped default
|
|
23227
|
+
* (docs/plans/responsive-chat.md §8: on only once the lead-answer eval passes). */
|
|
23228
|
+
export function leadQuickAnswersOn(store) {
|
|
23229
|
+
const row = store.handle.prepare("SELECT enabled FROM lead_quick_setting WHERE id = 1").get();
|
|
23230
|
+
return row !== undefined && Number(row["enabled"]) === 1;
|
|
23231
|
+
}
|
|
23232
|
+
/** Turn quick answers on or off, recording who and when (the caller proved the person may change the lead's settings). */
|
|
23233
|
+
export function setLeadQuickAnswers(store, enabled, by, now) {
|
|
23234
|
+
store.handle.prepare(`INSERT INTO lead_quick_setting (id, enabled, updated_by, updated_at) VALUES (1, ?, ?, ?)
|
|
23235
|
+
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());
|
|
23236
|
+
}
|
|
23237
|
+
/** Which model answers a running reply (v158): written at admission and when a quick step hands over to the main model.
|
|
23238
|
+
* Only the turn's own running generation may write it. */
|
|
23239
|
+
export function setLeadTurnRoute(store, turn, generation, route) {
|
|
23240
|
+
if (!LEAD_ROUTES.includes(route))
|
|
23241
|
+
return false;
|
|
23242
|
+
return Number(store.handle.prepare("UPDATE mate_turn SET route = ? WHERE id = ? AND generation = ? AND state = 'running'").run(route, turn, generation).changes) === 1;
|
|
23243
|
+
}
|
|
23244
|
+
/** Whether a new message in this thread may be the answer to the lead's own question (ask_owner): the thread's newest
|
|
23245
|
+
* reply asked one, less than LEAD_ASK_TTL_MS ago. Conservative on purpose: the quick-answer router (§8) keeps such a
|
|
23246
|
+
* message on the main model. */
|
|
23247
|
+
export function leadAskAwaitsAnswer(store, thread, now) {
|
|
23248
|
+
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
|
|
23249
|
+
WHERE m.id = (SELECT MAX(id) FROM lead_message WHERE thread = ? AND role = 'assistant') AND t.state = 'answered'`).get(thread);
|
|
23250
|
+
return row !== undefined && Date.parse(String(row["at"])) + LEAD_ASK_TTL_MS > now.getTime();
|
|
23251
|
+
}
|
|
@@ -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/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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { telegramCardMessage } from "./card-render.js";
|
|
2
2
|
import { cardOfProposal, cardPlainOf, cardsFirst, cardWithin, isCardProposal } from "./chat-card.js";
|
|
3
3
|
import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
|
|
4
|
-
import {
|
|
4
|
+
import { typedStartOf } from "./typed-start.js";
|
|
5
|
+
import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, startedTaskLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
5
6
|
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 } from "./chat-channel.js";
|
|
6
7
|
/**
|
|
7
8
|
* The paired phone as one more way to use the same assistant.
|
|
@@ -26,12 +27,12 @@ export { channelRepos as telegramConversationRepos, resolveChannelLead as resolv
|
|
|
26
27
|
*/
|
|
27
28
|
import { createHash, randomBytes } from "node:crypto";
|
|
28
29
|
import { leadFailureText, runLeadTurn } from "./lead.js";
|
|
29
|
-
import { confirmLeadProposal, dismissLeadProposal } from "./lead-doors.js";
|
|
30
|
+
import { confirmLeadProposal, confirmTypedStart, dismissLeadProposal, TYPED_START_STARTED } from "./lead-doors.js";
|
|
30
31
|
import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
31
32
|
import { LEAD_ASK_OTHER } from "./store.js";
|
|
32
33
|
import { phoneText } from "./telegram-status.js";
|
|
33
34
|
import { shapeReplyParts, telegramReply } from "./reply-shape.js";
|
|
34
|
-
import {
|
|
35
|
+
import { createTelegramLiveDraft, forgetWaitingDraft, telegramAck, telegramWaitingDraft } from "./telegram-live.js";
|
|
35
36
|
import { TeamLeads } from "./team-leads.js";
|
|
36
37
|
import { chatResultHref } from "./chat-controls.js";
|
|
37
38
|
import { resultImageFileName, safeResultImageCaption, verifyResultImage } from "./chat-evidence.js";
|
|
@@ -129,7 +130,10 @@ export function mintCardTokens(store, binding, proposal, now, messageId, chatId
|
|
|
129
130
|
for (const [token, phase] of [[confirm, "confirm"], [dismiss, "dismiss"]]) {
|
|
130
131
|
store.createTelegramProposalAction({ token, binding: binding.id, proposal, phase, chatId, ttlMs: CARD_TTL_MS, ...(messageId === undefined ? {} : { messageId }) }, now);
|
|
131
132
|
}
|
|
132
|
-
|
|
133
|
+
// A Start card's button says what it does: the card's last line tells the person to tap it (or reply yes).
|
|
134
|
+
const saved = store.getLeadProposal(proposal);
|
|
135
|
+
const label = saved?.kind === "action" && sharedActionPayload(saved.payload)?.operation === "task_start" ? "Start work" : "Confirm";
|
|
136
|
+
return { keyboard: [[telegramButton(label, confirm), telegramButton("Dismiss", dismiss)]], tokens: [confirm, dismiss] };
|
|
133
137
|
}
|
|
134
138
|
/**
|
|
135
139
|
* One tap on a proposal card, applied inside the update's own transaction
|
|
@@ -249,7 +253,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
|
|
|
249
253
|
}
|
|
250
254
|
const irreversible = proposal.kind === "answer" && proposal.payload["reversible"] === false;
|
|
251
255
|
const sharedAction = proposal.kind === "action" ? sharedActionPayload(proposal.payload) : null;
|
|
252
|
-
const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction);
|
|
256
|
+
const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction, "telegram");
|
|
253
257
|
// The arm: two fresh one-time tokens make a real challenge, exactly as a
|
|
254
258
|
// decision button does. Nothing is answered or recorded here.
|
|
255
259
|
const arm = () => {
|
|
@@ -373,8 +377,26 @@ const ANSWERED_SENDING = "answered:sending";
|
|
|
373
377
|
*/
|
|
374
378
|
async function runTelegramConversation(row, args) {
|
|
375
379
|
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
376
|
-
|
|
380
|
+
/** The row's live binding as it arrived (pairing alone: a reaction or a waiting draft carries no project data). */
|
|
381
|
+
const stillPaired = () => {
|
|
382
|
+
const live = store.liveTelegramBindingById(row.binding);
|
|
383
|
+
return live !== null && live.botId === botId && live.approverGeneration === row.approverGeneration && store.accountOf(live.approver)?.role === "approver";
|
|
384
|
+
};
|
|
385
|
+
const finish = (result) => {
|
|
386
|
+
const done = store.finishTelegramConversation(row.id, owner, result, clock());
|
|
387
|
+
// Answered (every part sent) or failed (its notice tried): the 👀 comes off, after what was delivered. Never awaited.
|
|
388
|
+
if (done && (result.state === "done" || result.state === "failed")) {
|
|
389
|
+
forgetWaitingDraft(botId, row.id);
|
|
390
|
+
void telegramAck({ store, botId, transport, clock, chatId: row.chatId, messageId: row.messageId, on: false, paired: stillPaired, ...(options.liveTiming?.ackGapMs === undefined ? {} : { gapMs: options.liveTiming.ackGapMs }) });
|
|
391
|
+
}
|
|
392
|
+
return done;
|
|
393
|
+
};
|
|
377
394
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
395
|
+
/** The claim, read (a draft never renews it: only the turn's own heartbeat and durable steps do). */
|
|
396
|
+
const claimed = () => {
|
|
397
|
+
const now = store.getTelegramConversation(row.id);
|
|
398
|
+
return now !== null && now.claimOwner === owner && now.claimExpiresAt !== null && now.claimExpiresAt > clock().toISOString();
|
|
399
|
+
};
|
|
378
400
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
379
401
|
const requeue = async (outcome) => {
|
|
380
402
|
// A turn already admitted for it waits for that turn's own outcome (bounded by the turn's deadline); waiting for the
|
|
@@ -390,6 +412,10 @@ async function runTelegramConversation(row, args) {
|
|
|
390
412
|
// Behind the person's own earlier messages: said once, then it waits quietly.
|
|
391
413
|
if (outcome === "backlog" && row.outcome !== "backlog")
|
|
392
414
|
await notify("Queued behind your earlier messages. It goes to the lead once they're answered.");
|
|
415
|
+
// Waiting for the lead: a draft that says so, refreshed while it waits (never over this chat's running turn).
|
|
416
|
+
if (outcome === "busy" || outcome === "backlog") {
|
|
417
|
+
await telegramWaitingDraft({ store, botId, transport, clock, chatId: row.chatId, binding: row.binding, draftId: row.id, allowed: () => stillPaired() && claimed(), ...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }) });
|
|
418
|
+
}
|
|
393
419
|
finish({ state: "queued", outcome, nextAttemptAt: new Date(clock().getTime() + CONVERSATION_RETRY_MS).toISOString() });
|
|
394
420
|
};
|
|
395
421
|
// 1. The exact binding the message arrived under must still be the live one.
|
|
@@ -551,7 +577,11 @@ async function runTelegramConversation(row, args) {
|
|
|
551
577
|
const proposal = part.kind === "card" && part.proposal !== null ? store.getLeadProposal(part.proposal) : null;
|
|
552
578
|
const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
|
|
553
579
|
const wanted = proposal === null ? null : phoneProposalLink(store, proposal, repos);
|
|
554
|
-
|
|
580
|
+
// A typed yes that started a task (one-step-chat.md C5): Toolroll's reply carries the task's link, as a tapped
|
|
581
|
+
// card does. From the record and the origin read now, like every link.
|
|
582
|
+
const yes = part.kind === "reply" ? typedStartOf(store, "telegram", `m${row.id}`) : null;
|
|
583
|
+
const started = yes !== null && yes.outcome === "started" && yes.taskId !== null ? phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, yes.taskId, repos)) : null;
|
|
584
|
+
const keyboard = keyboardWith(part.keyboard, started ?? phoneLinkButton(origin, wanted));
|
|
555
585
|
// A Toolroll card: read again now, shown only where every project behind it is still reachable. An unreadable
|
|
556
586
|
// one goes out as its saved plain words.
|
|
557
587
|
const isCard = proposal !== null && isCardProposal(proposal);
|
|
@@ -677,7 +707,7 @@ async function runTelegramConversation(row, args) {
|
|
|
677
707
|
return;
|
|
678
708
|
}
|
|
679
709
|
if (turn.state !== "answered") {
|
|
680
|
-
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id)));
|
|
710
|
+
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id), turn.failureDetail ?? null));
|
|
681
711
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
682
712
|
report.refused++;
|
|
683
713
|
return;
|
|
@@ -729,6 +759,62 @@ async function runTelegramConversation(row, args) {
|
|
|
729
759
|
return;
|
|
730
760
|
}
|
|
731
761
|
const repos = telegramConversationRepos(store, binding.approver, registry);
|
|
762
|
+
// A typed yes to a Start card (docs/plans/one-step-chat.md C5), recorded when the message arrived: Toolroll acts on it
|
|
763
|
+
// here and no lead turn runs for it. Its outcome and Toolroll's own reply are written in ONE transaction (the claim
|
|
764
|
+
// fences the reply; a lapsed claim takes the whole act back), then sent like any reply. A settled record returns its
|
|
765
|
+
// first outcome, so a redelivery or a crash starts nothing more.
|
|
766
|
+
const typed = typedStartOf(store, "telegram", `m${row.id}`);
|
|
767
|
+
if (typed !== null) {
|
|
768
|
+
const verified = verifyApproverStanding(store, binding.approver, binding.approverGeneration, repos);
|
|
769
|
+
// The card's turn is gone when the chat was ended (its proposals are deleted) or its project deleted: there is then
|
|
770
|
+
// no session to send a reply under, and nothing can have started. Toolroll says so itself. The yes is never passed on.
|
|
771
|
+
const unanchored = { said: null };
|
|
772
|
+
try {
|
|
773
|
+
store.transact(() => {
|
|
774
|
+
const now = clock();
|
|
775
|
+
const answered = confirmTypedStart(store, verified.ok ? verified.who : null, { provider: "telegram", event: typed.event }, now, { binding: binding.id, evidenceRoot: options.evidenceRoot });
|
|
776
|
+
// The reply is fenced on the session the card was composed under.
|
|
777
|
+
const card = store.getLeadProposal(typed.proposal);
|
|
778
|
+
const turn = card === null ? null : store.getLeadTurn(card.turn);
|
|
779
|
+
if (turn === null) {
|
|
780
|
+
// A start without its card can't happen (the door re-reads the card); if it ever did, take it back.
|
|
781
|
+
if (answered.ok)
|
|
782
|
+
throw new PlanRefused();
|
|
783
|
+
unanchored.said = answered.said;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
// The card is answered: its buttons are spent with it.
|
|
787
|
+
if (answered.ok)
|
|
788
|
+
store.consumeTelegramProposalActions(typed.proposal, now);
|
|
789
|
+
if (!store.planTelegramConversationParts(row.id, owner, { session: turn.session, turn: turn.id }, [{ kind: "reply", text: answered.said, replyTo: row.messageId }], now))
|
|
790
|
+
throw new PlanRefused();
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
if (error instanceof PlanRefused)
|
|
795
|
+
return;
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
if (unanchored.said !== null) {
|
|
799
|
+
await notify(unanchored.said);
|
|
800
|
+
finish({ state: "failed", outcome: "typed-start:not-counted" });
|
|
801
|
+
report.refused++;
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// The card it answered is repainted as a tap's would be, best effort: its buttons are already spent, and the
|
|
805
|
+
// reply below says the same words with the same link.
|
|
806
|
+
const settled = typedStartOf(store, "telegram", typed.event);
|
|
807
|
+
if (settled?.outcome === "started" && settled.taskId !== null && pairingProblem() === null && held()) {
|
|
808
|
+
const link = phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, settled.taskId, repos));
|
|
809
|
+
try {
|
|
810
|
+
await transport("editMessageText", { chat_id: chatId, message_id: Number(typed.cardMessage), text: `✓ ${TYPED_START_STARTED}`, link_preview_options: { is_disabled: true },
|
|
811
|
+
reply_markup: { inline_keyboard: link === null ? [] : [link] } });
|
|
812
|
+
}
|
|
813
|
+
catch { /* the reply below still says it */ }
|
|
814
|
+
}
|
|
815
|
+
await sendOrHandOff("typed-start");
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
732
818
|
// 4. A session bound before an earlier dispatch: the receipt lives there,
|
|
733
819
|
// whatever session is live today.
|
|
734
820
|
if (row.session !== null) {
|
|
@@ -777,10 +863,41 @@ async function runTelegramConversation(row, args) {
|
|
|
777
863
|
// crash gets the original turn back, never a second dispatch.
|
|
778
864
|
const heartbeat = setInterval(() => { held(); }, 30_000);
|
|
779
865
|
heartbeat.unref?.();
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
866
|
+
// The live reply: one draft in this private chat with the status line and the reply as it is written
|
|
867
|
+
// (telegram-live.ts). A draft with words goes out only after the full fence: this exact binding, approver and the
|
|
868
|
+
// ceiling the turn opened under (the registry read at most every 5 s), the turn still running under the generation it
|
|
869
|
+
// first ran with, and this row's claim, re-checked after the read.
|
|
870
|
+
let draftGeneration = null;
|
|
871
|
+
let draftRegistry = null;
|
|
872
|
+
const turnStillRunning = (turnId) => {
|
|
873
|
+
const turn = store.getLeadTurn(turnId);
|
|
874
|
+
if (turn === null || turn.state !== "running" || turn.session !== session.id || turn.thread !== thread.id)
|
|
875
|
+
return false;
|
|
876
|
+
draftGeneration ??= turn.generation;
|
|
877
|
+
return turn.generation === draftGeneration;
|
|
878
|
+
};
|
|
879
|
+
const draftStanding = async (turnId) => {
|
|
880
|
+
if (!turnStillRunning(turnId) || !claimed())
|
|
881
|
+
return false;
|
|
882
|
+
if (draftRegistry === null || clock().getTime() - draftRegistry.at > 5_000) {
|
|
883
|
+
try {
|
|
884
|
+
draftRegistry = { at: clock().getTime(), repos: await readProjects() };
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
draftRegistry = null;
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const read = draftRegistry.repos;
|
|
892
|
+
const problem = await telegramChannelProblem(store, { botId, bindingId: binding.id, approverGeneration: binding.approverGeneration, repos: who.repos }, async () => read);
|
|
893
|
+
return problem === null && turnStillRunning(turnId) && claimed();
|
|
894
|
+
};
|
|
895
|
+
const live = createTelegramLiveDraft({
|
|
896
|
+
store, botId, transport, clock, chatId, binding: binding.id, draftId: row.id,
|
|
897
|
+
// Shaped as the final reply is: for this message, with the phone's own trusted origin.
|
|
898
|
+
shape: { asked: row.text, appOrigin: options.phoneOrigin?.() ?? null },
|
|
899
|
+
standing: draftStanding, paired: () => pairingProblem() === null,
|
|
900
|
+
...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }),
|
|
784
901
|
});
|
|
785
902
|
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
903
|
let outcome;
|
|
@@ -790,12 +907,13 @@ async function runTelegramConversation(row, args) {
|
|
|
790
907
|
...(row.context === null ? {} : { context: row.context }),
|
|
791
908
|
...(replyImage === undefined ? {} : { replyImage }),
|
|
792
909
|
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
793
|
-
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress:
|
|
910
|
+
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress: live.onProgress,
|
|
794
911
|
});
|
|
795
912
|
}
|
|
796
913
|
finally {
|
|
797
914
|
clearInterval(heartbeat);
|
|
798
|
-
|
|
915
|
+
// Closed, and its draft in flight settled, before anything durable: no draft can land after the answer or a notice.
|
|
916
|
+
await live.close();
|
|
799
917
|
}
|
|
800
918
|
const receipt = store.leadRequestReceipt(session.id, row.request);
|
|
801
919
|
if (receipt !== null)
|