@indigoai-us/hq-cli 5.109.16 → 5.111.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/assets/bot-workers/setup/context/USER-GUIDE.md +363 -0
  3. package/assets/bot-workers/setup/context/quick-reference.md +199 -0
  4. package/assets/bot-workers/setup/skills/first-company.md +71 -0
  5. package/assets/bot-workers/setup/skills/standing-help.md +74 -0
  6. package/assets/bot-workers/setup/worker.yaml +422 -0
  7. package/dist/commands/bot-continuity.d.ts +28 -0
  8. package/dist/commands/bot-continuity.js +68 -0
  9. package/dist/commands/bot.d.ts +73 -0
  10. package/dist/commands/bot.js +776 -0
  11. package/dist/commands/skill.d.ts +12 -3
  12. package/dist/commands/skill.js +79 -3
  13. package/dist/commands/workers.d.ts +2 -14
  14. package/dist/commands/workers.js +2 -8
  15. package/dist/lib/bot/api.d.ts +202 -0
  16. package/dist/lib/bot/api.js +202 -0
  17. package/dist/lib/bot/company-bind.d.ts +27 -0
  18. package/dist/lib/bot/company-bind.js +62 -0
  19. package/dist/lib/bot/config.d.ts +106 -0
  20. package/dist/lib/bot/config.js +141 -0
  21. package/dist/lib/bot/continuity-download.d.ts +28 -0
  22. package/dist/lib/bot/continuity-download.js +75 -0
  23. package/dist/lib/bot/continuity-install.d.ts +14 -0
  24. package/dist/lib/bot/continuity-install.js +101 -0
  25. package/dist/lib/bot/continuity.d.ts +66 -0
  26. package/dist/lib/bot/continuity.js +301 -0
  27. package/dist/lib/bot/creds.d.ts +24 -0
  28. package/dist/lib/bot/creds.js +51 -0
  29. package/dist/lib/bot/daemon.d.ts +75 -0
  30. package/dist/lib/bot/daemon.js +316 -0
  31. package/dist/lib/bot/inbox-state.d.ts +18 -0
  32. package/dist/lib/bot/inbox-state.js +51 -0
  33. package/dist/lib/bot/index.d.ts +16 -0
  34. package/dist/lib/bot/index.js +16 -0
  35. package/dist/lib/bot/inflight.d.ts +40 -0
  36. package/dist/lib/bot/inflight.js +44 -0
  37. package/dist/lib/bot/log.d.ts +13 -0
  38. package/dist/lib/bot/log.js +59 -0
  39. package/dist/lib/bot/owner-context.d.ts +75 -0
  40. package/dist/lib/bot/owner-context.js +151 -0
  41. package/dist/lib/bot/paths.d.ts +61 -0
  42. package/dist/lib/bot/paths.js +103 -0
  43. package/dist/lib/bot/progress.d.ts +84 -0
  44. package/dist/lib/bot/progress.js +167 -0
  45. package/dist/lib/bot/promote.d.ts +16 -0
  46. package/dist/lib/bot/promote.js +106 -0
  47. package/dist/lib/bot/promotion-hold.d.ts +24 -0
  48. package/dist/lib/bot/promotion-hold.js +103 -0
  49. package/dist/lib/bot/promotion-receipt.d.ts +9 -0
  50. package/dist/lib/bot/promotion-receipt.js +56 -0
  51. package/dist/lib/bot/promotion-upload.d.ts +16 -0
  52. package/dist/lib/bot/promotion-upload.js +65 -0
  53. package/dist/lib/bot/prompt.d.ts +103 -0
  54. package/dist/lib/bot/prompt.js +329 -0
  55. package/dist/lib/bot/room-policy.d.ts +53 -0
  56. package/dist/lib/bot/room-policy.js +73 -0
  57. package/dist/lib/bot/run.d.ts +98 -0
  58. package/dist/lib/bot/run.js +787 -0
  59. package/dist/lib/bot/runtime/claude.d.ts +49 -0
  60. package/dist/lib/bot/runtime/claude.js +151 -0
  61. package/dist/lib/bot/runtime/codex.d.ts +28 -0
  62. package/dist/lib/bot/runtime/codex.js +147 -0
  63. package/dist/lib/bot/runtime/grok.d.ts +16 -0
  64. package/dist/lib/bot/runtime/grok.js +67 -0
  65. package/dist/lib/bot/runtime/index.d.ts +35 -0
  66. package/dist/lib/bot/runtime/index.js +279 -0
  67. package/dist/lib/bot/runtime/messages-stream.d.ts +27 -0
  68. package/dist/lib/bot/runtime/messages-stream.js +85 -0
  69. package/dist/lib/bot/runtime/types.d.ts +136 -0
  70. package/dist/lib/bot/runtime/types.js +51 -0
  71. package/dist/lib/bot/scaffold.d.ts +38 -0
  72. package/dist/lib/bot/scaffold.js +94 -0
  73. package/dist/lib/bot/session.d.ts +19 -0
  74. package/dist/lib/bot/session.js +39 -0
  75. package/dist/lib/bot/status.d.ts +40 -0
  76. package/dist/lib/bot/status.js +66 -0
  77. package/dist/lib/bot/worker-source.d.ts +66 -0
  78. package/dist/lib/bot/worker-source.js +283 -0
  79. package/dist/lib/workers-registry/read.d.ts +15 -0
  80. package/dist/lib/workers-registry/read.js +17 -0
  81. package/dist/register-all.js +2 -0
  82. package/package.json +2 -1
