@indigoai-us/hq-cli 5.135.0 → 5.137.0
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 +11 -0
- package/dist/command-catalog.generated.d.ts +47 -0
- package/dist/command-catalog.generated.js +62 -0
- package/dist/commands/bot-jobs.d.ts +127 -0
- package/dist/commands/bot-jobs.js +445 -0
- package/dist/commands/bot.js +3 -0
- package/dist/commands/channels.d.ts +9 -1
- package/dist/commands/channels.js +56 -1
- package/dist/commands/dm.d.ts +6 -0
- package/dist/commands/dm.js +1 -1
- package/dist/lib/bot/api.d.ts +66 -0
- package/dist/lib/bot/api.js +38 -0
- package/dist/lib/bot/index.d.ts +3 -0
- package/dist/lib/bot/index.js +3 -0
- package/dist/lib/bot/inflight.d.ts +42 -0
- package/dist/lib/bot/job-completions.d.ts +91 -0
- package/dist/lib/bot/job-completions.js +132 -0
- package/dist/lib/bot/job-run.d.ts +109 -0
- package/dist/lib/bot/job-run.js +246 -0
- package/dist/lib/bot/paths.d.ts +9 -1
- package/dist/lib/bot/paths.js +14 -0
- package/dist/lib/bot/prompt.d.ts +12 -0
- package/dist/lib/bot/prompt.js +31 -0
- package/dist/lib/bot/room-policy.d.ts +18 -1
- package/dist/lib/bot/room-policy.js +20 -2
- package/dist/lib/bot/run.d.ts +23 -1
- package/dist/lib/bot/run.js +404 -7
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled job runs: the third kind of inbox item a bot can be handed.
|
|
3
|
+
*
|
|
4
|
+
* The cloud does all the scheduling. hq-pro-agents fires a job on an
|
|
5
|
+
* EventBridge schedule and writes a DURABLE inbox item for the agent
|
|
6
|
+
* (`channel: "job"`) carrying the job's own prompt. Nothing is scheduled on
|
|
7
|
+
* this computer — there is no crontab, no launchd timer, no local clock.
|
|
8
|
+
*
|
|
9
|
+
* Two rules shape everything here (owner decisions, 2026-09-15):
|
|
10
|
+
*
|
|
11
|
+
* - CATCH-UP, never skip. A run whose `staleAfter` has passed is still run,
|
|
12
|
+
* once, and the bot says in its report that it is a catch-up and names the
|
|
13
|
+
* time the run was originally due. A Mac that was asleep overnight wakes up
|
|
14
|
+
* and does the morning job late rather than silently dropping it.
|
|
15
|
+
* - ONE catch-up per job per wake. If several runs of the SAME job are pending
|
|
16
|
+
* (a long sleep over many fires), only the newest runs; the older ones are
|
|
17
|
+
* reported to the cloud as `skipped-precondition` with a plain disposition,
|
|
18
|
+
* so the job's in-progress flag is released and the history is honest.
|
|
19
|
+
*
|
|
20
|
+
* Everything in this file is pure so the rules are unit-testable.
|
|
21
|
+
*/
|
|
22
|
+
export function isJobItem(item) {
|
|
23
|
+
return item.channel === "job";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The job fields of an inbox item, or null when it is not a usable job run.
|
|
27
|
+
* A job item with no jobId or no prompt is nothing the bot can run; the caller
|
|
28
|
+
* acks it rather than looping on it forever.
|
|
29
|
+
*/
|
|
30
|
+
export function readJobRun(item) {
|
|
31
|
+
if (!isJobItem(item))
|
|
32
|
+
return null;
|
|
33
|
+
const jobId = typeof item.jobId === "string" ? item.jobId.trim() : "";
|
|
34
|
+
const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
|
|
35
|
+
if (!jobId || !prompt)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
jobId,
|
|
39
|
+
prompt,
|
|
40
|
+
...(item.context !== undefined && item.context !== null ? { context: item.context } : {}),
|
|
41
|
+
...(typeof item.script === "string" && item.script.trim() ? { script: item.script } : {}),
|
|
42
|
+
...(typeof item.firedAt === "string" && item.firedAt.trim() ? { firedAt: item.firedAt.trim() } : {}),
|
|
43
|
+
...(typeof item.staleAfter === "string" && item.staleAfter.trim() ? { staleAfter: item.staleAfter.trim() } : {}),
|
|
44
|
+
...(typeof item.requestId === "string" && item.requestId.trim() ? { requestId: item.requestId.trim() } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The run's extra context as text for the turn, or null when there is none. */
|
|
48
|
+
export function jobContextText(context) {
|
|
49
|
+
if (context === undefined || context === null)
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof context === "string")
|
|
52
|
+
return context.trim() || null;
|
|
53
|
+
try {
|
|
54
|
+
const keys = Object.keys(context);
|
|
55
|
+
if (keys.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
return JSON.stringify(context, null, 2);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The zone this computer is in — where the owner scheduled the job. */
|
|
64
|
+
export function machineTimeZone() {
|
|
65
|
+
try {
|
|
66
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "UTC";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A readable instant for a person: "2026-09-15 09:00 EDT".
|
|
74
|
+
*
|
|
75
|
+
* Rendered in the OWNER's zone, not UTC. Schedules are created in the machine's
|
|
76
|
+
* zone ("every weekday at 9am" means 9am where the owner lives), so reporting
|
|
77
|
+
* the same run as "due 13:00 UTC" reads as the wrong time to the only person
|
|
78
|
+
* who sees it. The format is fixed (en-CA + explicit parts) rather than
|
|
79
|
+
* locale-default, so the bot reads the same on every machine and the output is
|
|
80
|
+
* testable.
|
|
81
|
+
*/
|
|
82
|
+
export function formatJobTime(iso, timeZone = machineTimeZone()) {
|
|
83
|
+
if (!iso)
|
|
84
|
+
return "an unknown time";
|
|
85
|
+
const d = new Date(iso);
|
|
86
|
+
if (Number.isNaN(d.getTime()))
|
|
87
|
+
return iso;
|
|
88
|
+
try {
|
|
89
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
90
|
+
timeZone,
|
|
91
|
+
year: "numeric",
|
|
92
|
+
month: "2-digit",
|
|
93
|
+
day: "2-digit",
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
hour12: false,
|
|
97
|
+
timeZoneName: "short",
|
|
98
|
+
}).formatToParts(d);
|
|
99
|
+
const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
100
|
+
const [year, month, day, hour, minute, zone] = [
|
|
101
|
+
get("year"),
|
|
102
|
+
get("month"),
|
|
103
|
+
get("day"),
|
|
104
|
+
get("hour"),
|
|
105
|
+
get("minute"),
|
|
106
|
+
get("timeZoneName"),
|
|
107
|
+
];
|
|
108
|
+
if (!year || !month || !day || !hour || !minute)
|
|
109
|
+
throw new Error("incomplete");
|
|
110
|
+
// Intl renders midnight as "24" in some hourCycles; normalise to 00.
|
|
111
|
+
const h = hour === "24" ? "00" : hour;
|
|
112
|
+
return `${year}-${month}-${day} ${h}:${minute}${zone ? ` ${zone}` : ""}`;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** True when the delivery deadline has passed: this run is late, not skipped. */
|
|
119
|
+
export function isCatchUpRun(job, now) {
|
|
120
|
+
if (!job.staleAfter)
|
|
121
|
+
return false;
|
|
122
|
+
const deadline = new Date(job.staleAfter);
|
|
123
|
+
if (Number.isNaN(deadline.getTime()))
|
|
124
|
+
return false;
|
|
125
|
+
return now.getTime() > deadline.getTime();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The user turn for a fired job. It says plainly that this is a scheduled run
|
|
129
|
+
* so the model does not answer as if a person had just spoken, and (for a late
|
|
130
|
+
* run) that it is a catch-up, naming the time the run was originally due.
|
|
131
|
+
*/
|
|
132
|
+
export function buildJobTurnPrompt(job, opts) {
|
|
133
|
+
const catchUp = isCatchUpRun(job, opts.now);
|
|
134
|
+
const due = formatJobTime(job.firedAt, opts.timeZone);
|
|
135
|
+
const lines = [];
|
|
136
|
+
// The due time is stated EXACTLY once: in the late sentence for a catch-up,
|
|
137
|
+
// in the opening sentence otherwise. Saying it twice reads as two times.
|
|
138
|
+
lines.push(`[Scheduled job · ${job.jobId}]`);
|
|
139
|
+
lines.push(`This is a scheduled run that fired on its own${catchUp ? "" : `, due at ${due}`}. Nobody just sent you a ` +
|
|
140
|
+
"message and nobody is waiting in a chat. Do the work described below, then finish with a short " +
|
|
141
|
+
"plain-language report of what you did and what you found — that report is what your owner reads.");
|
|
142
|
+
if (catchUp) {
|
|
143
|
+
lines.push(`This run is late: it was due at ${due} and is only running now because this computer was asleep ` +
|
|
144
|
+
"or switched off. Do it as a catch-up, skip anything that no longer makes sense at this hour, " +
|
|
145
|
+
"and say in your report that it ran late.");
|
|
146
|
+
}
|
|
147
|
+
const body = [lines.join("\n"), job.prompt];
|
|
148
|
+
const context = jobContextText(job.context);
|
|
149
|
+
if (context)
|
|
150
|
+
body.push(`Extra context for this run:\n${context}`);
|
|
151
|
+
return body.join("\n\n");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The line the bot's DM report starts with, so the owner sees at a glance that
|
|
155
|
+
* this is a scheduled run and not a reply to something they said.
|
|
156
|
+
*/
|
|
157
|
+
export function jobReportHeader(job, catchUp, timeZone) {
|
|
158
|
+
const base = `⏰ Scheduled job \`${job.jobId}\``;
|
|
159
|
+
return catchUp
|
|
160
|
+
? `${base} — catch-up for the run due ${formatJobTime(job.firedAt, timeZone)}\n\n`
|
|
161
|
+
: `${base}\n\n`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Sort key for "which pending run of this job is the newest".
|
|
165
|
+
*
|
|
166
|
+
* A run with NO timestamp must sort LOWEST, never highest. Falling back to the
|
|
167
|
+
* messageId was a real bug: an id like `jobrun-…` sorts above any ISO timestamp
|
|
168
|
+
* (letters beat digits), so a run missing `firedAt` would win as "newest" and
|
|
169
|
+
* supersede a genuinely newer run — the newer work would be skipped and the
|
|
170
|
+
* older one would run.
|
|
171
|
+
*/
|
|
172
|
+
export function jobRunOrderKey(item) {
|
|
173
|
+
const fired = typeof item.firedAt === "string" ? item.firedAt.trim() : "";
|
|
174
|
+
if (fired)
|
|
175
|
+
return fired;
|
|
176
|
+
const received = typeof item.receivedAt === "string" ? item.receivedAt.trim() : "";
|
|
177
|
+
if (received)
|
|
178
|
+
return received;
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
/** The disposition reported for an older pending run of the same job. */
|
|
182
|
+
export const SUPERSEDED_DISPOSITION = "a newer pending run of this job ran instead (the computer was asleep through several fires)";
|
|
183
|
+
/**
|
|
184
|
+
* Decide which pending job runs to actually run. `handled` maps jobId to the
|
|
185
|
+
* newest run key this process has already picked up; it is UPDATED in place so
|
|
186
|
+
* a later poll cannot resurrect an older run of a job whose newer run already
|
|
187
|
+
* finished and was acked.
|
|
188
|
+
*
|
|
189
|
+
* Returns the message ids that must NOT be run — they are reported to the
|
|
190
|
+
* cloud as `skipped-precondition` instead.
|
|
191
|
+
*/
|
|
192
|
+
export function planJobRuns(items, handled) {
|
|
193
|
+
const superseded = new Set();
|
|
194
|
+
const byJob = new Map();
|
|
195
|
+
for (const item of items) {
|
|
196
|
+
const job = readJobRun(item);
|
|
197
|
+
if (!job)
|
|
198
|
+
continue;
|
|
199
|
+
const list = byJob.get(job.jobId);
|
|
200
|
+
if (list)
|
|
201
|
+
list.push(item);
|
|
202
|
+
else
|
|
203
|
+
byJob.set(job.jobId, [item]);
|
|
204
|
+
}
|
|
205
|
+
for (const [jobId, runs] of byJob) {
|
|
206
|
+
let newest = handled.get(jobId) ?? "";
|
|
207
|
+
for (const run of runs) {
|
|
208
|
+
const key = jobRunOrderKey(run);
|
|
209
|
+
if (key > newest)
|
|
210
|
+
newest = key;
|
|
211
|
+
}
|
|
212
|
+
handled.set(jobId, newest);
|
|
213
|
+
let keptNewest = false;
|
|
214
|
+
for (const run of runs) {
|
|
215
|
+
if (jobRunOrderKey(run) === newest && !keptNewest) {
|
|
216
|
+
keptNewest = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
superseded.add(run.messageId);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { superseded };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The most run output the cloud keeps (hq-pro-agents jobs/results.ts
|
|
226
|
+
* `MAX_JOB_RUN_OUTPUT_BYTES`). The server bounds it itself; the client sends up
|
|
227
|
+
* to the same bound rather than an arbitrary shorter cut, so a long report is
|
|
228
|
+
* recorded in full wherever the cloud can hold it.
|
|
229
|
+
*/
|
|
230
|
+
export const MAX_JOB_RUN_OUTPUT_BYTES = 64 * 1024;
|
|
231
|
+
/** Cut `text` to at most `maxBytes` of UTF-8 without splitting a character. */
|
|
232
|
+
export function jobRunOutput(text, maxBytes = MAX_JOB_RUN_OUTPUT_BYTES) {
|
|
233
|
+
const bytes = Buffer.from(text, "utf8");
|
|
234
|
+
if (bytes.byteLength <= maxBytes)
|
|
235
|
+
return text;
|
|
236
|
+
let end = maxBytes;
|
|
237
|
+
// Step back over UTF-8 continuation bytes (10xxxxxx) to a character boundary.
|
|
238
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80)
|
|
239
|
+
end -= 1;
|
|
240
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
241
|
+
}
|
|
242
|
+
/** A saved report body with the run's header taken back off, for the cloud's record. */
|
|
243
|
+
export function stripJobHeader(body, header) {
|
|
244
|
+
return header && body.startsWith(header) ? body.slice(header.length) : body;
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=job-run.js.map
|
package/dist/lib/bot/paths.d.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* session.json model-CLI session id for the owner DM
|
|
12
12
|
* session-dm-<hexPeerUid>.json one session per other DM correspondent
|
|
13
13
|
* session-room-<channelId>.json one session per room the bot answers in
|
|
14
|
+
* session-job-<jobId>.json one session per scheduled job the bot runs
|
|
15
|
+
* job-completions.json run completions the cloud has not accepted yet
|
|
14
16
|
* pid single-file pid lock
|
|
15
17
|
* logs/bot.log rotated at 5MB
|
|
16
18
|
*
|
|
@@ -39,10 +41,16 @@ export declare function botInboxStatePath(dir: string): string;
|
|
|
39
41
|
* Model conversations are scoped to a DM correspondent or room. The owner's
|
|
40
42
|
* legacy "dm" scope remains stable; other senders use "dm:<peerUid>".
|
|
41
43
|
*/
|
|
42
|
-
export type BotSessionScope = "dm" | `dm:${string}` | `room:${string}`;
|
|
44
|
+
export type BotSessionScope = "dm" | `dm:${string}` | `room:${string}` | `job:${string}`;
|
|
43
45
|
/** Preserve the owner session; keep every other correspondent separate. */
|
|
44
46
|
export declare function dmSessionScope(peerUid: string, ownerUid: string): BotSessionScope;
|
|
45
47
|
export declare function roomSessionScope(channelId: string): BotSessionScope;
|
|
48
|
+
/**
|
|
49
|
+
* One session per scheduled job. A job turn must never resume the owner's DM
|
|
50
|
+
* conversation (a scheduled run is not a reply), and two different jobs must
|
|
51
|
+
* not share a session either — each recurring routine keeps its own history.
|
|
52
|
+
*/
|
|
53
|
+
export declare function jobSessionScope(jobId: string): BotSessionScope;
|
|
46
54
|
export declare function botSessionPath(dir: string, scope?: BotSessionScope): string;
|
|
47
55
|
export declare function botTokenStateDir(dir: string): string;
|
|
48
56
|
/**
|
package/dist/lib/bot/paths.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* session.json model-CLI session id for the owner DM
|
|
12
12
|
* session-dm-<hexPeerUid>.json one session per other DM correspondent
|
|
13
13
|
* session-room-<channelId>.json one session per room the bot answers in
|
|
14
|
+
* session-job-<jobId>.json one session per scheduled job the bot runs
|
|
15
|
+
* job-completions.json run completions the cloud has not accepted yet
|
|
14
16
|
* pid single-file pid lock
|
|
15
17
|
* logs/bot.log rotated at 5MB
|
|
16
18
|
*
|
|
@@ -69,11 +71,23 @@ export function dmSessionScope(peerUid, ownerUid) {
|
|
|
69
71
|
export function roomSessionScope(channelId) {
|
|
70
72
|
return `room:${channelId}`;
|
|
71
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* One session per scheduled job. A job turn must never resume the owner's DM
|
|
76
|
+
* conversation (a scheduled run is not a reply), and two different jobs must
|
|
77
|
+
* not share a session either — each recurring routine keeps its own history.
|
|
78
|
+
*/
|
|
79
|
+
export function jobSessionScope(jobId) {
|
|
80
|
+
return `job:${jobId}`;
|
|
81
|
+
}
|
|
72
82
|
export function botSessionPath(dir, scope = "dm") {
|
|
73
83
|
if (scope === "dm")
|
|
74
84
|
return path.join(dir, BOT_SESSION_NAME);
|
|
75
85
|
if (scope.startsWith("dm:"))
|
|
76
86
|
return path.join(dir, `session-dm-${Buffer.from(scope.slice(3)).toString("hex")}.json`);
|
|
87
|
+
if (scope.startsWith("job:")) {
|
|
88
|
+
const jobId = scope.slice("job:".length).replace(/[^A-Za-z0-9_-]/g, "_");
|
|
89
|
+
return path.join(dir, `session-job-${jobId}.json`);
|
|
90
|
+
}
|
|
77
91
|
const channelId = scope.slice("room:".length).replace(/[^A-Za-z0-9_-]/g, "_");
|
|
78
92
|
return path.join(dir, `session-room-${channelId}.json`);
|
|
79
93
|
}
|
package/dist/lib/bot/prompt.d.ts
CHANGED
|
@@ -105,6 +105,18 @@ export declare function botKindParagraph(input: Pick<BuildSystemPromptInput, "ag
|
|
|
105
105
|
* preamble (grok).
|
|
106
106
|
*/
|
|
107
107
|
export declare function buildSystemPrompt(input: BuildSystemPromptInput): string;
|
|
108
|
+
/**
|
|
109
|
+
* What the bot knows about putting its own work on a schedule. The schedule
|
|
110
|
+
* lives in the cloud; the bot never writes a crontab or a launchd timer.
|
|
111
|
+
*
|
|
112
|
+
* The catch-up wording is deliberately precise. The cloud only dead-letters a
|
|
113
|
+
* stale run on the job's NEXT fire, and the staleness window is an hour, so a
|
|
114
|
+
* missed run is picked up on wake only if the wake happens within about an
|
|
115
|
+
* hour; a longer outage means that run is gone and the job simply resumes at
|
|
116
|
+
* its next scheduled time. Promising an unconditional catch-up would be the bot
|
|
117
|
+
* telling its owner something untrue about work they are relying on.
|
|
118
|
+
*/
|
|
119
|
+
export declare function scheduledJobsParagraph(botName: string): string;
|
|
108
120
|
/**
|
|
109
121
|
* The one-time introduction the bot DMs its owner when it first comes online.
|
|
110
122
|
* A bot created with `--intro` sends that text verbatim instead.
|
package/dist/lib/bot/prompt.js
CHANGED
|
@@ -289,6 +289,12 @@ export function buildSystemPrompt(input) {
|
|
|
289
289
|
`comes from a room with other people in it (a "Recent messages:" block may follow for context; the last part is the message you are answering). ` +
|
|
290
290
|
`Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
|
|
291
291
|
`or anything from your DMs to other people.`);
|
|
292
|
+
// Only company bots: the cloud's jobs routes refuse a personal bot's per_
|
|
293
|
+
// company claim today, so telling a personal bot it can schedule work makes
|
|
294
|
+
// it promise its owner something that then fails. Drop this condition when
|
|
295
|
+
// the server accepts personal bots — the paragraph itself needs no change.
|
|
296
|
+
if (kind === "company")
|
|
297
|
+
parts.push(scheduledJobsParagraph(input.botName));
|
|
292
298
|
if (workerMode && worker) {
|
|
293
299
|
const yamlPath = path.join(input.workerDir, "worker.yaml");
|
|
294
300
|
if (worker.instructions) {
|
|
@@ -333,6 +339,31 @@ export function buildSystemPrompt(input) {
|
|
|
333
339
|
}
|
|
334
340
|
return `${parts.join("\n\n")}\n`;
|
|
335
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* What the bot knows about putting its own work on a schedule. The schedule
|
|
344
|
+
* lives in the cloud; the bot never writes a crontab or a launchd timer.
|
|
345
|
+
*
|
|
346
|
+
* The catch-up wording is deliberately precise. The cloud only dead-letters a
|
|
347
|
+
* stale run on the job's NEXT fire, and the staleness window is an hour, so a
|
|
348
|
+
* missed run is picked up on wake only if the wake happens within about an
|
|
349
|
+
* hour; a longer outage means that run is gone and the job simply resumes at
|
|
350
|
+
* its next scheduled time. Promising an unconditional catch-up would be the bot
|
|
351
|
+
* telling its owner something untrue about work they are relying on.
|
|
352
|
+
*/
|
|
353
|
+
export function scheduledJobsParagraph(botName) {
|
|
354
|
+
return (`## Scheduled jobs\n\n` +
|
|
355
|
+
`You can put your own work on a recurring schedule. When your owner asks for something regular ` +
|
|
356
|
+
`("check X every weekday morning", "send me a summary every Friday"), run ` +
|
|
357
|
+
`\`hq bot jobs ${botName} --add "<what to do>" --every "<plain English, e.g. every weekday at 9am>"\`. ` +
|
|
358
|
+
`See what you have scheduled with \`hq bot jobs ${botName}\`, and remove one with ` +
|
|
359
|
+
`\`hq bot jobs ${botName} --rm <jobId>\`.\n` +
|
|
360
|
+
`After you schedule something, say back in plain words what you set up and when it will run. Also say what ` +
|
|
361
|
+
`happens if this computer is asleep or switched off when a run is due: it is picked up as a catch-up when the ` +
|
|
362
|
+
`computer wakes, as long as it wakes within about an hour; after a longer outage that run is skipped and the ` +
|
|
363
|
+
`job carries on at its next scheduled time. A schedule cannot fire more often than every 5 minutes.\n` +
|
|
364
|
+
`When one of these fires you get a turn that starts with "[Scheduled job · …]". Nobody is waiting in a chat: ` +
|
|
365
|
+
`do the work and finish with a short report — that report is what your owner reads.`);
|
|
366
|
+
}
|
|
336
367
|
/**
|
|
337
368
|
* The one-time introduction the bot DMs its owner when it first comes online.
|
|
338
369
|
* A bot created with `--intro` sends that text verbatim instead.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/** Reply to any correspondent or delivered room message, except our own posts.
|
|
1
|
+
/** Reply to any correspondent or delivered room message, except our own posts.
|
|
2
|
+
* Jobs (channel "job") are scheduled runs the cloud fired: not addressed to
|
|
3
|
+
* anyone, they resolve to their own lane and model session, and report in the
|
|
4
|
+
* owner's DM.
|
|
5
|
+
*/
|
|
2
6
|
import type { InboxItem } from "./api.js";
|
|
7
|
+
import { type JobRunFields } from "./job-run.js";
|
|
3
8
|
import { type BotSessionScope } from "./paths.js";
|
|
4
9
|
export type ReplyTarget = {
|
|
5
10
|
kind: "dm";
|
|
@@ -12,6 +17,18 @@ export type ReplyTarget = {
|
|
|
12
17
|
sessionScope: BotSessionScope;
|
|
13
18
|
/** True when this bot was @mentioned: answer without claiming. */
|
|
14
19
|
mentioned: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
/**
|
|
22
|
+
* A scheduled job the cloud fired for this bot. Nobody sent it: the work
|
|
23
|
+
* is the job's own prompt, and the result is reported in the owner's DM,
|
|
24
|
+
* clearly marked as a scheduled run rather than a reply.
|
|
25
|
+
*/
|
|
26
|
+
kind: "job";
|
|
27
|
+
jobId: string;
|
|
28
|
+
/** Where the report goes: the bot's DM with its owner. */
|
|
29
|
+
peerUid: string;
|
|
30
|
+
sessionScope: BotSessionScope;
|
|
31
|
+
job: JobRunFields;
|
|
15
32
|
};
|
|
16
33
|
export interface ReplyPolicyConfig {
|
|
17
34
|
ownerUid: string;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/** Reply to any correspondent or delivered room message, except our own posts.
|
|
2
|
-
|
|
1
|
+
/** Reply to any correspondent or delivered room message, except our own posts.
|
|
2
|
+
* Jobs (channel "job") are scheduled runs the cloud fired: not addressed to
|
|
3
|
+
* anyone, they resolve to their own lane and model session, and report in the
|
|
4
|
+
* owner's DM.
|
|
5
|
+
*/
|
|
6
|
+
import { readJobRun } from "./job-run.js";
|
|
7
|
+
import { jobSessionScope, roomSessionScope } from "./paths.js";
|
|
3
8
|
function escapeRegExp(s) {
|
|
4
9
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
10
|
}
|
|
@@ -13,6 +18,19 @@ export function isDmItem(item) {
|
|
|
13
18
|
}
|
|
14
19
|
export function resolveReplyTarget(item, config) {
|
|
15
20
|
const sender = item.fromPersonUid ?? "";
|
|
21
|
+
if (item.channel === "job") {
|
|
22
|
+
const job = readJobRun(item);
|
|
23
|
+
// A job item with no jobId or no prompt is nothing the bot can run.
|
|
24
|
+
if (!job)
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
kind: "job",
|
|
28
|
+
jobId: job.jobId,
|
|
29
|
+
peerUid: config.ownerUid,
|
|
30
|
+
sessionScope: jobSessionScope(job.jobId),
|
|
31
|
+
job,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
16
34
|
if (isDmItem(item)) {
|
|
17
35
|
return sender && sender !== config.agentUid ? { kind: "dm", peerUid: sender } : null;
|
|
18
36
|
}
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
|
|
14
14
|
* model turn on that prompt as if the owner sent it, answered in the DM, so
|
|
15
15
|
* a guided bot starts working without waiting for the owner to type
|
|
16
|
+
* - job items (channel "job"): a run the cloud fired on an EventBridge
|
|
17
|
+
* schedule. Nothing is scheduled on this computer. The run is one model turn
|
|
18
|
+
* in its own session (one per jobId), reported in the owner's DM with a
|
|
19
|
+
* "scheduled job" header through the same split/recovery path as every other
|
|
20
|
+
* reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
|
|
21
|
+
* the job's in-progress flag. A run whose staleAfter has passed still runs,
|
|
22
|
+
* once, labelled a catch-up; older pending runs of the same job are reported
|
|
23
|
+
* skipped-precondition instead of piling up.
|
|
16
24
|
* - delivered room items (channels / group chats) are eligible for replies; claimed
|
|
17
25
|
* first (first-claim-wins), one session per room, reply posted in the room
|
|
18
26
|
* - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
|
|
@@ -82,6 +90,18 @@ export declare const MAX_PARALLEL_TURNS = 2;
|
|
|
82
90
|
export declare const RECOVERY_ATTEMPT_LIMIT = 5;
|
|
83
91
|
/** What the person sees when a reply could not be delivered at all. */
|
|
84
92
|
export declare const UNDELIVERABLE_REPLY_TEXT = "I wrote a reply but couldn't deliver it. Ask me again and I'll keep it shorter.";
|
|
93
|
+
/**
|
|
94
|
+
* The same, for a scheduled run. Nobody asked, so "ask me again" is wrong: the
|
|
95
|
+
* run DID finish, and its outcome is still recorded with the job in HQ.
|
|
96
|
+
*/
|
|
97
|
+
export declare const UNDELIVERABLE_JOB_REPORT_TEXT = "This scheduled run finished, but I couldn't deliver its report here. Its result is saved with the job's run history.";
|
|
98
|
+
/**
|
|
99
|
+
* Scheduled work must never crowd out the people the bot talks to. A job turn
|
|
100
|
+
* can run for a long time, so at most ONE runs at a time, and a job never takes
|
|
101
|
+
* the last free slot: a DM or a room message can always start immediately
|
|
102
|
+
* instead of waiting, with no explanation, behind two long jobs.
|
|
103
|
+
*/
|
|
104
|
+
export declare const MAX_PARALLEL_JOB_TURNS = 1;
|
|
85
105
|
export type SleepFn = (ms: number) => Promise<void>;
|
|
86
106
|
export interface BotRunDeps {
|
|
87
107
|
dir: string;
|
|
@@ -163,7 +183,9 @@ export interface BotHandle {
|
|
|
163
183
|
/**
|
|
164
184
|
* The lane an inbox item runs in: the model session its turn resumes. Owner
|
|
165
185
|
* DMs retain the legacy DM session; other senders and rooms each have their
|
|
166
|
-
* own
|
|
186
|
+
* own, and so does each scheduled job (two different jobs must never share a
|
|
187
|
+
* model session, and a job must never pollute the owner's DM conversation).
|
|
188
|
+
* An item that never reaches a model turn gets a lane of its own.
|
|
167
189
|
*/
|
|
168
190
|
export declare function inboxLaneKey(item: InboxItem, config: Pick<BotConfig, "ownerUid" | "agentUid" | "name">): string;
|
|
169
191
|
export declare function preflightCreds(dir: string, config: BotConfig): string | null;
|