@sigrun/ext-gateway 0.0.0-stage → 0.1.2
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 +69 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +112 -0
- package/dist/interactions/chat.d.ts +28 -0
- package/dist/interactions/chat.js +199 -0
- package/dist/interactions/prompts.d.ts +68 -0
- package/dist/interactions/prompts.js +246 -0
- package/dist/interactions/receipts.d.ts +7 -0
- package/dist/interactions/receipts.js +69 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +64 -0
- package/dist/outbound/delivery.d.ts +131 -0
- package/dist/outbound/delivery.js +404 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +81 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +18 -0
- package/dist/session-meta.js +39 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +65 -0
- package/dist/state/identities.js +135 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +176 -0
- package/dist/state/ledger.js +256 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- package/package.json +54 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable chat-prompt bookkeeping. Ledger delivery receipts become
|
|
3
|
+
* MessageRefs here; pending reads stay synchronous for inbound stage 8.
|
|
4
|
+
* Settled prompts stop matching numbered/reply-to answers.
|
|
5
|
+
*
|
|
6
|
+
* Retention (release-readiness spec 4 §§2–4): the persisted file is
|
|
7
|
+
* version 2 — `prompts` rows plus a `settled` tombstone map carrying
|
|
8
|
+
* the settlement timestamp and outcome text. Seven days after
|
|
9
|
+
* settlement the bulky row (refs/plan) expires; the compact tombstone
|
|
10
|
+
* persists PERMANENTLY so a delayed receipt or replay can never
|
|
11
|
+
* resurrect an answerable prompt. Pending rows are never age-pruned.
|
|
12
|
+
* Pruning runs at store initialization and at most once per write (no
|
|
13
|
+
* timer), through the one `prune` helper and its boundary predicate.
|
|
14
|
+
* Refs/plans are therefore bounded by active prompts plus seven days
|
|
15
|
+
* of settled history; the tombstone set is deliberately unbounded.
|
|
16
|
+
*/
|
|
17
|
+
import type { MessageRef } from "@sigrun/sigrun/extension";
|
|
18
|
+
import type { EngineLog } from "../log.js";
|
|
19
|
+
import type { ChatAnswerPlan } from "./render.js";
|
|
20
|
+
/** Settled-row retention (matches the delivery ledger's seven days). */
|
|
21
|
+
export declare const PROMPT_RETENTION_MS = 604800000;
|
|
22
|
+
export interface PendingPrompt {
|
|
23
|
+
readonly conversationId: number;
|
|
24
|
+
readonly callId: string;
|
|
25
|
+
readonly plan: ChatAnswerPlan;
|
|
26
|
+
}
|
|
27
|
+
export interface PromptStoreOptions {
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
readonly log?: EngineLog;
|
|
30
|
+
}
|
|
31
|
+
export declare class PromptStore {
|
|
32
|
+
private readonly file;
|
|
33
|
+
private readonly now;
|
|
34
|
+
private readonly log;
|
|
35
|
+
private rows;
|
|
36
|
+
private settled;
|
|
37
|
+
constructor(path: string, options?: PromptStoreOptions);
|
|
38
|
+
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
39
|
+
record(conversationId: number, callId: string, key: string, ref: MessageRef, plan: ChatAnswerPlan): Promise<void>;
|
|
40
|
+
/** All delivered message refs for settlement edits (including settled). */
|
|
41
|
+
refsOf(conversationId: number, callId: string): readonly MessageRef[];
|
|
42
|
+
/** Whether this recipient/chat already has a prompt recorded. */
|
|
43
|
+
has(conversationId: number, callId: string, key: string): boolean;
|
|
44
|
+
/** Recipient identities for opaque-key receipt correlation. */
|
|
45
|
+
pendingRecipients(conversationId: number): readonly {
|
|
46
|
+
callId: string;
|
|
47
|
+
key: string;
|
|
48
|
+
}[];
|
|
49
|
+
/** Recipient keys for receipt correlation (opaque binding keys may contain colons). */
|
|
50
|
+
keysOf(conversationId: number, callId: string): readonly string[];
|
|
51
|
+
/** Unsettled choice/text prompts in one chat (bound key or user home key). */
|
|
52
|
+
pendingInChat(bindingKey: string): readonly PendingPrompt[];
|
|
53
|
+
/** Find the prompt behind a reply-to platform message id. */
|
|
54
|
+
byReplyTo(bindingKey: string, messageId: string): PendingPrompt | undefined;
|
|
55
|
+
/** A recipient has been enqueued even before its receipt supplies a ref. */
|
|
56
|
+
markEnqueued(conversationId: number, callId: string, key: string, plan: ChatAnswerPlan): Promise<void>;
|
|
57
|
+
/** Install the real refs after the interaction ledger entry is delivered. */
|
|
58
|
+
recordReceipt(conversationId: number, callId: string, key: string, chat: MessageRef["chat"], messageIds: readonly string[]): Promise<void>;
|
|
59
|
+
/** Retire pending answers; the refs stay until retention expires them. */
|
|
60
|
+
settle(conversationId: number, callId: string, outcome?: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* The outcome text recorded at settlement, when this identity is
|
|
63
|
+
* settled. The receipt path reads it to self-heal the prompt edit;
|
|
64
|
+
* the tombstone outlives the bulky row, so the answer survives
|
|
65
|
+
* pruning even when no editable ref remains.
|
|
66
|
+
*/
|
|
67
|
+
settledOutcome(conversationId: number, callId: string, key: string): string | undefined;
|
|
68
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable chat-prompt bookkeeping. Ledger delivery receipts become
|
|
3
|
+
* MessageRefs here; pending reads stay synchronous for inbound stage 8.
|
|
4
|
+
* Settled prompts stop matching numbered/reply-to answers.
|
|
5
|
+
*
|
|
6
|
+
* Retention (release-readiness spec 4 §§2–4): the persisted file is
|
|
7
|
+
* version 2 — `prompts` rows plus a `settled` tombstone map carrying
|
|
8
|
+
* the settlement timestamp and outcome text. Seven days after
|
|
9
|
+
* settlement the bulky row (refs/plan) expires; the compact tombstone
|
|
10
|
+
* persists PERMANENTLY so a delayed receipt or replay can never
|
|
11
|
+
* resurrect an answerable prompt. Pending rows are never age-pruned.
|
|
12
|
+
* Pruning runs at store initialization and at most once per write (no
|
|
13
|
+
* timer), through the one `prune` helper and its boundary predicate.
|
|
14
|
+
* Refs/plans are therefore bounded by active prompts plus seven days
|
|
15
|
+
* of settled history; the tombstone set is deliberately unbounded.
|
|
16
|
+
*/
|
|
17
|
+
import { JsonFile } from "../state/json-file.js";
|
|
18
|
+
/** Settled-row retention (matches the delivery ledger's seven days). */
|
|
19
|
+
export const PROMPT_RETENTION_MS = 604_800_000;
|
|
20
|
+
/** The persisted schema version (v1 = unversioned, `settled: string[]`). */
|
|
21
|
+
const PROMPT_FILE_VERSION = 2;
|
|
22
|
+
export class PromptStore {
|
|
23
|
+
file;
|
|
24
|
+
now;
|
|
25
|
+
log;
|
|
26
|
+
rows;
|
|
27
|
+
settled;
|
|
28
|
+
constructor(path, options = {}) {
|
|
29
|
+
this.now = options.now ?? (() => Date.now());
|
|
30
|
+
this.log = options.log;
|
|
31
|
+
this.file = new JsonFile(path, () => ({
|
|
32
|
+
version: PROMPT_FILE_VERSION,
|
|
33
|
+
prompts: [],
|
|
34
|
+
settled: {},
|
|
35
|
+
}));
|
|
36
|
+
const state = this.file.readSync();
|
|
37
|
+
const migrated = migrate(state, this.now(), this.log);
|
|
38
|
+
// Initialization pruning rides the same helper the writes use.
|
|
39
|
+
prune(migrated, this.now());
|
|
40
|
+
// Persist the normalized/pruned shape ONLY when something changed:
|
|
41
|
+
// a legacy or repaired file must not re-migrate on every boot,
|
|
42
|
+
// while a healthy v2 file needs no rewrite at construction.
|
|
43
|
+
if (JSON.stringify(state) !== JSON.stringify(migrated)) {
|
|
44
|
+
this.file.updateSync((draft) => {
|
|
45
|
+
draft.version = migrated.version;
|
|
46
|
+
draft.prompts = migrated.prompts;
|
|
47
|
+
draft.settled = migrated.settled;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
this.rows = migrated.prompts;
|
|
51
|
+
this.settled = migrated.settled;
|
|
52
|
+
}
|
|
53
|
+
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
54
|
+
async record(conversationId, callId, key, ref, plan) {
|
|
55
|
+
const state = await this.file.update((draft) => {
|
|
56
|
+
const identity = `${conversationId}:${callId}:${key}`;
|
|
57
|
+
const tombstone = draft.settled[identity];
|
|
58
|
+
const existing = draft.prompts.find((row) => identityOf(row) === identity);
|
|
59
|
+
// Settlement is CALL-scoped, not only row-scoped. A settlement
|
|
60
|
+
// landing before the first row exists still records a marker;
|
|
61
|
+
// otherwise a later fan-out/receipt could create an answerable
|
|
62
|
+
// prompt for an already-settled interaction. A real receipt may
|
|
63
|
+
// still install refs on a surviving row for the settlement edit.
|
|
64
|
+
if (existing === undefined && draft.settled[`${conversationId}:${callId}:`] !== undefined)
|
|
65
|
+
return;
|
|
66
|
+
if (tombstone !== undefined) {
|
|
67
|
+
// A settlement raced this record. An EMPTY ref never replaces
|
|
68
|
+
// a delivered one; a REAL ref still installs while the row
|
|
69
|
+
// survives so the late receipt can self-heal the edit. Once
|
|
70
|
+
// the row is pruned, BOTH shapes are ignored — no resurrection.
|
|
71
|
+
const existing = draft.prompts.find((row) => identityOf(row) === identity);
|
|
72
|
+
if (existing === undefined)
|
|
73
|
+
return;
|
|
74
|
+
if (ref.messageId === "")
|
|
75
|
+
return;
|
|
76
|
+
if (existing.ref.messageId !== "")
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const next = { conversationId, callId, key, ref, plan };
|
|
80
|
+
if (existing === undefined)
|
|
81
|
+
draft.prompts.push(next);
|
|
82
|
+
else
|
|
83
|
+
draft.prompts[draft.prompts.indexOf(existing)] = next;
|
|
84
|
+
prune(draft, this.now());
|
|
85
|
+
});
|
|
86
|
+
this.rows = state.prompts;
|
|
87
|
+
this.settled = state.settled;
|
|
88
|
+
}
|
|
89
|
+
/** All delivered message refs for settlement edits (including settled). */
|
|
90
|
+
refsOf(conversationId, callId) {
|
|
91
|
+
return this.rows
|
|
92
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
93
|
+
.map((row) => row.ref);
|
|
94
|
+
}
|
|
95
|
+
/** Whether this recipient/chat already has a prompt recorded. */
|
|
96
|
+
has(conversationId, callId, key) {
|
|
97
|
+
return this.rows.some((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
98
|
+
}
|
|
99
|
+
/** Recipient identities for opaque-key receipt correlation. */
|
|
100
|
+
pendingRecipients(conversationId) {
|
|
101
|
+
return this.rows
|
|
102
|
+
.filter((row) => row.conversationId === conversationId)
|
|
103
|
+
.map((row) => ({ callId: row.callId, key: row.key }));
|
|
104
|
+
}
|
|
105
|
+
/** Recipient keys for receipt correlation (opaque binding keys may contain colons). */
|
|
106
|
+
keysOf(conversationId, callId) {
|
|
107
|
+
return this.rows
|
|
108
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
109
|
+
.map((row) => row.key);
|
|
110
|
+
}
|
|
111
|
+
/** Unsettled choice/text prompts in one chat (bound key or user home key). */
|
|
112
|
+
pendingInChat(bindingKey) {
|
|
113
|
+
return this.rows
|
|
114
|
+
.filter((row) => row.key === bindingKey && this.settled[identityOf(row)] === undefined)
|
|
115
|
+
.map((row) => ({ conversationId: row.conversationId, callId: row.callId, plan: row.plan }));
|
|
116
|
+
}
|
|
117
|
+
/** Find the prompt behind a reply-to platform message id. */
|
|
118
|
+
byReplyTo(bindingKey, messageId) {
|
|
119
|
+
const row = this.rows.find((candidate) => candidate.key === bindingKey &&
|
|
120
|
+
candidate.ref.messageId === messageId &&
|
|
121
|
+
this.settled[identityOf(candidate)] === undefined);
|
|
122
|
+
return row === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: { conversationId: row.conversationId, callId: row.callId, plan: row.plan };
|
|
125
|
+
}
|
|
126
|
+
/** A recipient has been enqueued even before its receipt supplies a ref. */
|
|
127
|
+
async markEnqueued(conversationId, callId, key, plan) {
|
|
128
|
+
await this.record(conversationId, callId, key, { chat: { chatId: "", chatType: "dm" }, messageId: "" }, plan);
|
|
129
|
+
}
|
|
130
|
+
/** Install the real refs after the interaction ledger entry is delivered. */
|
|
131
|
+
async recordReceipt(conversationId, callId, key, chat, messageIds) {
|
|
132
|
+
const existing = this.rows.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
133
|
+
await this.record(conversationId, callId, key, { chat, messageId: messageIds[0] ?? "" }, existing?.plan ?? { kind: "webui" });
|
|
134
|
+
}
|
|
135
|
+
/** Retire pending answers; the refs stay until retention expires them. */
|
|
136
|
+
async settle(conversationId, callId, outcome = "answered") {
|
|
137
|
+
const state = await this.file.update((draft) => {
|
|
138
|
+
const keys = [
|
|
139
|
+
`${conversationId}:${callId}:`,
|
|
140
|
+
...draft.prompts
|
|
141
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
142
|
+
.map(identityOf),
|
|
143
|
+
];
|
|
144
|
+
for (const identity of keys) {
|
|
145
|
+
// Re-settling an already-settled interaction keeps the FIRST
|
|
146
|
+
// settlement's timestamp and outcome: retention is measured
|
|
147
|
+
// from settlement, never extended by later writes.
|
|
148
|
+
if (draft.settled[identity] !== undefined)
|
|
149
|
+
continue;
|
|
150
|
+
draft.settled[identity] = { at: this.now(), outcome };
|
|
151
|
+
}
|
|
152
|
+
prune(draft, this.now());
|
|
153
|
+
});
|
|
154
|
+
this.rows = state.prompts;
|
|
155
|
+
this.settled = state.settled;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The outcome text recorded at settlement, when this identity is
|
|
159
|
+
* settled. The receipt path reads it to self-heal the prompt edit;
|
|
160
|
+
* the tombstone outlives the bulky row, so the answer survives
|
|
161
|
+
* pruning even when no editable ref remains.
|
|
162
|
+
*/
|
|
163
|
+
settledOutcome(conversationId, callId, key) {
|
|
164
|
+
return this.settled[`${conversationId}:${callId}:${key}`]?.outcome
|
|
165
|
+
?? this.settled[`${conversationId}:${callId}:`]?.outcome;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* THE retention helper (init and every write call this, at most once
|
|
170
|
+
* per write): a SETTLED row expires at `settledAt + RETENTION_MS <= now`.
|
|
171
|
+
* The tombstone itself is never pruned — it is what stops a delayed
|
|
172
|
+
* receipt from recreating an answerable prompt. Pending rows are
|
|
173
|
+
* never age-pruned.
|
|
174
|
+
*/
|
|
175
|
+
function prune(draft, now) {
|
|
176
|
+
draft.prompts = draft.prompts.filter((row) => {
|
|
177
|
+
const tombstone = draft.settled[identityOf(row)];
|
|
178
|
+
if (tombstone === undefined)
|
|
179
|
+
return true;
|
|
180
|
+
return tombstone.at + PROMPT_RETENTION_MS > now;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Normalize whatever was on disk to the v2 shape. An unversioned v1
|
|
185
|
+
* file keeps every prompt row and stamps its settled identities with
|
|
186
|
+
* the MIGRATION time (one cohort, expiring together seven days later).
|
|
187
|
+
* Individually malformed tombstones are dropped with a log line each;
|
|
188
|
+
* valid unrelated rows are never discarded. A wholly malformed JSON
|
|
189
|
+
* document never reaches here — JsonFile throws naming the path.
|
|
190
|
+
*/
|
|
191
|
+
function migrate(state, now, log) {
|
|
192
|
+
const prompts = Array.isArray(state.prompts) ? state.prompts : [];
|
|
193
|
+
const settled = {};
|
|
194
|
+
const raw = state.settled;
|
|
195
|
+
if (Array.isArray(raw)) {
|
|
196
|
+
// v1: bare identities, age unknown — the migration timestamp is
|
|
197
|
+
// the conservative choice (one further retention window).
|
|
198
|
+
for (const identity of raw) {
|
|
199
|
+
if (typeof identity !== "string")
|
|
200
|
+
continue;
|
|
201
|
+
settled[identity] = { at: now, outcome: "answered" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (raw !== null && typeof raw === "object") {
|
|
205
|
+
for (const [identity, value] of Object.entries(raw)) {
|
|
206
|
+
const tombstone = value;
|
|
207
|
+
if (tombstone === null || typeof tombstone !== "object" ||
|
|
208
|
+
typeof tombstone.at !== "number" || !Number.isFinite(tombstone.at) ||
|
|
209
|
+
typeof tombstone.outcome !== "string") {
|
|
210
|
+
log?.warn(`prompts: dropping malformed settled tombstone "${identity}" (its row, if any, stays live)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!validIdentity(identity)) {
|
|
214
|
+
log?.warn(`prompts: dropping settled tombstone with a malformed identity "${identity}"`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
settled[identity] = { at: tombstone.at, outcome: tombstone.outcome };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (raw !== undefined) {
|
|
221
|
+
log?.warn("prompts: ignoring malformed settled container (expected an array or map)");
|
|
222
|
+
}
|
|
223
|
+
return { version: PROMPT_FILE_VERSION, prompts, settled };
|
|
224
|
+
}
|
|
225
|
+
function identityOf(row) {
|
|
226
|
+
return `${row.conversationId}:${row.callId}:${row.key}`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* An identity is `<conversationId>:<callId>:<key>` — a positive integer
|
|
230
|
+
* conversation id, a non-empty callId without colons, and a non-empty
|
|
231
|
+
* key (opaque binding/home keys may themselves contain colons).
|
|
232
|
+
*/
|
|
233
|
+
function validIdentity(identity) {
|
|
234
|
+
const separator = identity.indexOf(":");
|
|
235
|
+
if (separator <= 0)
|
|
236
|
+
return false;
|
|
237
|
+
if (!/^[1-9][0-9]*$/.test(identity.slice(0, separator)))
|
|
238
|
+
return false;
|
|
239
|
+
const rest = identity.slice(separator + 1);
|
|
240
|
+
const second = rest.indexOf(":");
|
|
241
|
+
// The empty key is the call-level settlement marker, written when
|
|
242
|
+
// settlement precedes prompt-row creation.
|
|
243
|
+
if (second === rest.length - 1)
|
|
244
|
+
return true;
|
|
245
|
+
return second > 0;
|
|
246
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
|
+
import type { LedgerEntry } from "../state/ledger.js";
|
|
3
|
+
import type { PromptStore } from "./prompts.js";
|
|
4
|
+
import { type InteractionChatDeps } from "./chat.js";
|
|
5
|
+
export declare function receiptForInteraction(prompts: PromptStore, entry: LedgerEntry, receipt: {
|
|
6
|
+
readonly messageIds: readonly string[];
|
|
7
|
+
}, deps?: InteractionChatDeps): Promise<void>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
|
+
import { enqueueSettlementEdits } from "./chat.js";
|
|
3
|
+
export async function receiptForInteraction(prompts, entry, receipt, deps) {
|
|
4
|
+
// The receipt is authoritative: an early settlement may have removed
|
|
5
|
+
// the pending row, but the delivered message still needs an edit ref.
|
|
6
|
+
if (entry.bindingKey !== undefined) {
|
|
7
|
+
const match = entry.tag?.match(/^interaction:(\d+):(.+)$/);
|
|
8
|
+
if (match === null || match === undefined)
|
|
9
|
+
return;
|
|
10
|
+
const suffix = `:${entry.bindingKey}`;
|
|
11
|
+
if (!match[2].endsWith(suffix))
|
|
12
|
+
return;
|
|
13
|
+
const callId = match[2].slice(0, -suffix.length);
|
|
14
|
+
const conversationId = Number(match[1]);
|
|
15
|
+
await prompts.recordReceipt(conversationId, callId, entry.bindingKey, entry.chat, receipt.messageIds);
|
|
16
|
+
await healSettledPrompt(prompts, deps, conversationId, callId, entry.bindingKey, receipt.messageIds);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const tag = entry.tag;
|
|
20
|
+
if (tag === undefined || !tag.startsWith("interaction:"))
|
|
21
|
+
return;
|
|
22
|
+
const rest = tag.slice("interaction:".length);
|
|
23
|
+
const separator = rest.indexOf(":");
|
|
24
|
+
if (separator < 0)
|
|
25
|
+
return;
|
|
26
|
+
const conversationId = Number(rest.slice(0, separator));
|
|
27
|
+
const afterConversation = rest.slice(separator + 1);
|
|
28
|
+
if (!Number.isInteger(conversationId))
|
|
29
|
+
return;
|
|
30
|
+
// Unbound home keys have no ledger bindingKey; the pending rows'
|
|
31
|
+
// callIds are the other side of the persisted identity.
|
|
32
|
+
for (const row of prompts.pendingRecipients(conversationId)) {
|
|
33
|
+
const suffix = `${row.callId}:${row.key}`;
|
|
34
|
+
if (afterConversation !== suffix)
|
|
35
|
+
continue;
|
|
36
|
+
await prompts.recordReceipt(conversationId, row.callId, row.key, entry.chat, receipt.messageIds);
|
|
37
|
+
await healSettledPrompt(prompts, deps, conversationId, row.callId, row.key, receipt.messageIds);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A real receipt for an ALREADY-settled prompt self-heals the live
|
|
43
|
+
* buttons: settlement ran with an empty ref (no edit possible, so a
|
|
44
|
+
* notice went out), and this delivered message is the one that now
|
|
45
|
+
* needs the outcome edit. The edit tag is the same the settlement
|
|
46
|
+
* path would have used, so a repeat receipt stays a no-op.
|
|
47
|
+
*/
|
|
48
|
+
async function healSettledPrompt(prompts, deps, conversationId, callId, key, messageIds) {
|
|
49
|
+
if (deps === undefined)
|
|
50
|
+
return;
|
|
51
|
+
if (messageIds.length === 0 || messageIds[0] === "")
|
|
52
|
+
return;
|
|
53
|
+
const outcome = prompts.settledOutcome(conversationId, callId, key);
|
|
54
|
+
if (outcome === undefined)
|
|
55
|
+
return;
|
|
56
|
+
// Tombstones outlive their bulky rows. A receipt AFTER pruning must
|
|
57
|
+
// be a true no-op: with no surviving row/ref, the settlement helper
|
|
58
|
+
// would otherwise take its fallback and enqueue duplicate notices.
|
|
59
|
+
const ref = prompts.refsOf(conversationId, callId)
|
|
60
|
+
.find((candidate) => candidate.messageId === messageIds[0]);
|
|
61
|
+
if (ref === undefined)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
await enqueueSettlementEdits(deps, conversationId, callId, outcome);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
deps.log.error(`settled prompt receipt heal failed (${conversationId}:${callId})`, error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic chat presentation for a generic interaction record.
|
|
3
|
+
* The planner admits only answers chat can represent faithfully; every
|
|
4
|
+
* other root form stays in the webui (notably secrets and open choices).
|
|
5
|
+
*/
|
|
6
|
+
import { type ChoiceOption } from "@sigrun/shared/blocks";
|
|
7
|
+
import type { InteractionRecord } from "@sigrun/shared/interactions";
|
|
8
|
+
import type { ChannelCapabilities, OutboundMessage } from "@sigrun/sigrun/extension";
|
|
9
|
+
import type { TokenAction } from "../inbound/tokens.js";
|
|
10
|
+
/** The answer shapes a chat can collect without losing form semantics. */
|
|
11
|
+
export type ChatAnswerPlan = {
|
|
12
|
+
readonly kind: "choice";
|
|
13
|
+
readonly field: string;
|
|
14
|
+
readonly options: readonly ChoiceOption[];
|
|
15
|
+
readonly buttons: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: "text";
|
|
18
|
+
readonly field: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: "webui";
|
|
21
|
+
};
|
|
22
|
+
export interface RenderInteractionOptions {
|
|
23
|
+
readonly conversationName: string;
|
|
24
|
+
readonly link?: string;
|
|
25
|
+
/** The conversation stamped into each interaction token. */
|
|
26
|
+
readonly conversationId?: number;
|
|
27
|
+
/** Mint the opaque transport-private action carried by each button. */
|
|
28
|
+
readonly issue: (action: TokenAction & {
|
|
29
|
+
readonly kind: "interaction";
|
|
30
|
+
}) => string;
|
|
31
|
+
}
|
|
32
|
+
/** Plan the one answer mechanism the record's root form supports in chat. */
|
|
33
|
+
export declare function planInteraction(record: InteractionRecord, caps: ChannelCapabilities): ChatAnswerPlan;
|
|
34
|
+
/** Render the generic record and its chat-specific answer affordance. */
|
|
35
|
+
export declare function renderInteraction(record: InteractionRecord, caps: ChannelCapabilities, opts: RenderInteractionOptions): {
|
|
36
|
+
text: string;
|
|
37
|
+
buttons?: OutboundMessage["buttons"];
|
|
38
|
+
};
|
|
39
|
+
export declare function truncateInteractionText(text: string, max: number): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic chat presentation for a generic interaction record.
|
|
3
|
+
* The planner admits only answers chat can represent faithfully; every
|
|
4
|
+
* other root form stays in the webui (notably secrets and open choices).
|
|
5
|
+
*/
|
|
6
|
+
import { toText } from "@sigrun/shared/blocks";
|
|
7
|
+
import { renderChunks } from "../outbound/render.js";
|
|
8
|
+
/** Plan the one answer mechanism the record's root form supports in chat. */
|
|
9
|
+
export function planInteraction(record, caps) {
|
|
10
|
+
const root = record.body?.root;
|
|
11
|
+
if (root?.type !== "form")
|
|
12
|
+
return { kind: "webui" };
|
|
13
|
+
const fields = root.children.filter((child) => child.type !== "section");
|
|
14
|
+
// A section can carry arbitrary presentation/field nesting. Planning
|
|
15
|
+
// only the flat root form avoids claiming a subset of a richer form.
|
|
16
|
+
if (fields.length !== root.children.length)
|
|
17
|
+
return { kind: "webui" };
|
|
18
|
+
const choices = fields.filter((field) => field.type === "choice");
|
|
19
|
+
if (choices.length === 1 &&
|
|
20
|
+
fields.every((field) => field.type === "choice" ||
|
|
21
|
+
((field.type === "text" || field.type === "longtext") && field.required !== true))) {
|
|
22
|
+
const choice = choices[0];
|
|
23
|
+
if (choice.multi !== true &&
|
|
24
|
+
choice.allowOther === undefined &&
|
|
25
|
+
Array.isArray(choice.options)) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "choice",
|
|
28
|
+
field: choice.name,
|
|
29
|
+
options: choice.options,
|
|
30
|
+
buttons: choice.options.length <= (caps.buttons?.max ?? 0),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (fields.length === 1 && (fields[0].type === "text" || fields[0].type === "longtext")) {
|
|
35
|
+
return { kind: "text", field: fields[0].name };
|
|
36
|
+
}
|
|
37
|
+
return { kind: "webui" };
|
|
38
|
+
}
|
|
39
|
+
/** Render the generic record and its chat-specific answer affordance. */
|
|
40
|
+
export function renderInteraction(record, caps, opts) {
|
|
41
|
+
const plan = planInteraction(record, caps);
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (record.body !== undefined) {
|
|
44
|
+
const projected = plan.kind === "choice" ? withoutFormFields(record.body) : record.body;
|
|
45
|
+
const text = toText(projected, {}, { width: 60, locale: "en" });
|
|
46
|
+
if (text !== "")
|
|
47
|
+
lines.push(text);
|
|
48
|
+
}
|
|
49
|
+
if (opts.conversationName !== "")
|
|
50
|
+
lines.push(`Conversation: ${opts.conversationName}`);
|
|
51
|
+
let buttons;
|
|
52
|
+
if (plan.kind === "choice") {
|
|
53
|
+
const options = [...plan.options].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true));
|
|
54
|
+
const label = choiceLabel(record.body, plan.field);
|
|
55
|
+
if (label !== "")
|
|
56
|
+
lines.push(label);
|
|
57
|
+
if (plan.buttons) {
|
|
58
|
+
const row = options.map((option) => ({
|
|
59
|
+
label: truncateLabel(option.label, caps.buttons?.labelMax),
|
|
60
|
+
data: opts.issue({
|
|
61
|
+
kind: "interaction",
|
|
62
|
+
field: plan.field,
|
|
63
|
+
conversationId: opts.conversationId ?? 0,
|
|
64
|
+
callId: record.callId,
|
|
65
|
+
optionId: option.id,
|
|
66
|
+
}),
|
|
67
|
+
...(option.tone === "bad" ? { style: "danger" } : {}),
|
|
68
|
+
}));
|
|
69
|
+
const perRow = Math.max(1, caps.buttons?.perRow ?? row.length);
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (let index = 0; index < row.length; index += perRow)
|
|
72
|
+
rows.push(row.slice(index, index + perRow));
|
|
73
|
+
buttons = rows;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
options.forEach((option, index) => lines.push(`${index + 1}. ${option.label}`));
|
|
77
|
+
lines.push("Reply with a number.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (plan.kind === "text") {
|
|
81
|
+
lines.push("Reply to this message to answer.");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push("Answer this in the webui");
|
|
85
|
+
if (opts.link !== undefined)
|
|
86
|
+
lines.push(opts.link);
|
|
87
|
+
}
|
|
88
|
+
// The body projection is plain text; the capability formatter still
|
|
89
|
+
// runs so native/plain channels get the same §8.6 treatment as finals.
|
|
90
|
+
const formatted = renderChunks(lines.join("\n"), caps).join("\n");
|
|
91
|
+
return {
|
|
92
|
+
text: truncateInteractionText(formatted, caps.maxMessageLength),
|
|
93
|
+
...(buttons === undefined ? {} : { buttons }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Omit form fields when buttons/the numbered list replace their rendering. */
|
|
97
|
+
function withoutFormFields(tree) {
|
|
98
|
+
if (tree.root.type !== "form")
|
|
99
|
+
return tree;
|
|
100
|
+
const root = { ...tree.root, children: tree.root.children.filter((child) => child.type === "section") };
|
|
101
|
+
return { ...tree, root };
|
|
102
|
+
}
|
|
103
|
+
function choiceLabel(body, name) {
|
|
104
|
+
if (body?.root.type !== "form")
|
|
105
|
+
return "";
|
|
106
|
+
const field = body.root.children.find((child) => child.type === "choice" && child.name === name);
|
|
107
|
+
return field?.type === "choice" && typeof field.label === "string" ? field.label : "";
|
|
108
|
+
}
|
|
109
|
+
function truncateLabel(label, max) {
|
|
110
|
+
// Literal options are a planner requirement, so their labels are text.
|
|
111
|
+
const text = typeof label === "string" ? label : "";
|
|
112
|
+
return max === undefined ? text : text.slice(0, Math.max(0, max));
|
|
113
|
+
}
|
|
114
|
+
export function truncateInteractionText(text, max) {
|
|
115
|
+
const limit = Math.max(1, max);
|
|
116
|
+
if (text.length <= limit)
|
|
117
|
+
return text;
|
|
118
|
+
const note = "\n… full details in the webui";
|
|
119
|
+
if (limit <= note.length)
|
|
120
|
+
return text.slice(0, limit);
|
|
121
|
+
return `${text.slice(0, limit - note.length).trimEnd()}${note}`;
|
|
122
|
+
}
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job delivery targets (spec §8.8): a channel alone means the owner's
|
|
3
|
+
* home DM; an explicit chat must be that home DM or a chat bound to a
|
|
4
|
+
* conversation the owner can write. This policy gate is shared by job
|
|
5
|
+
* delivery and the chat-facing message tools.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChatRef, SigrunHost } from "@sigrun/sigrun/extension";
|
|
8
|
+
import type { BindingStore } from "./state/bindings.js";
|
|
9
|
+
import type { IdentityStore } from "./state/identities.js";
|
|
10
|
+
export interface DeliveryTarget {
|
|
11
|
+
readonly channel: string;
|
|
12
|
+
readonly chatId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ResolvedTarget {
|
|
15
|
+
readonly channel: string;
|
|
16
|
+
readonly chat: ChatRef;
|
|
17
|
+
}
|
|
18
|
+
export interface RejectedTarget {
|
|
19
|
+
readonly target: string;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ResolveTargetsResult {
|
|
23
|
+
readonly resolved: readonly ResolvedTarget[];
|
|
24
|
+
readonly rejected: readonly RejectedTarget[];
|
|
25
|
+
}
|
|
26
|
+
export interface TargetDeps {
|
|
27
|
+
readonly host: SigrunHost;
|
|
28
|
+
readonly bindings: BindingStore;
|
|
29
|
+
readonly identities: IdentityStore;
|
|
30
|
+
}
|
|
31
|
+
/** Parse `<channel>` or `<channel>:<chatId>`; the chat id is opaque. */
|
|
32
|
+
export declare function parseTarget(target: string): DeliveryTarget | undefined;
|
|
33
|
+
/** Resolve and authorize each target independently; no target aborts the rest. */
|
|
34
|
+
export declare function resolveTargets(deps: TargetDeps, owner: string, targets: readonly string[]): Promise<ResolveTargetsResult>;
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job delivery targets (spec §8.8): a channel alone means the owner's
|
|
3
|
+
* home DM; an explicit chat must be that home DM or a chat bound to a
|
|
4
|
+
* conversation the owner can write. This policy gate is shared by job
|
|
5
|
+
* delivery and the chat-facing message tools.
|
|
6
|
+
*/
|
|
7
|
+
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
8
|
+
/** Parse `<channel>` or `<channel>:<chatId>`; the chat id is opaque. */
|
|
9
|
+
export function parseTarget(target) {
|
|
10
|
+
const separator = target.indexOf(":");
|
|
11
|
+
const channel = separator === -1 ? target : target.slice(0, separator);
|
|
12
|
+
const chatId = separator === -1 ? undefined : target.slice(separator + 1);
|
|
13
|
+
if (!CHANNEL_PATTERN.test(channel) || chatId === "")
|
|
14
|
+
return undefined;
|
|
15
|
+
return chatId === undefined ? { channel } : { channel, chatId };
|
|
16
|
+
}
|
|
17
|
+
/** Resolve and authorize each target independently; no target aborts the rest. */
|
|
18
|
+
export async function resolveTargets(deps, owner, targets) {
|
|
19
|
+
const resolved = [];
|
|
20
|
+
const rejected = [];
|
|
21
|
+
const homes = deps.identities.homesOf(owner);
|
|
22
|
+
const bindings = deps.bindings.entries();
|
|
23
|
+
for (const target of targets) {
|
|
24
|
+
const parsed = parseTarget(target);
|
|
25
|
+
if (parsed === undefined) {
|
|
26
|
+
rejected.push({ target, reason: "invalid target grammar" });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const home = homes.find((entry) => entry.channel === parsed.channel);
|
|
30
|
+
if (parsed.chatId === undefined) {
|
|
31
|
+
if (home === undefined) {
|
|
32
|
+
rejected.push({ target, reason: `no home chat on channel "${parsed.channel}"` });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
resolved.push({ channel: parsed.channel, chat: home.chat });
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (home?.chat.chatId === parsed.chatId) {
|
|
40
|
+
resolved.push({ channel: parsed.channel, chat: home.chat });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const binding = findChatBinding(bindings, parsed.channel, parsed.chatId);
|
|
44
|
+
if (binding?.dead === true) {
|
|
45
|
+
rejected.push({ target, reason: "chat is gone" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const meta = binding === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: await deps.host.conversations.meta(binding.conversationId);
|
|
51
|
+
const access = meta === undefined
|
|
52
|
+
? "none"
|
|
53
|
+
: await deps.host.access.accessOf({ user: owner }, { owner: meta.owner, sharing: meta.sharing });
|
|
54
|
+
if (binding === undefined || access !== "write") {
|
|
55
|
+
rejected.push({ target, reason: "chat is not bound to a writable conversation" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
resolved.push({ channel: parsed.channel, chat: binding.chat });
|
|
59
|
+
}
|
|
60
|
+
return { resolved, rejected };
|
|
61
|
+
}
|
|
62
|
+
/** Find a chat without guessing its thread id or group/DM type. */
|
|
63
|
+
function findChatBinding(bindings, channel, chatId) {
|
|
64
|
+
const prefix = `${channel}:default:`;
|
|
65
|
+
for (const [key, binding] of Object.entries(bindings)) {
|
|
66
|
+
if (!key.startsWith(prefix))
|
|
67
|
+
continue;
|
|
68
|
+
if (binding?.chat.chatId === chatId)
|
|
69
|
+
return binding;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|