@flow-engineer/flow 0.0.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +611 -0
- package/coding-agents/skill.md +353 -0
- package/coding-agents/test-skill.md +223 -0
- package/coding-agents/tools-skill.md +89 -0
- package/dist/flow-messaging/src/client.d.ts +40 -0
- package/dist/flow-messaging/src/client.js +142 -0
- package/dist/flow-messaging/src/index.d.ts +72 -0
- package/dist/flow-messaging/src/index.js +605 -0
- package/dist/simulators/src/index.d.ts +2 -0
- package/dist/simulators/src/index.js +2 -0
- package/dist/simulators/src/page.html +1245 -0
- package/dist/simulators/src/person.html +100 -0
- package/dist/simulators/src/platforms.d.ts +29 -0
- package/dist/simulators/src/platforms.js +72 -0
- package/dist/simulators/src/server.d.ts +201 -0
- package/dist/simulators/src/server.js +759 -0
- package/dist/src/agent.d.ts +504 -0
- package/dist/src/agent.js +818 -0
- package/dist/src/channels.d.ts +178 -0
- package/dist/src/channels.js +243 -0
- package/dist/src/conversation.d.ts +361 -0
- package/dist/src/conversation.js +1487 -0
- package/dist/src/db.d.ts +18 -0
- package/dist/src/db.js +57 -0
- package/dist/src/events.d.ts +17 -0
- package/dist/src/events.js +20 -0
- package/dist/src/http.d.ts +45 -0
- package/dist/src/http.js +269 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +11 -0
- package/dist/src/model.d.ts +21 -0
- package/dist/src/model.js +96 -0
- package/dist/src/names.d.ts +22 -0
- package/dist/src/names.js +43 -0
- package/dist/src/nudges.d.ts +23 -0
- package/dist/src/nudges.js +117 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/postgres.js +62 -0
- package/dist/src/schedules.d.ts +60 -0
- package/dist/src/schedules.js +249 -0
- package/dist/src/skills.d.ts +12 -0
- package/dist/src/skills.js +69 -0
- package/dist/src/store.d.ts +214 -0
- package/dist/src/store.js +448 -0
- package/dist/src/talk.d.ts +57 -0
- package/dist/src/talk.js +166 -0
- package/dist/src/time.d.ts +71 -0
- package/dist/src/time.js +170 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.js +26 -0
- package/dist/src/traces.d.ts +31 -0
- package/dist/src/traces.js +303 -0
- package/dist/src/types.d.ts +260 -0
- package/dist/src/types.js +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +580 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +117 -0
- package/dist/testing/src/check-endpoint.d.ts +31 -0
- package/dist/testing/src/check-endpoint.js +158 -0
- package/dist/testing/src/chinwag.d.ts +2 -0
- package/dist/testing/src/chinwag.js +6 -0
- package/dist/testing/src/cli.d.ts +2 -0
- package/dist/testing/src/cli.js +592 -0
- package/dist/testing/src/client.d.ts +117 -0
- package/dist/testing/src/client.js +956 -0
- package/dist/testing/src/flow-login.d.ts +143 -0
- package/dist/testing/src/flow-login.js +249 -0
- package/dist/testing/src/http-agent.d.ts +47 -0
- package/dist/testing/src/http-agent.js +155 -0
- package/dist/testing/src/index.d.ts +3 -0
- package/dist/testing/src/index.js +2 -0
- package/dist/testing/src/new.d.ts +13 -0
- package/dist/testing/src/new.js +147 -0
- package/dist/testing/src/print-run.d.ts +3 -0
- package/dist/testing/src/print-run.js +22 -0
- package/dist/testing/src/protocol.d.ts +159 -0
- package/dist/testing/src/protocol.js +28 -0
- package/dist/testing/src/report.d.ts +74 -0
- package/dist/testing/src/report.js +90 -0
- package/dist/testing/src/show.d.ts +36 -0
- package/dist/testing/src/show.js +280 -0
- package/dist/testing/src/suite.d.ts +87 -0
- package/dist/testing/src/suite.js +198 -0
- package/docs/agents.md +85 -0
- package/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +43 -0
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/main.ts +30 -0
- package/examples/cafe/src/tools.ts +62 -0
- package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
- package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
- package/examples/cafe/tests/tools/get_menu.yaml +14 -0
- package/examples/cafe/tests/tools/place_order.yaml +29 -0
- package/examples/http-agent/server.mjs +32 -0
- package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
- package/flow-messaging/README.md +191 -0
- package/package.json +63 -8
- package/spec/agent-endpoint.openapi.yaml +148 -0
- package/spec/nudges.openapi.yaml +167 -0
- package/spec/request.schema.json +121 -0
- package/spec/response.schema.json +284 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Database } from "./db.ts";
|
|
2
|
+
import type { ChatMessage } from "./types.ts";
|
|
3
|
+
/** Quiet hours, "HH:MM" to "HH:MM" in the person's own time; they may wrap midnight. */
|
|
4
|
+
export interface QuietHours {
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
}
|
|
8
|
+
/** Who said where a person is: the business (setTimeZone), or the person (they named a place). */
|
|
9
|
+
export type TimeZoneSource = "business" | "person";
|
|
10
|
+
export interface ConversationRow {
|
|
11
|
+
id: string;
|
|
12
|
+
personKey: string;
|
|
13
|
+
timeZone: string | null;
|
|
14
|
+
timeZoneSource: TimeZoneSource | null;
|
|
15
|
+
/** Their own quiet hours: "off" for none, null for the agent's. */
|
|
16
|
+
quietHours: QuietHours | "off" | null;
|
|
17
|
+
turnOpen: boolean;
|
|
18
|
+
messageCount: number;
|
|
19
|
+
recentChannels: string[];
|
|
20
|
+
/** Turns begun so far; a turn run again after a restart keeps its number. */
|
|
21
|
+
turns: number;
|
|
22
|
+
}
|
|
23
|
+
export interface IdentityRow {
|
|
24
|
+
channel: string;
|
|
25
|
+
address: string;
|
|
26
|
+
conversationId: string;
|
|
27
|
+
verifiedBy: string;
|
|
28
|
+
lastMessageAt: number | null;
|
|
29
|
+
/** Where their latest message on this identity came from, so sends to it go back the same way. */
|
|
30
|
+
via: Via;
|
|
31
|
+
}
|
|
32
|
+
/** A real provider's webhook, or the test playground (a test person on a simulator). */
|
|
33
|
+
export type Via = "provider" | "playground";
|
|
34
|
+
export interface InboxRow {
|
|
35
|
+
id: number;
|
|
36
|
+
kind: "message" | "note" | "schedule" | "nudge";
|
|
37
|
+
payload: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
export interface ScheduleRow {
|
|
40
|
+
id: string;
|
|
41
|
+
conversationId: string;
|
|
42
|
+
channel: string;
|
|
43
|
+
instruction: string;
|
|
44
|
+
fixedText: boolean;
|
|
45
|
+
mode: "message" | "check";
|
|
46
|
+
rule: Record<string, unknown>;
|
|
47
|
+
timeZone: string;
|
|
48
|
+
nextRunAt: number | null;
|
|
49
|
+
runCount: number;
|
|
50
|
+
maxRuns: number | null;
|
|
51
|
+
endsOn: string | null;
|
|
52
|
+
status: "active" | "done" | "cancelled";
|
|
53
|
+
version: number;
|
|
54
|
+
origin: "person" | "api";
|
|
55
|
+
createdAt: number;
|
|
56
|
+
}
|
|
57
|
+
export type NewSchedule = Omit<ScheduleRow, "version" | "runCount" | "status" | "createdAt">;
|
|
58
|
+
export interface NudgeRow {
|
|
59
|
+
id: string;
|
|
60
|
+
conversationId: string;
|
|
61
|
+
channel: string;
|
|
62
|
+
body: Record<string, unknown>;
|
|
63
|
+
status: string;
|
|
64
|
+
reason: string | null;
|
|
65
|
+
idempotencyKey: string | null;
|
|
66
|
+
}
|
|
67
|
+
export interface NoContactRow {
|
|
68
|
+
id: string;
|
|
69
|
+
rule: string;
|
|
70
|
+
until: number | null;
|
|
71
|
+
/** They texted STOP: nothing the business starts goes to them, with no model call, until START. */
|
|
72
|
+
hard: boolean;
|
|
73
|
+
/** stop (their STOP), pause (nothing at all until `until`: no model call), topic (a model judges each message). */
|
|
74
|
+
kind: "stop" | "pause" | "topic";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What an agent needs of its store: a Store, or something that forwards each call to a Store
|
|
78
|
+
* elsewhere (a store service that holds the database, so the agent never holds its credentials).
|
|
79
|
+
*/
|
|
80
|
+
export type StoreApi = Omit<Store, "db" | "agentId">;
|
|
81
|
+
export declare class Store {
|
|
82
|
+
readonly db: Database;
|
|
83
|
+
readonly agentId: string;
|
|
84
|
+
private ready;
|
|
85
|
+
constructor(db: Database, agentId: string);
|
|
86
|
+
/** The state survives a restart, so an agent must resume() before its first message. */
|
|
87
|
+
get durable(): boolean;
|
|
88
|
+
/** Creates or upgrades Flow Framework's tables; safe to call any number of times. */
|
|
89
|
+
migrate(): Promise<void>;
|
|
90
|
+
identity(channel: string, address: string): Promise<IdentityRow | null>;
|
|
91
|
+
identities(conversationId: string): Promise<IdentityRow[]>;
|
|
92
|
+
/** The conversation of one person, by their person key: `key:<resolve's value>`, `phone:<E.164>` or `<channel>:<address>`. */
|
|
93
|
+
conversationByKey(personKey: string): Promise<ConversationRow | null>;
|
|
94
|
+
conversation(id: string): Promise<ConversationRow | null>;
|
|
95
|
+
allConversations(): Promise<ConversationRow[]>;
|
|
96
|
+
createConversation(id: string, personKey: string): Promise<void>;
|
|
97
|
+
addIdentity(conversationId: string, channel: string, address: string, verifiedBy: string): Promise<void>;
|
|
98
|
+
touchIdentity(channel: string, address: string, at: number): Promise<void>;
|
|
99
|
+
setIdentityVia(channel: string, address: string, via: Via): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Deletes everything kept about one person: their conversation, identities, history, inbox,
|
|
102
|
+
* background tools, schedules and their runs, nudges and no-contact rules.
|
|
103
|
+
*/
|
|
104
|
+
forgetPerson(conversationId: string): Promise<void>;
|
|
105
|
+
updateConversation(id: string, fields: {
|
|
106
|
+
turnOpen?: boolean;
|
|
107
|
+
turns?: number;
|
|
108
|
+
messageCount?: number;
|
|
109
|
+
recentChannels?: string[];
|
|
110
|
+
timeZone?: string;
|
|
111
|
+
timeZoneSource?: TimeZoneSource;
|
|
112
|
+
quietHours?: QuietHours | "off" | null;
|
|
113
|
+
}): Promise<void>;
|
|
114
|
+
history(conversationId: string): Promise<ChatMessage[]>;
|
|
115
|
+
/** The history with when each entry was stored, for showing the conversation (the playground). */
|
|
116
|
+
historyWithTimes(conversationId: string): Promise<Array<{
|
|
117
|
+
entry: ChatMessage;
|
|
118
|
+
at: number;
|
|
119
|
+
}>>;
|
|
120
|
+
/** Appends history entries from `seq` on, and marks inbox rows handled, together. */
|
|
121
|
+
appendHistory(conversationId: string, seq: number, entries: ChatMessage[], doneInbox?: number[]): Promise<void>;
|
|
122
|
+
/** A provider's message id already taken in this conversation on this channel (a webhook sent again). */
|
|
123
|
+
inboundTaken(conversationId: string, channel: string, messageId: string): Promise<boolean>;
|
|
124
|
+
/** Records a provider's message id, in the transaction of what its message stores; false when it was already taken. */
|
|
125
|
+
private inbound;
|
|
126
|
+
/**
|
|
127
|
+
* A person's message into the inbox, with the provider's id for it in the same transaction, so
|
|
128
|
+
* a crash never keeps the id without the message (its retry would be refused). Returns the inbox
|
|
129
|
+
* row's id, or null when the id was already taken: nothing is written then.
|
|
130
|
+
*/
|
|
131
|
+
addMessage(conversationId: string, payload: Record<string, unknown>, inbound?: {
|
|
132
|
+
channel: string;
|
|
133
|
+
messageId: string;
|
|
134
|
+
}): Promise<number | null>;
|
|
135
|
+
/** Deletes up to `limit` provider message ids recorded before `before`; returns how many it deleted. */
|
|
136
|
+
pruneInbound(before: number, limit: number): Promise<number>;
|
|
137
|
+
/**
|
|
138
|
+
* Claims an upkeep job that is due at `now` until `nextAt`, only if no other runner claimed it
|
|
139
|
+
* first (as a schedule's run is claimed): true for the one runner that does it.
|
|
140
|
+
*/
|
|
141
|
+
claimUpkeep(job: string, now: number, nextAt: number): Promise<boolean>;
|
|
142
|
+
addInbox(conversationId: string, kind: InboxRow["kind"], payload: Record<string, unknown>, db?: Omit<Database, "transaction">): Promise<number>;
|
|
143
|
+
openInbox(conversationId: string): Promise<InboxRow[]>;
|
|
144
|
+
doneInbox(ids: number[]): Promise<void>;
|
|
145
|
+
addTask(conversationId: string, taskId: string, callId: string, tool: string, startedAt: number): Promise<void>;
|
|
146
|
+
removeTask(conversationId: string, taskId: string): Promise<void>;
|
|
147
|
+
tasks(conversationId: string): Promise<Array<{
|
|
148
|
+
taskId: string;
|
|
149
|
+
tool: string;
|
|
150
|
+
startedAt: number;
|
|
151
|
+
}>>;
|
|
152
|
+
/** Conversations with something left to do after a restart. */
|
|
153
|
+
unfinished(): Promise<string[]>;
|
|
154
|
+
addSchedule(s: NewSchedule, db?: Omit<Database, "transaction">): Promise<void>;
|
|
155
|
+
schedules(conversationId: string): Promise<ScheduleRow[]>;
|
|
156
|
+
cancelSchedule(conversationId: string, id: string): Promise<boolean>;
|
|
157
|
+
dueSchedules(now: number, limit?: number): Promise<ScheduleRow[]>;
|
|
158
|
+
/**
|
|
159
|
+
* Moves a due schedule on and records its run, in one transaction, only if no one else did first
|
|
160
|
+
* (the version must still match). A delivered run also gets its inbox row. Returns the inbox
|
|
161
|
+
* row's id, 0 for a run recorded without one, or null when another runner had it.
|
|
162
|
+
*/
|
|
163
|
+
claimRun(s: ScheduleRow, next: {
|
|
164
|
+
nextRunAt: number | null;
|
|
165
|
+
status: ScheduleRow["status"];
|
|
166
|
+
}, run: {
|
|
167
|
+
id: string;
|
|
168
|
+
outcome: string;
|
|
169
|
+
reason?: string;
|
|
170
|
+
ranAt: number;
|
|
171
|
+
}, inbox?: Record<string, unknown>): Promise<number | null>;
|
|
172
|
+
settleRun(id: string, outcome: string, reason?: string): Promise<void>;
|
|
173
|
+
runs(scheduleId: string): Promise<Array<{
|
|
174
|
+
id: string;
|
|
175
|
+
dueAt: number;
|
|
176
|
+
outcome: string;
|
|
177
|
+
reason: string | null;
|
|
178
|
+
}>>;
|
|
179
|
+
nudgeByKey(key: string): Promise<NudgeRow | null>;
|
|
180
|
+
nudge(id: string): Promise<NudgeRow | null>;
|
|
181
|
+
/**
|
|
182
|
+
* Records a nudge with its inbox row (when queued) or its schedule (when it has a time), together.
|
|
183
|
+
* Returns the inbox row's id (0 for none), or null when its idempotency key was already taken:
|
|
184
|
+
* nothing is written then.
|
|
185
|
+
*/
|
|
186
|
+
addNudge(n: NudgeRow, schedule?: NewSchedule): Promise<number | null>;
|
|
187
|
+
/** A nudge waits after all (their WhatsApp window is closed): held, with what its release needs. */
|
|
188
|
+
holdNudge(id: string, body: Record<string, unknown>, reason: string): Promise<void>;
|
|
189
|
+
/** Nudges held for quiet hours or a closed window, for one conversation or every one. */
|
|
190
|
+
heldNudges(conversationId?: string): Promise<NudgeRow[]>;
|
|
191
|
+
/**
|
|
192
|
+
* A held nudge goes on: queued, with its inbox row, in one transaction. Returns the inbox row's
|
|
193
|
+
* id, or null when it was no longer held (another runner took it, or it was settled).
|
|
194
|
+
*/
|
|
195
|
+
releaseNudge(n: NudgeRow): Promise<number | null>;
|
|
196
|
+
settleNudge(id: string, status: string, reason?: string): Promise<void>;
|
|
197
|
+
addNoContact(conversationId: string, id: string, rule: string, until: number | null, kind?: "pause" | "topic"): Promise<void>;
|
|
198
|
+
/** Removes one of the agent's rules; a STOP is not one of them (only START lifts it). */
|
|
199
|
+
removeNoContact(conversationId: string, id: string): Promise<boolean>;
|
|
200
|
+
/**
|
|
201
|
+
* They texted STOP (`stop` is its rule, kept unless one is already in force) or START (null:
|
|
202
|
+
* their STOP is lifted), with the provider's id for the message in the same transaction. False
|
|
203
|
+
* when the id was already taken: nothing is changed then.
|
|
204
|
+
*/
|
|
205
|
+
setStop(conversationId: string, stop: {
|
|
206
|
+
id: string;
|
|
207
|
+
rule: string;
|
|
208
|
+
} | null, inbound?: {
|
|
209
|
+
channel: string;
|
|
210
|
+
messageId: string;
|
|
211
|
+
}): Promise<boolean>;
|
|
212
|
+
/** The rules still in force at `now` (a rule with an `until` in the past has ended). */
|
|
213
|
+
noContact(conversationId: string, now: number): Promise<NoContactRow[]>;
|
|
214
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Flow Framework's own state in the database: who each person is and on which channels, every
|
|
2
|
+
// conversation's history, the inbox of events not yet handled, background tools in flight,
|
|
3
|
+
// scheduled messages and their runs, nudges, and what each person asked not to be messaged about.
|
|
4
|
+
// The author's data never lives here. Every row carries the agent's id, so one database can hold
|
|
5
|
+
// several agents.
|
|
6
|
+
/**
|
|
7
|
+
* The column types that differ between engines. Times are milliseconds since the epoch, past a
|
|
8
|
+
* 32-bit integer: SQLite's INTEGER holds 64 bits, Postgres needs BIGINT (which node-postgres hands
|
|
9
|
+
* back as a string, so every read goes through Number()). Flags stay integers 0/1 on both.
|
|
10
|
+
*/
|
|
11
|
+
const TYPES = {
|
|
12
|
+
sqlite: { int: "INTEGER", serial: "INTEGER PRIMARY KEY AUTOINCREMENT" },
|
|
13
|
+
postgres: { int: "BIGINT", serial: "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" },
|
|
14
|
+
};
|
|
15
|
+
const MIGRATIONS = [
|
|
16
|
+
(t) => `CREATE TABLE conversations (
|
|
17
|
+
agent_id TEXT NOT NULL, id TEXT NOT NULL, person_key TEXT NOT NULL, time_zone TEXT,
|
|
18
|
+
turn_open INTEGER NOT NULL DEFAULT 0, message_count INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
recent_channels TEXT NOT NULL DEFAULT '[]', created_at ${t.int} NOT NULL,
|
|
20
|
+
PRIMARY KEY (agent_id, id));
|
|
21
|
+
CREATE UNIQUE INDEX conversations_person ON conversations (agent_id, person_key);
|
|
22
|
+
CREATE TABLE identities (
|
|
23
|
+
agent_id TEXT NOT NULL, channel TEXT NOT NULL, address TEXT NOT NULL, conversation_id TEXT NOT NULL,
|
|
24
|
+
verified_by TEXT NOT NULL, last_message_at ${t.int},
|
|
25
|
+
PRIMARY KEY (agent_id, channel, address));
|
|
26
|
+
CREATE TABLE messages (
|
|
27
|
+
agent_id TEXT NOT NULL, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, entry TEXT NOT NULL, at ${t.int} NOT NULL,
|
|
28
|
+
PRIMARY KEY (agent_id, conversation_id, seq));
|
|
29
|
+
CREATE TABLE inbox (
|
|
30
|
+
id ${t.serial}, agent_id TEXT NOT NULL, conversation_id TEXT NOT NULL,
|
|
31
|
+
kind TEXT NOT NULL, payload TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, at ${t.int} NOT NULL);
|
|
32
|
+
CREATE INDEX inbox_open ON inbox (agent_id, conversation_id, done);
|
|
33
|
+
CREATE TABLE background_tools (
|
|
34
|
+
agent_id TEXT NOT NULL, conversation_id TEXT NOT NULL, task_id TEXT NOT NULL, call_id TEXT NOT NULL,
|
|
35
|
+
tool TEXT NOT NULL, started_at ${t.int} NOT NULL,
|
|
36
|
+
PRIMARY KEY (agent_id, conversation_id, task_id));
|
|
37
|
+
CREATE TABLE schedules (
|
|
38
|
+
agent_id TEXT NOT NULL, id TEXT NOT NULL, conversation_id TEXT NOT NULL, channel TEXT NOT NULL,
|
|
39
|
+
instruction TEXT NOT NULL, fixed_text INTEGER NOT NULL DEFAULT 0, mode TEXT NOT NULL, rule TEXT NOT NULL,
|
|
40
|
+
time_zone TEXT NOT NULL, next_run_at ${t.int}, run_count INTEGER NOT NULL DEFAULT 0, max_runs INTEGER,
|
|
41
|
+
ends_on TEXT, status TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL,
|
|
42
|
+
created_at ${t.int} NOT NULL,
|
|
43
|
+
PRIMARY KEY (agent_id, id));
|
|
44
|
+
CREATE INDEX schedules_due ON schedules (agent_id, status, next_run_at);
|
|
45
|
+
CREATE TABLE schedule_runs (
|
|
46
|
+
agent_id TEXT NOT NULL, id TEXT NOT NULL, schedule_id TEXT NOT NULL, due_at ${t.int} NOT NULL,
|
|
47
|
+
ran_at ${t.int} NOT NULL, outcome TEXT NOT NULL, reason TEXT,
|
|
48
|
+
PRIMARY KEY (agent_id, id));
|
|
49
|
+
CREATE TABLE nudges (
|
|
50
|
+
agent_id TEXT NOT NULL, id TEXT NOT NULL, conversation_id TEXT NOT NULL, channel TEXT NOT NULL,
|
|
51
|
+
body TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, idempotency_key TEXT, created_at ${t.int} NOT NULL,
|
|
52
|
+
PRIMARY KEY (agent_id, id));
|
|
53
|
+
CREATE UNIQUE INDEX nudges_key ON nudges (agent_id, idempotency_key);
|
|
54
|
+
CREATE TABLE no_contact (
|
|
55
|
+
agent_id TEXT NOT NULL, id TEXT NOT NULL, conversation_id TEXT NOT NULL, rule TEXT NOT NULL,
|
|
56
|
+
until ${t.int}, created_at ${t.int} NOT NULL,
|
|
57
|
+
PRIMARY KEY (agent_id, id));`,
|
|
58
|
+
// Where an identity's latest message came from: the channel's provider, or the playground.
|
|
59
|
+
() => `ALTER TABLE identities ADD COLUMN via TEXT NOT NULL DEFAULT 'provider'`,
|
|
60
|
+
// A rule from a keyword (STOP): a hard stop, checked without the model, lifted only by START.
|
|
61
|
+
() => `ALTER TABLE no_contact ADD COLUMN hard INTEGER NOT NULL DEFAULT 0`,
|
|
62
|
+
// Phone keys are E.164 now: "phone:15550100100" (as WhatsApp gives it) and "phone:0015550100100"
|
|
63
|
+
// become "phone:+15550100100", so the same number on SMS joins them. Where a number already has
|
|
64
|
+
// two conversations (under any two of those forms), all are left as they are: merging two
|
|
65
|
+
// histories is the owner's call. Each look-up is by equality, so it uses the person_key index.
|
|
66
|
+
() => `UPDATE conversations SET person_key = 'phone:+' || substr(person_key, CASE WHEN person_key LIKE 'phone:00%' THEN 9 ELSE 7 END)
|
|
67
|
+
WHERE person_key LIKE 'phone:%' AND person_key NOT LIKE 'phone:+%'
|
|
68
|
+
AND NOT EXISTS (SELECT 1 FROM conversations other WHERE other.agent_id = conversations.agent_id
|
|
69
|
+
AND other.person_key = 'phone:+' || substr(conversations.person_key, CASE WHEN conversations.person_key LIKE 'phone:00%' THEN 9 ELSE 7 END))
|
|
70
|
+
AND NOT EXISTS (SELECT 1 FROM conversations other WHERE other.agent_id = conversations.agent_id
|
|
71
|
+
AND other.person_key = CASE WHEN conversations.person_key LIKE 'phone:00%' THEN 'phone:' || substr(conversations.person_key, 9) ELSE 'phone:00' || substr(conversations.person_key, 7) END)`,
|
|
72
|
+
// A provider's message ids, so a webhook's retry is taken once (per conversation: some providers
|
|
73
|
+
// number messages per chat); and each conversation's turn count, for tools' idempotency keys.
|
|
74
|
+
(t) => `CREATE TABLE inbound (
|
|
75
|
+
agent_id TEXT NOT NULL, conversation_id TEXT NOT NULL, channel TEXT NOT NULL, message_id TEXT NOT NULL, at ${t.int} NOT NULL,
|
|
76
|
+
PRIMARY KEY (agent_id, conversation_id, channel, message_id));
|
|
77
|
+
ALTER TABLE conversations ADD COLUMN turns INTEGER NOT NULL DEFAULT 0`,
|
|
78
|
+
// Provider ids are deleted once old; upkeep jobs (that pruning) are claimed, so one runner does each.
|
|
79
|
+
(t) => `CREATE INDEX inbound_at ON inbound (agent_id, at);
|
|
80
|
+
CREATE TABLE upkeep (agent_id TEXT NOT NULL, job TEXT NOT NULL, next_at ${t.int} NOT NULL, PRIMARY KEY (agent_id, job))`,
|
|
81
|
+
// A person's contact settings: their own quiet hours (null: the agent's), where their time zone
|
|
82
|
+
// came from, and each no-contact rule's kind (stop, pause: checked with no model; topic: judged).
|
|
83
|
+
() => `ALTER TABLE conversations ADD COLUMN quiet_hours TEXT;
|
|
84
|
+
ALTER TABLE conversations ADD COLUMN time_zone_source TEXT;
|
|
85
|
+
ALTER TABLE no_contact ADD COLUMN kind TEXT NOT NULL DEFAULT 'topic';
|
|
86
|
+
UPDATE no_contact SET kind = 'stop' WHERE hard = 1;
|
|
87
|
+
CREATE INDEX nudges_status ON nudges (agent_id, status)`,
|
|
88
|
+
];
|
|
89
|
+
const json = (value) => JSON.stringify(value);
|
|
90
|
+
const parse = (text, fallback) => {
|
|
91
|
+
try {
|
|
92
|
+
return typeof text === "string" ? JSON.parse(text) : fallback;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return fallback;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const num = (v) => (v === null || v === undefined ? null : Number(v));
|
|
99
|
+
export class Store {
|
|
100
|
+
db;
|
|
101
|
+
agentId;
|
|
102
|
+
ready = null;
|
|
103
|
+
constructor(db, agentId) {
|
|
104
|
+
this.db = db;
|
|
105
|
+
this.agentId = agentId;
|
|
106
|
+
}
|
|
107
|
+
/** The state survives a restart, so an agent must resume() before its first message. */
|
|
108
|
+
get durable() {
|
|
109
|
+
return this.db.durable;
|
|
110
|
+
}
|
|
111
|
+
/** Creates or upgrades Flow Framework's tables; safe to call any number of times. */
|
|
112
|
+
migrate() {
|
|
113
|
+
this.ready ??= (async () => {
|
|
114
|
+
const types = TYPES[this.db.dialect ?? "sqlite"];
|
|
115
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS parley_migrations (version INTEGER PRIMARY KEY, applied_at ${types.int} NOT NULL)`);
|
|
116
|
+
const done = new Set((await this.db.query("SELECT version FROM parley_migrations")).map((r) => Number(r.version)));
|
|
117
|
+
for (const [i, migration] of MIGRATIONS.entries()) {
|
|
118
|
+
if (done.has(i + 1))
|
|
119
|
+
continue;
|
|
120
|
+
await this.db.transaction(async (tx) => {
|
|
121
|
+
for (const statement of migration(types).split(";").map((s) => s.trim()).filter(Boolean))
|
|
122
|
+
await tx.run(statement);
|
|
123
|
+
await tx.run("INSERT INTO parley_migrations (version, applied_at) VALUES (?, ?)", [i + 1, Date.now()]);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
return this.ready;
|
|
128
|
+
}
|
|
129
|
+
// People and their channels.
|
|
130
|
+
async identity(channel, address) {
|
|
131
|
+
const [row] = await this.db.query("SELECT * FROM identities WHERE agent_id = ? AND channel = ? AND address = ?", [this.agentId, channel, address]);
|
|
132
|
+
return row ? identityOf(row) : null;
|
|
133
|
+
}
|
|
134
|
+
async identities(conversationId) {
|
|
135
|
+
const rows = await this.db.query("SELECT * FROM identities WHERE agent_id = ? AND conversation_id = ?", [this.agentId, conversationId]);
|
|
136
|
+
return rows.map(identityOf);
|
|
137
|
+
}
|
|
138
|
+
/** The conversation of one person, by their person key: `key:<resolve's value>`, `phone:<E.164>` or `<channel>:<address>`. */
|
|
139
|
+
async conversationByKey(personKey) {
|
|
140
|
+
const [row] = await this.db.query("SELECT * FROM conversations WHERE agent_id = ? AND person_key = ?", [this.agentId, personKey]);
|
|
141
|
+
return row ? conversationOf(row) : null;
|
|
142
|
+
}
|
|
143
|
+
async conversation(id) {
|
|
144
|
+
const [row] = await this.db.query("SELECT * FROM conversations WHERE agent_id = ? AND id = ?", [this.agentId, id]);
|
|
145
|
+
return row ? conversationOf(row) : null;
|
|
146
|
+
}
|
|
147
|
+
async allConversations() {
|
|
148
|
+
return (await this.db.query("SELECT * FROM conversations WHERE agent_id = ?", [this.agentId])).map(conversationOf);
|
|
149
|
+
}
|
|
150
|
+
async createConversation(id, personKey) {
|
|
151
|
+
await this.db.run("INSERT INTO conversations (agent_id, id, person_key, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", [this.agentId, id, personKey, Date.now()]);
|
|
152
|
+
}
|
|
153
|
+
async addIdentity(conversationId, channel, address, verifiedBy) {
|
|
154
|
+
await this.db.run("INSERT INTO identities (agent_id, channel, address, conversation_id, verified_by) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", [this.agentId, channel, address, conversationId, verifiedBy]);
|
|
155
|
+
}
|
|
156
|
+
async touchIdentity(channel, address, at) {
|
|
157
|
+
await this.db.run("UPDATE identities SET last_message_at = ? WHERE agent_id = ? AND channel = ? AND address = ?", [at, this.agentId, channel, address]);
|
|
158
|
+
}
|
|
159
|
+
async setIdentityVia(channel, address, via) {
|
|
160
|
+
await this.db.run("UPDATE identities SET via = ? WHERE agent_id = ? AND channel = ? AND address = ?", [via, this.agentId, channel, address]);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Deletes everything kept about one person: their conversation, identities, history, inbox,
|
|
164
|
+
* background tools, schedules and their runs, nudges and no-contact rules.
|
|
165
|
+
*/
|
|
166
|
+
async forgetPerson(conversationId) {
|
|
167
|
+
const scope = [this.agentId, conversationId];
|
|
168
|
+
await this.db.transaction(async (tx) => {
|
|
169
|
+
await tx.run("DELETE FROM schedule_runs WHERE agent_id = ? AND schedule_id IN (SELECT id FROM schedules WHERE agent_id = ? AND conversation_id = ?)", [this.agentId, ...scope]);
|
|
170
|
+
for (const table of ["schedules", "nudges", "no_contact", "background_tools", "inbox", "messages", "inbound", "identities"]) {
|
|
171
|
+
await tx.run(`DELETE FROM ${table} WHERE agent_id = ? AND conversation_id = ?`, scope);
|
|
172
|
+
}
|
|
173
|
+
await tx.run("DELETE FROM conversations WHERE agent_id = ? AND id = ?", scope);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async updateConversation(id, fields) {
|
|
177
|
+
const sets = [];
|
|
178
|
+
const params = [];
|
|
179
|
+
if (fields.turnOpen !== undefined)
|
|
180
|
+
sets.push("turn_open = ?"), params.push(fields.turnOpen ? 1 : 0);
|
|
181
|
+
if (fields.turns !== undefined)
|
|
182
|
+
sets.push("turns = ?"), params.push(fields.turns);
|
|
183
|
+
if (fields.messageCount !== undefined)
|
|
184
|
+
sets.push("message_count = ?"), params.push(fields.messageCount);
|
|
185
|
+
if (fields.recentChannels !== undefined)
|
|
186
|
+
sets.push("recent_channels = ?"), params.push(json(fields.recentChannels));
|
|
187
|
+
if (fields.timeZone !== undefined)
|
|
188
|
+
sets.push("time_zone = ?"), params.push(fields.timeZone);
|
|
189
|
+
if (fields.timeZoneSource !== undefined)
|
|
190
|
+
sets.push("time_zone_source = ?"), params.push(fields.timeZoneSource);
|
|
191
|
+
if (fields.quietHours !== undefined)
|
|
192
|
+
sets.push("quiet_hours = ?"), params.push(fields.quietHours === null ? null : json(fields.quietHours));
|
|
193
|
+
if (!sets.length)
|
|
194
|
+
return;
|
|
195
|
+
await this.db.run(`UPDATE conversations SET ${sets.join(", ")} WHERE agent_id = ? AND id = ?`, [...params, this.agentId, id]);
|
|
196
|
+
}
|
|
197
|
+
// History.
|
|
198
|
+
async history(conversationId) {
|
|
199
|
+
const rows = await this.db.query("SELECT entry FROM messages WHERE agent_id = ? AND conversation_id = ? ORDER BY seq", [this.agentId, conversationId]);
|
|
200
|
+
return rows.map((r) => parse(r.entry, { role: "user", content: "" }));
|
|
201
|
+
}
|
|
202
|
+
/** The history with when each entry was stored, for showing the conversation (the playground). */
|
|
203
|
+
async historyWithTimes(conversationId) {
|
|
204
|
+
const rows = await this.db.query("SELECT entry, at FROM messages WHERE agent_id = ? AND conversation_id = ? ORDER BY seq", [this.agentId, conversationId]);
|
|
205
|
+
return rows.map((r) => ({ entry: parse(r.entry, { role: "user", content: "" }), at: Number(r.at) }));
|
|
206
|
+
}
|
|
207
|
+
/** Appends history entries from `seq` on, and marks inbox rows handled, together. */
|
|
208
|
+
async appendHistory(conversationId, seq, entries, doneInbox = []) {
|
|
209
|
+
await this.db.transaction(async (tx) => {
|
|
210
|
+
for (const [i, entry] of entries.entries()) {
|
|
211
|
+
await tx.run("INSERT INTO messages (agent_id, conversation_id, seq, entry, at) VALUES (?, ?, ?, ?, ?)", [this.agentId, conversationId, seq + i, json(entry), Date.now()]);
|
|
212
|
+
}
|
|
213
|
+
for (const id of doneInbox)
|
|
214
|
+
await tx.run("UPDATE inbox SET done = 1 WHERE agent_id = ? AND id = ?", [this.agentId, id]);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** A provider's message id already taken in this conversation on this channel (a webhook sent again). */
|
|
218
|
+
async inboundTaken(conversationId, channel, messageId) {
|
|
219
|
+
return (await this.db.query("SELECT 1 FROM inbound WHERE agent_id = ? AND conversation_id = ? AND channel = ? AND message_id = ?", [this.agentId, conversationId, channel, messageId])).length > 0;
|
|
220
|
+
}
|
|
221
|
+
/** Records a provider's message id, in the transaction of what its message stores; false when it was already taken. */
|
|
222
|
+
async inbound(conversationId, channel, messageId, db = this.db) {
|
|
223
|
+
return (await db.run("INSERT INTO inbound (agent_id, conversation_id, channel, message_id, at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", [this.agentId, conversationId, channel, messageId, Date.now()])) > 0;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* A person's message into the inbox, with the provider's id for it in the same transaction, so
|
|
227
|
+
* a crash never keeps the id without the message (its retry would be refused). Returns the inbox
|
|
228
|
+
* row's id, or null when the id was already taken: nothing is written then.
|
|
229
|
+
*/
|
|
230
|
+
async addMessage(conversationId, payload, inbound) {
|
|
231
|
+
return this.db.transaction(async (tx) => {
|
|
232
|
+
if (inbound && !(await this.inbound(conversationId, inbound.channel, inbound.messageId, tx)))
|
|
233
|
+
return null;
|
|
234
|
+
return this.addInbox(conversationId, "message", payload, tx);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Deletes up to `limit` provider message ids recorded before `before`; returns how many it deleted. */
|
|
238
|
+
async pruneInbound(before, limit) {
|
|
239
|
+
return this.db.run(`DELETE FROM inbound WHERE agent_id = ? AND (conversation_id, channel, message_id) IN
|
|
240
|
+
(SELECT conversation_id, channel, message_id FROM inbound WHERE agent_id = ? AND at < ? LIMIT ?)`, [this.agentId, this.agentId, before, limit]);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Claims an upkeep job that is due at `now` until `nextAt`, only if no other runner claimed it
|
|
244
|
+
* first (as a schedule's run is claimed): true for the one runner that does it.
|
|
245
|
+
*/
|
|
246
|
+
async claimUpkeep(job, now, nextAt) {
|
|
247
|
+
return this.db.transaction(async (tx) => {
|
|
248
|
+
await tx.run("INSERT INTO upkeep (agent_id, job, next_at) VALUES (?, ?, 0) ON CONFLICT DO NOTHING", [this.agentId, job]);
|
|
249
|
+
return (await tx.run("UPDATE upkeep SET next_at = ? WHERE agent_id = ? AND job = ? AND next_at <= ?", [nextAt, this.agentId, job, now])) > 0;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// The inbox.
|
|
253
|
+
async addInbox(conversationId, kind, payload, db = this.db) {
|
|
254
|
+
const [row] = await db.query("INSERT INTO inbox (agent_id, conversation_id, kind, payload, at) VALUES (?, ?, ?, ?, ?) RETURNING id", [this.agentId, conversationId, kind, json(payload), Date.now()]);
|
|
255
|
+
return Number(row.id);
|
|
256
|
+
}
|
|
257
|
+
async openInbox(conversationId) {
|
|
258
|
+
const rows = await this.db.query("SELECT id, kind, payload FROM inbox WHERE agent_id = ? AND conversation_id = ? AND done = 0 ORDER BY id", [this.agentId, conversationId]);
|
|
259
|
+
return rows.map((r) => ({ id: Number(r.id), kind: r.kind, payload: parse(r.payload, {}) }));
|
|
260
|
+
}
|
|
261
|
+
async doneInbox(ids) {
|
|
262
|
+
for (const id of ids)
|
|
263
|
+
await this.db.run("UPDATE inbox SET done = 1 WHERE agent_id = ? AND id = ?", [this.agentId, id]);
|
|
264
|
+
}
|
|
265
|
+
// Background tools.
|
|
266
|
+
async addTask(conversationId, taskId, callId, tool, startedAt) {
|
|
267
|
+
await this.db.run("INSERT INTO background_tools (agent_id, conversation_id, task_id, call_id, tool, started_at) VALUES (?, ?, ?, ?, ?, ?)", [this.agentId, conversationId, taskId, callId, tool, startedAt]);
|
|
268
|
+
}
|
|
269
|
+
async removeTask(conversationId, taskId) {
|
|
270
|
+
await this.db.run("DELETE FROM background_tools WHERE agent_id = ? AND conversation_id = ? AND task_id = ?", [this.agentId, conversationId, taskId]);
|
|
271
|
+
}
|
|
272
|
+
async tasks(conversationId) {
|
|
273
|
+
const rows = await this.db.query("SELECT task_id, tool, started_at FROM background_tools WHERE agent_id = ? AND conversation_id = ?", [this.agentId, conversationId]);
|
|
274
|
+
return rows.map((r) => ({ taskId: String(r.task_id), tool: String(r.tool), startedAt: Number(r.started_at) }));
|
|
275
|
+
}
|
|
276
|
+
/** Conversations with something left to do after a restart. */
|
|
277
|
+
async unfinished() {
|
|
278
|
+
const rows = await this.db.query(`SELECT id FROM conversations WHERE agent_id = ? AND (turn_open = 1
|
|
279
|
+
OR id IN (SELECT conversation_id FROM inbox WHERE agent_id = ? AND done = 0)
|
|
280
|
+
OR id IN (SELECT conversation_id FROM background_tools WHERE agent_id = ?))`, [this.agentId, this.agentId, this.agentId]);
|
|
281
|
+
return rows.map((r) => String(r.id));
|
|
282
|
+
}
|
|
283
|
+
// Scheduled messages.
|
|
284
|
+
async addSchedule(s, db = this.db) {
|
|
285
|
+
await db.run(`INSERT INTO schedules (agent_id, id, conversation_id, channel, instruction, fixed_text, mode, rule, time_zone, next_run_at, max_runs, ends_on, status, origin, created_at)
|
|
286
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, [this.agentId, s.id, s.conversationId, s.channel, s.instruction, s.fixedText ? 1 : 0, s.mode, json(s.rule), s.timeZone, s.nextRunAt, s.maxRuns, s.endsOn, s.origin, Date.now()]);
|
|
287
|
+
}
|
|
288
|
+
async schedules(conversationId) {
|
|
289
|
+
const rows = await this.db.query("SELECT * FROM schedules WHERE agent_id = ? AND conversation_id = ? AND status = 'active' ORDER BY next_run_at", [this.agentId, conversationId]);
|
|
290
|
+
return rows.map(scheduleOf);
|
|
291
|
+
}
|
|
292
|
+
async cancelSchedule(conversationId, id) {
|
|
293
|
+
const changed = await this.db.run("UPDATE schedules SET status = 'cancelled', version = version + 1 WHERE agent_id = ? AND conversation_id = ? AND id = ? AND status = 'active'", [this.agentId, conversationId, id]);
|
|
294
|
+
return changed > 0;
|
|
295
|
+
}
|
|
296
|
+
async dueSchedules(now, limit = 50) {
|
|
297
|
+
const rows = await this.db.query("SELECT * FROM schedules WHERE agent_id = ? AND status = 'active' AND next_run_at <= ? ORDER BY next_run_at LIMIT ?", [this.agentId, now, limit]);
|
|
298
|
+
return rows.map(scheduleOf);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Moves a due schedule on and records its run, in one transaction, only if no one else did first
|
|
302
|
+
* (the version must still match). A delivered run also gets its inbox row. Returns the inbox
|
|
303
|
+
* row's id, 0 for a run recorded without one, or null when another runner had it.
|
|
304
|
+
*/
|
|
305
|
+
async claimRun(s, next, run, inbox) {
|
|
306
|
+
return this.db.transaction(async (tx) => {
|
|
307
|
+
const changed = await tx.run("UPDATE schedules SET next_run_at = ?, status = ?, run_count = run_count + 1, version = version + 1 WHERE agent_id = ? AND id = ? AND version = ?", [next.nextRunAt, next.status, this.agentId, s.id, s.version]);
|
|
308
|
+
if (!changed)
|
|
309
|
+
return null;
|
|
310
|
+
await tx.run("INSERT INTO schedule_runs (agent_id, id, schedule_id, due_at, ran_at, outcome, reason) VALUES (?, ?, ?, ?, ?, ?, ?)", [this.agentId, run.id, s.id, s.nextRunAt, run.ranAt, run.outcome, run.reason ?? null]);
|
|
311
|
+
return inbox ? this.addInbox(s.conversationId, "schedule", inbox, tx) : 0;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async settleRun(id, outcome, reason) {
|
|
315
|
+
await this.db.run("UPDATE schedule_runs SET outcome = ?, reason = ? WHERE agent_id = ? AND id = ?", [outcome, reason ?? null, this.agentId, id]);
|
|
316
|
+
}
|
|
317
|
+
async runs(scheduleId) {
|
|
318
|
+
const rows = await this.db.query("SELECT * FROM schedule_runs WHERE agent_id = ? AND schedule_id = ? ORDER BY due_at", [this.agentId, scheduleId]);
|
|
319
|
+
return rows.map((r) => ({ id: String(r.id), dueAt: Number(r.due_at), outcome: String(r.outcome), reason: r.reason ?? null }));
|
|
320
|
+
}
|
|
321
|
+
// Nudges.
|
|
322
|
+
async nudgeByKey(key) {
|
|
323
|
+
const [row] = await this.db.query("SELECT * FROM nudges WHERE agent_id = ? AND idempotency_key = ?", [this.agentId, key]);
|
|
324
|
+
return row ? nudgeOf(row) : null;
|
|
325
|
+
}
|
|
326
|
+
async nudge(id) {
|
|
327
|
+
const [row] = await this.db.query("SELECT * FROM nudges WHERE agent_id = ? AND id = ?", [this.agentId, id]);
|
|
328
|
+
return row ? nudgeOf(row) : null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Records a nudge with its inbox row (when queued) or its schedule (when it has a time), together.
|
|
332
|
+
* Returns the inbox row's id (0 for none), or null when its idempotency key was already taken:
|
|
333
|
+
* nothing is written then.
|
|
334
|
+
*/
|
|
335
|
+
async addNudge(n, schedule) {
|
|
336
|
+
return this.db.transaction(async (tx) => {
|
|
337
|
+
const added = await tx.run("INSERT INTO nudges (agent_id, id, conversation_id, channel, body, status, reason, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", [this.agentId, n.id, n.conversationId, n.channel, json(n.body), n.status, n.reason, n.idempotencyKey, Date.now()]);
|
|
338
|
+
if (!added)
|
|
339
|
+
return null;
|
|
340
|
+
if (schedule)
|
|
341
|
+
await this.addSchedule(schedule, tx);
|
|
342
|
+
return n.status === "queued" ? this.addInbox(n.conversationId, "nudge", { nudge: n.id, channel: n.channel, ...n.body }, tx) : 0;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/** A nudge waits after all (their WhatsApp window is closed): held, with what its release needs. */
|
|
346
|
+
async holdNudge(id, body, reason) {
|
|
347
|
+
await this.db.run("UPDATE nudges SET status = 'held', reason = ?, body = ? WHERE agent_id = ? AND id = ?", [reason, json(body), this.agentId, id]);
|
|
348
|
+
}
|
|
349
|
+
/** Nudges held for quiet hours or a closed window, for one conversation or every one. */
|
|
350
|
+
async heldNudges(conversationId) {
|
|
351
|
+
const rows = conversationId
|
|
352
|
+
? await this.db.query("SELECT * FROM nudges WHERE agent_id = ? AND status = 'held' AND conversation_id = ? ORDER BY created_at", [this.agentId, conversationId])
|
|
353
|
+
: await this.db.query("SELECT * FROM nudges WHERE agent_id = ? AND status = 'held' ORDER BY created_at", [this.agentId]);
|
|
354
|
+
return rows.map(nudgeOf);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A held nudge goes on: queued, with its inbox row, in one transaction. Returns the inbox row's
|
|
358
|
+
* id, or null when it was no longer held (another runner took it, or it was settled).
|
|
359
|
+
*/
|
|
360
|
+
async releaseNudge(n) {
|
|
361
|
+
return this.db.transaction(async (tx) => {
|
|
362
|
+
const changed = await tx.run("UPDATE nudges SET status = 'queued', reason = NULL WHERE agent_id = ? AND id = ? AND status = 'held'", [this.agentId, n.id]);
|
|
363
|
+
if (!changed)
|
|
364
|
+
return null;
|
|
365
|
+
return this.addInbox(n.conversationId, "nudge", { nudge: n.id, channel: n.channel, ...n.body }, tx);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async settleNudge(id, status, reason) {
|
|
369
|
+
await this.db.run("UPDATE nudges SET status = ?, reason = ? WHERE agent_id = ? AND id = ?", [status, reason ?? null, this.agentId, id]);
|
|
370
|
+
}
|
|
371
|
+
// What a person asked not to be messaged about.
|
|
372
|
+
async addNoContact(conversationId, id, rule, until, kind = "topic") {
|
|
373
|
+
await this.db.run("INSERT INTO no_contact (agent_id, id, conversation_id, rule, until, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [this.agentId, id, conversationId, rule, until, kind, Date.now()]);
|
|
374
|
+
}
|
|
375
|
+
/** Removes one of the agent's rules; a STOP is not one of them (only START lifts it). */
|
|
376
|
+
async removeNoContact(conversationId, id) {
|
|
377
|
+
return (await this.db.run("DELETE FROM no_contact WHERE agent_id = ? AND conversation_id = ? AND id = ? AND hard = 0", [this.agentId, conversationId, id])) > 0;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* They texted STOP (`stop` is its rule, kept unless one is already in force) or START (null:
|
|
381
|
+
* their STOP is lifted), with the provider's id for the message in the same transaction. False
|
|
382
|
+
* when the id was already taken: nothing is changed then.
|
|
383
|
+
*/
|
|
384
|
+
async setStop(conversationId, stop, inbound) {
|
|
385
|
+
return this.db.transaction(async (tx) => {
|
|
386
|
+
if (inbound && !(await this.inbound(conversationId, inbound.channel, inbound.messageId, tx)))
|
|
387
|
+
return false;
|
|
388
|
+
if (!stop)
|
|
389
|
+
await tx.run("DELETE FROM no_contact WHERE agent_id = ? AND conversation_id = ? AND hard = 1", [this.agentId, conversationId]);
|
|
390
|
+
else if (!(await tx.query("SELECT 1 FROM no_contact WHERE agent_id = ? AND conversation_id = ? AND hard = 1", [this.agentId, conversationId])).length) {
|
|
391
|
+
await tx.run("INSERT INTO no_contact (agent_id, id, conversation_id, rule, until, hard, kind, created_at) VALUES (?, ?, ?, ?, NULL, 1, 'stop', ?)", [this.agentId, stop.id, conversationId, stop.rule, Date.now()]);
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** The rules still in force at `now` (a rule with an `until` in the past has ended). */
|
|
397
|
+
async noContact(conversationId, now) {
|
|
398
|
+
const rows = await this.db.query("SELECT id, rule, until, hard, kind FROM no_contact WHERE agent_id = ? AND conversation_id = ? AND (until IS NULL OR until > ?) ORDER BY created_at", [this.agentId, conversationId, now]);
|
|
399
|
+
return rows.map((r) => ({ id: String(r.id), rule: String(r.rule), until: num(r.until), hard: Number(r.hard) === 1, kind: (Number(r.hard) === 1 ? "stop" : r.kind === "pause" ? "pause" : "topic") }));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function conversationOf(r) {
|
|
403
|
+
return {
|
|
404
|
+
id: String(r.id),
|
|
405
|
+
personKey: String(r.person_key),
|
|
406
|
+
timeZone: r.time_zone ?? null,
|
|
407
|
+
timeZoneSource: r.time_zone_source ?? null,
|
|
408
|
+
quietHours: r.quiet_hours == null ? null : parse(r.quiet_hours, null),
|
|
409
|
+
turnOpen: Number(r.turn_open) === 1,
|
|
410
|
+
messageCount: Number(r.message_count),
|
|
411
|
+
recentChannels: parse(r.recent_channels, []),
|
|
412
|
+
turns: Number(r.turns ?? 0),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function identityOf(r) {
|
|
416
|
+
return { channel: String(r.channel), address: String(r.address), conversationId: String(r.conversation_id), verifiedBy: String(r.verified_by), lastMessageAt: num(r.last_message_at), via: r.via === "playground" ? "playground" : "provider" };
|
|
417
|
+
}
|
|
418
|
+
function scheduleOf(r) {
|
|
419
|
+
return {
|
|
420
|
+
id: String(r.id),
|
|
421
|
+
conversationId: String(r.conversation_id),
|
|
422
|
+
channel: String(r.channel),
|
|
423
|
+
instruction: String(r.instruction),
|
|
424
|
+
fixedText: Number(r.fixed_text) === 1,
|
|
425
|
+
mode: r.mode,
|
|
426
|
+
rule: parse(r.rule, {}),
|
|
427
|
+
timeZone: String(r.time_zone),
|
|
428
|
+
nextRunAt: num(r.next_run_at),
|
|
429
|
+
runCount: Number(r.run_count),
|
|
430
|
+
maxRuns: num(r.max_runs),
|
|
431
|
+
endsOn: r.ends_on ?? null,
|
|
432
|
+
status: r.status,
|
|
433
|
+
version: Number(r.version),
|
|
434
|
+
origin: r.origin,
|
|
435
|
+
createdAt: Number(r.created_at),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function nudgeOf(r) {
|
|
439
|
+
return {
|
|
440
|
+
id: String(r.id),
|
|
441
|
+
conversationId: String(r.conversation_id),
|
|
442
|
+
channel: String(r.channel),
|
|
443
|
+
body: parse(r.body, {}),
|
|
444
|
+
status: String(r.status),
|
|
445
|
+
reason: r.reason ?? null,
|
|
446
|
+
idempotencyKey: r.idempotency_key ?? null,
|
|
447
|
+
};
|
|
448
|
+
}
|