@@ -0,0 +1,329 @@
1
+ /**
2
+ * System context for the bot's model session (local-bots US-003/US-006, Feature 4).
3
+ *
4
+ * Scaffolded bot — assembled from the bot's own worker folder under the HQ root:
5
+ * personal/workers/<name>/worker.yaml identity + capabilities
6
+ * personal/workers/<name>/persona.md voice and standing instructions
7
+ * personal/workers/<name>/memory/*.md durable memory the bot may edit
8
+ *
9
+ * Worker-sourced bot (workerSource "worker") — the bot runs a company worker:
10
+ * companies/<co>/workers/<id>/worker.yaml parsed: instructions, identity.persona,
11
+ * skills (→ skills/<name>.md | skills/<name>/SKILL.md),
12
+ * context.base (inlined under a budget)
13
+ * companies/<co>/policies/*.md listed as paths (hard rules; read before acting)
14
+ * <memoryDir>/*.md the bot's own memory (personal/workers/<bot>/memory,
15
+ * or an absolute ~/.hq/bots/<bot>/memory for local memory)
16
+ *
17
+ * Pure over an injected reader so tests never touch the real HQ.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import * as yaml from "js-yaml";
22
+ import { packageRoot } from "../../utils/hq-roots.js";
23
+ export const MEMORY_FILE_BUDGET_BYTES = 64 * 1024;
24
+ export const MEMORY_TOTAL_BUDGET_BYTES = 192 * 1024;
25
+ export const CONTEXT_FILE_BUDGET_BYTES = 32 * 1024;
26
+ export const CONTEXT_TOTAL_BUDGET_BYTES = 96 * 1024;
27
+ export const defaultPromptFs = {
28
+ existsSync: (p) => fs.existsSync(p),
29
+ readFileSync: (p) => fs.readFileSync(p, "utf8"),
30
+ readdirSync: (p) => fs.readdirSync(p),
31
+ };
32
+ export function resolveMemoryDir(workerDir, memoryDir) {
33
+ return memoryDir?.trim() ? memoryDir.replace(/\/+$/, "") : path.join(workerDir, "memory");
34
+ }
35
+ function readMemory(hqRoot, memoryRel, io) {
36
+ const memory = [];
37
+ const memDir = path.isAbsolute(memoryRel) ? memoryRel : path.join(hqRoot, memoryRel);
38
+ if (!io.existsSync(memDir))
39
+ return memory;
40
+ let total = 0;
41
+ let names;
42
+ try {
43
+ names = io.readdirSync(memDir).sort();
44
+ }
45
+ catch {
46
+ return memory;
47
+ }
48
+ for (const name of names) {
49
+ if (!name.endsWith(".md") || name.startsWith("."))
50
+ continue;
51
+ let body;
52
+ try {
53
+ body = io.readFileSync(path.join(memDir, name));
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (body.length > MEMORY_FILE_BUDGET_BYTES) {
59
+ body = `${body.slice(0, MEMORY_FILE_BUDGET_BYTES)}\n\n[truncated: ${name} exceeds the memory file budget]`;
60
+ }
61
+ if (total + body.length > MEMORY_TOTAL_BUDGET_BYTES)
62
+ break;
63
+ total += body.length;
64
+ memory.push({ name, body });
65
+ }
66
+ return memory;
67
+ }
68
+ /** A path is usable only when it stays inside the HQ root (no `..`, no absolute escapes). */
69
+ function insideHqRoot(hqRoot, rel) {
70
+ const root = path.resolve(hqRoot);
71
+ const abs = path.resolve(root, rel);
72
+ if (abs === root || !abs.startsWith(root + path.sep))
73
+ return null;
74
+ return abs;
75
+ }
76
+ function asString(v) {
77
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
78
+ }
79
+ /** `instructions` may be a string, a block scalar, or a list of strings/objects. */
80
+ export function normalizeInstructions(raw) {
81
+ if (typeof raw === "string")
82
+ return raw.trim() || undefined;
83
+ if (Array.isArray(raw)) {
84
+ const lines = raw
85
+ .map((item) => {
86
+ if (typeof item === "string")
87
+ return item.trim();
88
+ if (item && typeof item === "object")
89
+ return yaml.dump(item, { lineWidth: -1 }).trim();
90
+ return item === null || item === undefined ? "" : String(item);
91
+ })
92
+ .filter(Boolean)
93
+ .map((line) => `- ${line.replace(/\n/g, "\n ")}`);
94
+ return lines.length > 0 ? lines.join("\n") : undefined;
95
+ }
96
+ return undefined;
97
+ }
98
+ function resolveSkillFile(hqRoot, workerDir, name, doc, io) {
99
+ const candidates = [];
100
+ const docRel = asString(doc);
101
+ if (docRel)
102
+ candidates.push(path.isAbsolute(docRel) ? docRel : path.join(workerDir, docRel));
103
+ candidates.push(path.join(workerDir, "skills", `${name}.md`), path.join(workerDir, "skills", name, "SKILL.md"));
104
+ for (const rel of candidates) {
105
+ let abs = insideHqRoot(hqRoot, rel);
106
+ // The portable setup template is shipped outside the HQ tree. Only its
107
+ // own skills may cross that boundary; other workers keep the HQ guard.
108
+ if (!abs) {
109
+ const bundled = path.join(packageRoot(), "assets", "bot-workers", "setup");
110
+ if (path.resolve(hqRoot, workerDir) === bundled) {
111
+ abs = insideHqRoot(path.join(bundled, "skills"), path.resolve(hqRoot, rel));
112
+ }
113
+ }
114
+ if (abs && io.existsSync(abs))
115
+ return path.relative(path.resolve(hqRoot), abs);
116
+ }
117
+ return undefined;
118
+ }
119
+ function readWorkerSource(hqRoot, workerDir, workerYaml, io, companyOverride) {
120
+ const out = { skills: [], contextFiles: [], contextPaths: [], policyPaths: [] };
121
+ let doc = {};
122
+ if (workerYaml) {
123
+ try {
124
+ const parsed = yaml.load(workerYaml);
125
+ if (parsed && typeof parsed === "object")
126
+ doc = parsed;
127
+ }
128
+ catch {
129
+ doc = {};
130
+ }
131
+ }
132
+ const meta = (doc.worker && typeof doc.worker === "object" ? doc.worker : {});
133
+ out.id = asString(meta.id);
134
+ out.name = asString(meta.name);
135
+ out.company = companyOverride ?? asString(meta.company);
136
+ out.description = asString(meta.description);
137
+ out.instructions = normalizeInstructions(doc.instructions);
138
+ const identity = (doc.identity && typeof doc.identity === "object" ? doc.identity : {});
139
+ out.persona = asString(identity.persona);
140
+ if (Array.isArray(doc.skills)) {
141
+ for (const item of doc.skills) {
142
+ if (!item || typeof item !== "object")
143
+ continue;
144
+ const s = item;
145
+ const name = asString(s.name);
146
+ if (!name)
147
+ continue;
148
+ const file = resolveSkillFile(hqRoot, workerDir, name, s.doc, io);
149
+ out.skills.push({ name, ...(asString(s.description) ? { description: asString(s.description) } : {}), ...(file ? { file } : {}) });
150
+ }
151
+ }
152
+ const context = (doc.context && typeof doc.context === "object" ? doc.context : {});
153
+ const bundledSetup = path.resolve(hqRoot, workerDir) === path.join(packageRoot(), "assets", "bot-workers", "setup");
154
+ if (Array.isArray(context.base)) {
155
+ let total = 0;
156
+ for (const entry of context.base) {
157
+ const rel = asString(typeof entry === "string" ? entry : entry?.path);
158
+ if (!rel)
159
+ continue;
160
+ const abs = bundledSetup ? insideHqRoot(path.join(packageRoot(), "assets", "bot-workers", "setup"), rel) : insideHqRoot(hqRoot, rel);
161
+ if (!abs)
162
+ continue;
163
+ const shown = rel.replace(/^\.\//, "");
164
+ if (rel.endsWith("/") || !io.existsSync(abs)) {
165
+ out.contextPaths.push(shown);
166
+ continue;
167
+ }
168
+ let body;
169
+ try {
170
+ body = io.readFileSync(abs);
171
+ }
172
+ catch {
173
+ out.contextPaths.push(shown);
174
+ continue;
175
+ }
176
+ let truncated = false;
177
+ if (body.length > CONTEXT_FILE_BUDGET_BYTES) {
178
+ body = `${body.slice(0, CONTEXT_FILE_BUDGET_BYTES)}\n\n[truncated: ${shown} exceeds the context file budget — read the file for the rest]`;
179
+ truncated = true;
180
+ }
181
+ if (total + body.length > CONTEXT_TOTAL_BUDGET_BYTES) {
182
+ out.contextPaths.push(shown);
183
+ continue;
184
+ }
185
+ total += body.length;
186
+ out.contextFiles.push({ path: shown, body, ...(truncated ? { truncated } : {}) });
187
+ }
188
+ }
189
+ if (out.company) {
190
+ const policiesRel = path.join("companies", out.company, "policies");
191
+ const policiesAbs = insideHqRoot(hqRoot, policiesRel);
192
+ if (policiesAbs && io.existsSync(policiesAbs)) {
193
+ let names;
194
+ try {
195
+ names = io.readdirSync(policiesAbs).sort();
196
+ }
197
+ catch {
198
+ names = [];
199
+ }
200
+ out.policyPaths = names
201
+ .filter((n) => n.endsWith(".md") && !n.startsWith(".") && !n.includes(".conflict-"))
202
+ .map((n) => path.join(policiesRel, n));
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ export function readPromptSources(hqRoot, workerDir, io = defaultPromptFs, opts = {}) {
208
+ const base = path.join(hqRoot, workerDir);
209
+ const read = (file) => {
210
+ const p = path.join(base, file);
211
+ if (!io.existsSync(p))
212
+ return undefined;
213
+ try {
214
+ return io.readFileSync(p);
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ };
220
+ const memory = readMemory(hqRoot, resolveMemoryDir(workerDir, opts.memoryDir), io);
221
+ const workerYaml = read("worker.yaml");
222
+ if (opts.workerSource === "worker") {
223
+ return {
224
+ workerYaml,
225
+ persona: read("persona.md"),
226
+ memory,
227
+ worker: readWorkerSource(hqRoot, workerDir, workerYaml, io, opts.companySlug),
228
+ };
229
+ }
230
+ return { workerYaml, persona: read("persona.md"), memory };
231
+ }
232
+ /**
233
+ * The standing instructions the bot runs under. The DM body is delivered as
234
+ * the user turn; this text is the system prompt (claude/codex) or the prompt
235
+ * preamble (grok).
236
+ */
237
+ export function buildSystemPrompt(input) {
238
+ const memoryDir = resolveMemoryDir(input.workerDir, input.memoryDir);
239
+ const workerMode = input.workerSource === "worker" && input.sources.worker !== undefined;
240
+ const worker = input.sources.worker;
241
+ const workerId = input.workerId ?? worker?.id ?? path.basename(input.workerDir);
242
+ const company = input.companySlug ?? worker?.company;
243
+ const parts = [];
244
+ const opening = workerMode
245
+ ? `You are "${input.botName}", a personal HQ bot running the company worker \`${workerId}\`${company ? ` for \`${company}\`` : ""}, locally on your owner's computer.` +
246
+ (worker?.name ? ` The worker is "${worker.name}".` : "") +
247
+ (worker?.description ? ` ${worker.description}` : "") +
248
+ `\nYou work only inside \`${company ?? "this company"}\`'s context: its files, policies, services, and credentials — never another company's.`
249
+ : `You are "${input.botName}", a personal HQ bot that runs locally on your owner's computer.`;
250
+ parts.push(`${opening}\n` +
251
+ `Your HQ identity is ${input.agentUid}. Your owner is ${input.ownerUid}; only your owner can message you.\n` +
252
+ `You act with your owner's permissions, and every tool and command is pre-approved for you — do not ask for approval, just do the work. ` +
253
+ `Their HQ folder is ${input.hqRoot}; it is your working directory, so its files, skills, and \`hq\` commands are available directly.\n` +
254
+ `\`hq\` commands you run authenticate as you, the bot (${input.agentUid}), not as your owner: \`hq whoami\`, membership and company lookups describe the bot, ` +
255
+ `and a bot usually belongs to no company. Never use them to say who your owner is or which companies they belong to. ` +
256
+ `Direct messages from your owner start with an "Owner context" block checked with your owner's own sign-in; that is the only source for your owner's companies. ` +
257
+ `If it says the check failed, say you could not check; never say your owner has no company unless that block says so. ` +
258
+ `Room messages do not carry it: in a room, never state which companies your owner belongs to.\n` +
259
+ `Each message you receive is a direct message from your owner. Whatever you print as your final answer is sent back as a DM reply, ` +
260
+ `so answer the person directly, in plain language, and keep it as short as the request allows. ` +
261
+ `Do not narrate tool use or repeat the question. If you cannot do something, say so and suggest the nearest thing you can do.\n` +
262
+ `Your standing memory lives in ${memoryDir}/ (markdown). When you learn something durable about your owner or their preferences, ` +
263
+ `write it there in a short note so future turns remember it; never store secrets there.\n` +
264
+ `Your owner can also add you to group chats and channels. A message that starts with a \`[#channel · N members · from Name]\` line ` +
265
+ `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). ` +
266
+ `Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
267
+ `or anything from your DMs to other people.`);
268
+ if (workerMode && worker) {
269
+ const yamlPath = path.join(input.workerDir, "worker.yaml");
270
+ if (worker.instructions) {
271
+ parts.push(`## Worker instructions (${yamlPath})\n\n${worker.instructions}`);
272
+ }
273
+ if (worker.persona) {
274
+ parts.push(`## Persona\n\n${worker.persona}`);
275
+ }
276
+ if (worker.skills.length > 0) {
277
+ const lines = worker.skills.map((s) => {
278
+ const desc = s.description ? ` — ${s.description}` : "";
279
+ const file = s.file ? ` — ${s.file}` : " — (no skill file on disk)";
280
+ return `- ${s.name}${desc}${file}`;
281
+ });
282
+ parts.push(`## Skills (${path.join(input.workerDir, "skills")}/)\n\n` +
283
+ `These are the worker's skills. When a request matches one, read its file first and follow it.\n\n${lines.join("\n")}`);
284
+ }
285
+ if (worker.policyPaths.length > 0) {
286
+ parts.push(`## Company policies (companies/${company}/policies/)\n\n` +
287
+ `Hard rules for \`${company}\`. Read the relevant ones before acting; they override anything else in this prompt.\n\n` +
288
+ worker.policyPaths.map((p) => `- ${p}`).join("\n"));
289
+ }
290
+ if (worker.contextFiles.length > 0 || worker.contextPaths.length > 0) {
291
+ const blocks = worker.contextFiles.map((c) => `### ${c.path}\n\n${c.body.trim()}`);
292
+ const more = worker.contextPaths.length > 0
293
+ ? `\n\nAlso part of this worker's context (read on demand):\n${worker.contextPaths.map((p) => `- ${p}`).join("\n")}`
294
+ : "";
295
+ parts.push(`## Context (worker context.base)\n\n${blocks.join("\n\n")}${more}`.replace(/\n\n\n\n/g, "\n\n"));
296
+ }
297
+ }
298
+ else {
299
+ if (input.sources.workerYaml) {
300
+ parts.push(`## Worker definition (${path.join(input.workerDir, "worker.yaml")})\n\n\`\`\`yaml\n${input.sources.workerYaml.trim()}\n\`\`\``);
301
+ }
302
+ if (input.sources.persona) {
303
+ parts.push(`## Persona (${path.join(input.workerDir, "persona.md")})\n\n${input.sources.persona.trim()}`);
304
+ }
305
+ }
306
+ if (input.sources.memory.length > 0) {
307
+ const blocks = input.sources.memory.map((m) => `### ${m.name}\n\n${m.body.trim()}`);
308
+ parts.push(`## Memory (${memoryDir}/)\n\n${blocks.join("\n\n")}`);
309
+ }
310
+ return `${parts.join("\n\n")}\n`;
311
+ }
312
+ /** Fixed text sent to any non-owner who DMs the bot (never reaches the model). */
313
+ export function nonOwnerRefusalText(botName) {
314
+ return `Hi — I'm ${botName}, a personal bot, and I only take messages from my owner. Please reach out to them directly.`;
315
+ }
316
+ /**
317
+ * The one-time introduction the bot DMs its owner when it first comes online.
318
+ * A bot created with `--intro` sends that text verbatim instead.
319
+ */
320
+ export function introDmText(botName, runtime, intro) {
321
+ if (intro?.trim())
322
+ return intro.trim();
323
+ const runtimeLabel = runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : runtime === "grok" ? "Grok" : runtime;
324
+ return (`Hi, I'm ${botName} — your HQ bot. I run locally on this computer using your ${runtimeLabel} login, so I can work inside your HQ with your permissions.\n\n` +
325
+ `You can message me here from the desktop app or your phone whenever this computer is on, and I'll reply in this thread. ` +
326
+ `You can also open me in Claude Code or Codex from your HQ folder.\n\n` +
327
+ `What would you like me to do first?`);
328
+ }
329
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Who the bot answers, and where the answer goes (local-bots: group chats).
3
+ *
4
+ * Pure policy over one inbox item (owner decisions, 2026-09-11):
5
+ * - DMs: owner only. A non-owner DM is NOT this module's concern — run.ts
6
+ * sends the fixed refusal before consulting this policy; here it resolves
7
+ * to `null` (ack silently) so nothing else can leak a model turn.
8
+ * - Rooms (channels / group chats): answer anyone who @mentions the bot, plus
9
+ * plain (unmentioned) messages from the OWNER when the room is a small
10
+ * group (`channelScope === "group"`). Everything else is acked without a
11
+ * turn. The bot never answers its own posts.
12
+ * - Placement: a threaded trigger (item.rootEventId) gets a threaded reply;
13
+ * a top-level mention gets a top-level reply.
14
+ * - Claim unit: the trigger message itself (eventId / messageId). Only an
15
+ * UNMENTIONED responder (owner-in-group) claims it, so several of the
16
+ * owner's bots in one group produce one answer, not one each. An explicit
17
+ * @mention is never claimed: the person asked that bot, so it answers even
18
+ * if a sibling bot was faster (observed live 2026-09-11: claude-bot claimed
19
+ * the message that @mentioned iris).
20
+ * - Addressed elsewhere: the inbox item only carries `mentionedMe`, not the
21
+ * full mention list, so an unmentioned bot reads the text — a message that
22
+ * @mentions someone else is not for it and is acked silently.
23
+ */
24
+ import type { InboxItem } from "./api.js";
25
+ import { type BotSessionScope } from "./paths.js";
26
+ export type ReplyTarget = {
27
+ kind: "dm";
28
+ peerUid: string;
29
+ } | {
30
+ kind: "room";
31
+ channelId: string;
32
+ rootEventId?: string;
33
+ unitId: string;
34
+ sessionScope: BotSessionScope;
35
+ /** True when this bot was @mentioned: answer without claiming. */
36
+ mentioned: boolean;
37
+ };
38
+ export declare function textAddressesSomeone(text: string | undefined): boolean;
39
+ export interface ReplyPolicyConfig {
40
+ ownerUid: string;
41
+ agentUid: string;
42
+ /**
43
+ * The bot's name, so a plain-text `@<name>` counts as a mention. Group DMs
44
+ * sent from the CLI carry no structured mentions (only channels resolve
45
+ * `@name`), so without this "@claude-bot …" in a group would be treated as
46
+ * addressed to someone else and nobody would answer.
47
+ */
48
+ name?: string;
49
+ }
50
+ export declare function textMentionsName(text: string | undefined, name: string | undefined): boolean;
51
+ export declare function isDmItem(item: Pick<InboxItem, "channel">): boolean;
52
+ export declare function resolveReplyTarget(item: InboxItem, config: ReplyPolicyConfig): ReplyTarget | null;
53
+ //# sourceMappingURL=room-policy.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Who the bot answers, and where the answer goes (local-bots: group chats).
3
+ *
4
+ * Pure policy over one inbox item (owner decisions, 2026-09-11):
5
+ * - DMs: owner only. A non-owner DM is NOT this module's concern — run.ts
6
+ * sends the fixed refusal before consulting this policy; here it resolves
7
+ * to `null` (ack silently) so nothing else can leak a model turn.
8
+ * - Rooms (channels / group chats): answer anyone who @mentions the bot, plus
9
+ * plain (unmentioned) messages from the OWNER when the room is a small
10
+ * group (`channelScope === "group"`). Everything else is acked without a
11
+ * turn. The bot never answers its own posts.
12
+ * - Placement: a threaded trigger (item.rootEventId) gets a threaded reply;
13
+ * a top-level mention gets a top-level reply.
14
+ * - Claim unit: the trigger message itself (eventId / messageId). Only an
15
+ * UNMENTIONED responder (owner-in-group) claims it, so several of the
16
+ * owner's bots in one group produce one answer, not one each. An explicit
17
+ * @mention is never claimed: the person asked that bot, so it answers even
18
+ * if a sibling bot was faster (observed live 2026-09-11: claude-bot claimed
19
+ * the message that @mentioned iris).
20
+ * - Addressed elsewhere: the inbox item only carries `mentionedMe`, not the
21
+ * full mention list, so an unmentioned bot reads the text — a message that
22
+ * @mentions someone else is not for it and is acked silently.
23
+ */
24
+ import { roomSessionScope } from "./paths.js";
25
+ /** `@name` (word-boundary) anywhere in the text — "a@b.com" does not count. */
26
+ const MENTION_RE = /(^|[^\w@])@[A-Za-z0-9][\w.-]*/;
27
+ export function textAddressesSomeone(text) {
28
+ return typeof text === "string" && MENTION_RE.test(text);
29
+ }
30
+ function escapeRegExp(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+ }
33
+ export function textMentionsName(text, name) {
34
+ if (typeof text !== "string" || !name?.trim())
35
+ return false;
36
+ return new RegExp(`(^|[^\\w@])@${escapeRegExp(name.trim())}(?![\\w.-])`, "i").test(text);
37
+ }
38
+ export function isDmItem(item) {
39
+ return !item.channel || item.channel === "dm";
40
+ }
41
+ export function resolveReplyTarget(item, config) {
42
+ const sender = item.fromPersonUid ?? "";
43
+ if (isDmItem(item)) {
44
+ return sender === config.ownerUid ? { kind: "dm", peerUid: config.ownerUid } : null;
45
+ }
46
+ if (item.channel !== "room")
47
+ return null;
48
+ const channelId = typeof item.channelId === "string" ? item.channelId.trim() : "";
49
+ if (!channelId)
50
+ return null;
51
+ if (sender && sender === config.agentUid)
52
+ return null;
53
+ const mentioned = item.mentionedMe === true || textMentionsName(item.text, config.name);
54
+ const ownerInGroup = item.channelScope === "group" && sender === config.ownerUid;
55
+ if (!mentioned && !ownerInGroup)
56
+ return null;
57
+ // The owner addressed another member by name: not for this bot.
58
+ if (!mentioned && textAddressesSomeone(item.text))
59
+ return null;
60
+ const unitId = (item.eventId ?? item.messageId ?? "").trim();
61
+ if (!unitId)
62
+ return null;
63
+ const rootEventId = typeof item.rootEventId === "string" && item.rootEventId.trim() ? item.rootEventId.trim() : undefined;
64
+ return {
65
+ kind: "room",
66
+ channelId,
67
+ ...(rootEventId ? { rootEventId } : {}),
68
+ unitId,
69
+ sessionScope: roomSessionScope(channelId),
70
+ mentioned,
71
+ };
72
+ }
73
+ //# sourceMappingURL=room-policy.js.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The resident bot process: `hq bot run <name>` (local-bots US-003/US-004).
3
+ *
4
+ * - pid lock in ~/.hq/bots/<name>/pid; status.json is the observable state
5
+ * - refuses to start unless the creds file names this bot's agt_ AND the
6
+ * server-side record is owned by the configured owner
7
+ * - heartbeat every 30s (POST /v1/agents/{uid}/heartbeat, agent JWT)
8
+ * - inbox poll every 5s: owner DMs go to ONE headless model turn (resumed by
9
+ * session id); the reply is sent as the bot; the message is acked only after
10
+ * the reply is sent; processed ids persist so a crash between reply and ack
11
+ * never answers twice
12
+ * - non-owner DM senders get a fixed refusal and never reach the model
13
+ * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
14
+ * model turn on that prompt as if the owner sent it, answered in the DM, so
15
+ * a guided bot starts working without waiting for the owner to type
16
+ * - room items (channels / group chats): answered when the bot is @mentioned,
17
+ * or when the owner posts in a small group (see room-policy.ts); claimed
18
+ * first (first-claim-wins), one session per room, reply posted in the room
19
+ * - while a turn runs, each finished assistant message is posted as its own
20
+ * message (progress.ts); turns have no time limit
21
+ * - every received message gets a reply: a turn that fails after the model
22
+ * started (error, crash, owner stop, restart) posts one "did not finish"
23
+ * message and is NOT run again — it may already have done outward things.
24
+ * inflight.json marks the turn in progress so a restart can say so.
25
+ * - failures before the model starts (missing binary) retry with backoff;
26
+ * 5 model failures in 10 minutes → state `failed` and a clean exit (launchd
27
+ * does not respawn a clean exit). Owner stops never count.
28
+ *
29
+ * Everything with a side effect is injectable so the loop is unit-testable.
30
+ */
31
+ import { type OwnerContext } from "./owner-context.js";
32
+ import type { BotApi } from "./api.js";
33
+ import type { BotConfig } from "./config.js";
34
+ import { type ProgressPosterOptions } from "./progress.js";
35
+ import { type BotLogger } from "./log.js";
36
+ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
37
+ import { type BotState, type BotStatusFile } from "./status.js";
38
+ export declare const INBOX_POLL_INTERVAL_MS = 2000;
39
+ export declare const HEARTBEAT_INTERVAL_MS = 30000;
40
+ export declare const FAILURE_WINDOW_MS: number;
41
+ export declare const FAILURE_LIMIT = 5;
42
+ export declare const MAX_ATTEMPTS_PER_MESSAGE = 3;
43
+ export declare const THINKING_EMOJI = "\uD83D\uDC40";
44
+ export declare const ROOM_THINKING_STATUS = "is thinking\u2026";
45
+ export declare const RECENT_MESSAGES_LIMIT = 15;
46
+ export declare const RECENT_MESSAGE_MAX_CHARS = 400;
47
+ export type SleepFn = (ms: number) => Promise<void>;
48
+ export interface BotRunDeps {
49
+ dir: string;
50
+ config: BotConfig;
51
+ api: BotApi;
52
+ runtime: BotRuntime;
53
+ runTurn?: (runtime: BotRuntime, input: RuntimeTurnInput) => Promise<RuntimeTurnResult>;
54
+ now?: () => Date;
55
+ sleep?: SleepFn;
56
+ random?: () => number;
57
+ log?: BotLogger;
58
+ pid?: number;
59
+ isProcessAlive?: (pid: number) => boolean;
60
+ inboxIntervalMs?: number;
61
+ heartbeatIntervalMs?: number;
62
+ /** Override the rendered system prompt (tests). */
63
+ systemPromptOverride?: string;
64
+ /** Signal source; defaults to `process`. */
65
+ processEvents?: {
66
+ once(event: "SIGTERM" | "SIGINT" | "SIGUSR2", cb: () => void): unknown;
67
+ };
68
+ /** Called when the loop ends; defaults to process.exit. */
69
+ exit?: (code: number) => void;
70
+ /** Skip the pid lock (tests that run several bots in one process). */
71
+ skipPidLock?: boolean;
72
+ /** Progress-post timing overrides (tests). */
73
+ progress?: Pick<ProgressPosterOptions, "coalesceMs" | "minGapMs" | "workingNoticeMs" | "setTimer" | "clearTimer" | "now">;
74
+ /**
75
+ * The owner's verified company context, prepended to every owner DM turn
76
+ * (never room turns) so the model never mistakes the bot's own (empty)
77
+ * memberships for its owner's.
78
+ * Omitted in tests that do not exercise it.
79
+ */
80
+ ownerContext?: () => Promise<OwnerContext>;
81
+ }
82
+ export declare const KICKOFF_MESSAGE_ID = "kickoff";
83
+ /** The core worker HQ's setup bot runs. */
84
+ export declare const SETUP_WORKER_ID = "setup";
85
+ /** The one message a person gets when a turn on their message did not finish. */
86
+ export declare function didNotFinishText(reason: string, progressPosted: boolean): string;
87
+ /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
88
+ export declare const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
89
+ export declare const RESTARTED_REASON = "I was restarted while I was working on it";
90
+ export interface BotHandle {
91
+ stop(mode?: "abort" | "drain"): Promise<void>;
92
+ /** Resolves when both loops have ended (state stopped or failed). */
93
+ done: Promise<BotState>;
94
+ status(): BotStatusFile | null;
95
+ }
96
+ export declare function preflightCreds(dir: string, config: BotConfig): string | null;
97
+ export declare function runBot(deps: BotRunDeps): Promise<BotHandle>;
98
+ //# sourceMappingURL=run.d.ts.map