aibroker 0.24.0 → 0.25.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.
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aibroker-hook-lib.mjs — shared helpers for AIBroker Claude Code hooks.
|
|
3
|
+
*
|
|
4
|
+
* These hooks make channel routing DETERMINISTIC instead of relying on the LLM
|
|
5
|
+
* to remember to call pailot_tts / pailot_send. They are pure IPC clients:
|
|
6
|
+
* they talk to the running AIBroker daemon over /tmp/aibroker.sock using the
|
|
7
|
+
* exact same wire protocol as src/ipc/client.ts — no daemon changes required.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is defensive: any failure resolves to a safe no-op so a hook
|
|
10
|
+
* can never block or crash a Claude Code turn.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { connect } from "node:net";
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const AIBROKER_SOCKET = "/tmp/aibroker.sock";
|
|
20
|
+
const IPC_TIMEOUT_MS = 8_000;
|
|
21
|
+
|
|
22
|
+
// ── stdin / hook payload ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Read all of stdin as a string. */
|
|
25
|
+
export async function readStdin() {
|
|
26
|
+
let input = "";
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
try {
|
|
29
|
+
for await (const chunk of process.stdin) {
|
|
30
|
+
input += decoder.decode(chunk, { stream: true });
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse the Claude Code hook payload (Stop / PreToolUse share these fields). */
|
|
39
|
+
export function parseHookInput(raw) {
|
|
40
|
+
try {
|
|
41
|
+
const p = JSON.parse(raw);
|
|
42
|
+
return {
|
|
43
|
+
transcriptPath: p.transcript_path ?? "",
|
|
44
|
+
cwd: p.cwd ?? process.cwd(),
|
|
45
|
+
sessionId: p.session_id ?? "",
|
|
46
|
+
toolName: p.tool_name ?? "",
|
|
47
|
+
toolInput: p.tool_input ?? {},
|
|
48
|
+
stopHookActive: p.stop_hook_active === true,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── transcript parsing ────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function readTranscriptLines(path) {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(path, "utf-8").trim().split("\n");
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseLine(line) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(line);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the text blocks of a message's content as a string array.
|
|
75
|
+
* String content → [string]. Array content → the `text` blocks only
|
|
76
|
+
* (tool_use / tool_result / image blocks are ignored).
|
|
77
|
+
*/
|
|
78
|
+
function textBlocks(content) {
|
|
79
|
+
if (typeof content === "string") return content.trim() ? [content] : [];
|
|
80
|
+
if (Array.isArray(content)) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const c of content) {
|
|
83
|
+
if (typeof c === "string" && c.trim()) out.push(c);
|
|
84
|
+
else if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) out.push(c.text);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the MOST RECENT genuine human prompt (a user-role message that carries
|
|
93
|
+
* real text, not a bare tool_result). Returns { index, blocks } or null.
|
|
94
|
+
*
|
|
95
|
+
* Only the latest human prompt decides routing — this enforces per-message
|
|
96
|
+
* independence (an earlier PAILot turn must not affect a later terminal turn).
|
|
97
|
+
*/
|
|
98
|
+
export function findLastHumanPrompt(lines) {
|
|
99
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
100
|
+
const e = parseLine(lines[i]);
|
|
101
|
+
if (!e || e.type !== "user" || e.message?.role !== "user") continue;
|
|
102
|
+
const blocks = textBlocks(e.message.content);
|
|
103
|
+
if (blocks.length > 0) return { index: i, blocks };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const PREFIX_RE = /^\s*\[(PAILot|Whazaa|Telex)(:voice)?\]/i;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detect a channel prefix, anchored to the START of a text block. AIBroker
|
|
112
|
+
* string-prepends the prefix (e.g. "[PAILot:voice] …"), so anchoring means a
|
|
113
|
+
* terminal message that merely *mentions* the token cannot trigger delivery.
|
|
114
|
+
* Returns { channel: 'pailot'|'whazaa'|'telex', voice: boolean } or null.
|
|
115
|
+
*/
|
|
116
|
+
export function detectChannel(blocks) {
|
|
117
|
+
for (const b of blocks) {
|
|
118
|
+
const m = b.match(PREFIX_RE);
|
|
119
|
+
if (m) return { channel: m[1].toLowerCase(), voice: !!m[2] };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Scan the current turn (lines AFTER the prompt) for which reply tools the
|
|
126
|
+
* assistant actually called. Matches by tool-name substring so it works for the
|
|
127
|
+
* fully-qualified MCP names (e.g. "mcp__aibroker__pailot_tts").
|
|
128
|
+
*/
|
|
129
|
+
export function scanReplyTools(lines, fromIdx) {
|
|
130
|
+
let tts = false;
|
|
131
|
+
let send = false;
|
|
132
|
+
for (let i = fromIdx + 1; i < lines.length; i++) {
|
|
133
|
+
const e = parseLine(lines[i]);
|
|
134
|
+
if (!e || e.type !== "assistant" || !Array.isArray(e.message?.content)) continue;
|
|
135
|
+
for (const c of e.message.content) {
|
|
136
|
+
if (c && c.type === "tool_use" && typeof c.name === "string") {
|
|
137
|
+
if (c.name.includes("pailot_tts")) tts = true;
|
|
138
|
+
if (c.name.includes("pailot_send")) send = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { tts, send };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The last non-empty assistant text in the current turn (the final answer). */
|
|
146
|
+
export function lastAssistantText(lines, fromIdx) {
|
|
147
|
+
for (let i = lines.length - 1; i > fromIdx; i--) {
|
|
148
|
+
const e = parseLine(lines[i]);
|
|
149
|
+
if (!e || e.type !== "assistant") continue;
|
|
150
|
+
const blocks = textBlocks(e.message?.content);
|
|
151
|
+
if (blocks.length > 0) return blocks.join("\n\n").trim();
|
|
152
|
+
}
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── IPC to the AIBroker daemon ────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Call a daemon method over the Unix socket. Mirrors src/ipc/client.ts framing
|
|
160
|
+
* (one JSON line in, one JSON line out). Passes through the terminal's session
|
|
161
|
+
* identifiers so the daemon can resolve the correct target session. Resolves
|
|
162
|
+
* { ok, result?, error? }; never rejects.
|
|
163
|
+
*/
|
|
164
|
+
export function ipcCall(method, params) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
let done = false;
|
|
167
|
+
let buffer = "";
|
|
168
|
+
let timer = null;
|
|
169
|
+
const finish = (v) => {
|
|
170
|
+
if (done) return;
|
|
171
|
+
done = true;
|
|
172
|
+
if (timer) clearTimeout(timer);
|
|
173
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
174
|
+
resolve(v);
|
|
175
|
+
};
|
|
176
|
+
const sock = connect(AIBROKER_SOCKET, () => {
|
|
177
|
+
const req = { id: randomUUID(), sessionId: process.env.TERM_SESSION_ID ?? "hook", method, params };
|
|
178
|
+
if (process.env.ITERM_SESSION_ID) req.itermSessionId = process.env.ITERM_SESSION_ID;
|
|
179
|
+
if (process.env.TMUX_PANE) req.tmuxPane = process.env.TMUX_PANE;
|
|
180
|
+
sock.write(JSON.stringify(req) + "\n");
|
|
181
|
+
});
|
|
182
|
+
sock.on("data", (chunk) => {
|
|
183
|
+
buffer += chunk.toString();
|
|
184
|
+
const nl = buffer.indexOf("\n");
|
|
185
|
+
if (nl === -1) return;
|
|
186
|
+
try {
|
|
187
|
+
finish(JSON.parse(buffer.slice(0, nl)));
|
|
188
|
+
} catch {
|
|
189
|
+
finish({ ok: false, error: "parse" });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
sock.on("error", () => finish({ ok: false, error: "socket" }));
|
|
193
|
+
sock.on("end", () => finish({ ok: false, error: "closed" }));
|
|
194
|
+
timer = setTimeout(() => finish({ ok: false, error: "timeout" }), IPC_TIMEOUT_MS);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Deliver text (or voice) to PAILot via the existing pailot_send IPC. */
|
|
199
|
+
export function pailotDeliver(text, voice) {
|
|
200
|
+
return ipcCall("pailot_send", { text, voice: !!voice });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── per-turn dedupe markers ───────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export function hashPrompt(text) {
|
|
206
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 16);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Returns true the FIRST time it is called for a given (kind, sessionId, hash)
|
|
211
|
+
* and false thereafter — so a hook fires at most once per turn.
|
|
212
|
+
*/
|
|
213
|
+
export function claimOnce(kind, sessionId, hash) {
|
|
214
|
+
try {
|
|
215
|
+
const marker = join(tmpdir(), `aibroker-${kind}-${sessionId || "x"}-${hash}.done`);
|
|
216
|
+
if (existsSync(marker)) return false;
|
|
217
|
+
writeFileSync(marker, String(Date.now()));
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
// If the marker can't be written, allow the action rather than suppress it.
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aibroker-progress.mjs — PreToolUse hook (matcher: Task). Layer 2.
|
|
4
|
+
*
|
|
5
|
+
* When the assistant spawns a Task (research / long work) during a turn that
|
|
6
|
+
* originated from PAILot, push a one-time progress note to the channel so the
|
|
7
|
+
* user — e.g. driving, listening — knows the session is working and an answer
|
|
8
|
+
* is coming. Without this, "I'll research this and report back" only ever
|
|
9
|
+
* reaches the terminal.
|
|
10
|
+
*
|
|
11
|
+
* Voice turns get ONE short spoken ack (not per-tool audio spam). Text turns
|
|
12
|
+
* get a text line describing the work. Fires at most once per turn.
|
|
13
|
+
*
|
|
14
|
+
* Always allows the tool (exit 0). Terminal turns → no-op.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readStdin, parseHookInput, readTranscriptLines,
|
|
19
|
+
findLastHumanPrompt, detectChannel,
|
|
20
|
+
pailotDeliver, hashPrompt, claimOnce,
|
|
21
|
+
} from "./aibroker-hook-lib.mjs";
|
|
22
|
+
|
|
23
|
+
const VOICE_ACK = "On it — I'm working on that now and I'll speak the answer as soon as it's ready.";
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const input = await parseHookInput(await readStdin());
|
|
27
|
+
// The subagent-spawn tool is "Task" in some harnesses, "Agent" in others.
|
|
28
|
+
if (!input || (input.toolName !== "Task" && input.toolName !== "Agent") || !input.transcriptPath) return;
|
|
29
|
+
|
|
30
|
+
const lines = readTranscriptLines(input.transcriptPath);
|
|
31
|
+
if (lines.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const prompt = findLastHumanPrompt(lines);
|
|
34
|
+
if (!prompt) return;
|
|
35
|
+
|
|
36
|
+
const chan = detectChannel(prompt.blocks);
|
|
37
|
+
if (!chan || chan.channel !== "pailot") return;
|
|
38
|
+
|
|
39
|
+
// One progress signal per turn.
|
|
40
|
+
const hash = hashPrompt(prompt.blocks.join("\n"));
|
|
41
|
+
if (!claimOnce("progress", input.sessionId, hash)) return;
|
|
42
|
+
|
|
43
|
+
if (chan.voice) {
|
|
44
|
+
await pailotDeliver(VOICE_ACK, true);
|
|
45
|
+
} else {
|
|
46
|
+
const desc = typeof input.toolInput?.description === "string" && input.toolInput.description.trim()
|
|
47
|
+
? input.toolInput.description.trim()
|
|
48
|
+
: "Working on your request";
|
|
49
|
+
await pailotDeliver(`🔧 ${desc}… I'll report back when done.`, false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aibroker-route-guard.mjs — Stop hook. Layer 1 of bulletproof channel routing.
|
|
4
|
+
*
|
|
5
|
+
* When a turn ends, inspect the triggering prompt. If it came from a channel
|
|
6
|
+
* (starts with [PAILot] / [PAILot:voice]) but the matching reply tool was NOT
|
|
7
|
+
* called during the turn, the answer went only to the terminal — a routing
|
|
8
|
+
* miss. This guard then delivers the final answer to the channel itself over
|
|
9
|
+
* the existing pailot_send IPC, so a voice question can never get zero answer.
|
|
10
|
+
*
|
|
11
|
+
* Scope: PAILot (text + voice). Whazaa/Telex replies need recipient context the
|
|
12
|
+
* hook doesn't have, so they are detected but left to the normal flow.
|
|
13
|
+
*
|
|
14
|
+
* Fails safe: any error → exit 0, no delivery. Terminal turns → no-op.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readStdin, parseHookInput, readTranscriptLines,
|
|
19
|
+
findLastHumanPrompt, detectChannel, scanReplyTools, lastAssistantText,
|
|
20
|
+
pailotDeliver, hashPrompt, claimOnce,
|
|
21
|
+
} from "./aibroker-hook-lib.mjs";
|
|
22
|
+
|
|
23
|
+
const DEBUG = process.env.AIBROKER_HOOK_DEBUG === "1";
|
|
24
|
+
const dbg = (m) => { if (DEBUG) console.error(`[route-guard] ${m}`); };
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
const input = await parseHookInput(await readStdin());
|
|
28
|
+
if (!input || !input.transcriptPath) return;
|
|
29
|
+
|
|
30
|
+
const lines = readTranscriptLines(input.transcriptPath);
|
|
31
|
+
if (lines.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const prompt = findLastHumanPrompt(lines);
|
|
34
|
+
if (!prompt) { dbg("no human prompt"); return; }
|
|
35
|
+
|
|
36
|
+
const chan = detectChannel(prompt.blocks);
|
|
37
|
+
if (!chan) { dbg("terminal turn — no prefix, no-op"); return; }
|
|
38
|
+
|
|
39
|
+
// Only PAILot is auto-deliverable (self-resolving session). Others: leave be.
|
|
40
|
+
if (chan.channel !== "pailot") { dbg(`channel ${chan.channel} out of scope`); return; }
|
|
41
|
+
|
|
42
|
+
const { tts, send } = scanReplyTools(lines, prompt.index);
|
|
43
|
+
// voice-in requires voice-out (pailot_tts). text-in is satisfied by either.
|
|
44
|
+
const answered = chan.voice ? tts : (send || tts);
|
|
45
|
+
if (answered) { dbg("already answered on channel — no-op"); return; }
|
|
46
|
+
|
|
47
|
+
const answer = lastAssistantText(lines, prompt.index);
|
|
48
|
+
if (!answer) { dbg("no assistant text to deliver"); return; }
|
|
49
|
+
|
|
50
|
+
// Fire at most once per turn.
|
|
51
|
+
const hash = hashPrompt(prompt.blocks.join("\n"));
|
|
52
|
+
if (!claimOnce("guard", input.sessionId, hash)) { dbg("already delivered this turn"); return; }
|
|
53
|
+
|
|
54
|
+
const res = await pailotDeliver(answer, chan.voice);
|
|
55
|
+
if (res?.ok) {
|
|
56
|
+
console.error(`[route-guard] recovered routing miss → delivered answer to PAILot${chan.voice ? " (voice)" : ""}.`);
|
|
57
|
+
} else {
|
|
58
|
+
dbg(`delivery failed: ${res?.error ?? "unknown"}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hooks/drain-mailbox.mjs — deliver what the mailbox is holding.
|
|
4
|
+
*
|
|
5
|
+
* `send_to_session` deposits into the target's mailbox and types a copy into
|
|
6
|
+
* its terminal. The typed copy is best-effort — a session mid-turn never reads
|
|
7
|
+
* it — and the mailbox was pull-only, so a message only arrived if the session
|
|
8
|
+
* happened to call `aibroker_receive`. It usually did not: 35 real messages
|
|
9
|
+
* across five sessions sat undrained on 2026-08-01/02, the oldest for a day.
|
|
10
|
+
*
|
|
11
|
+
* A UserPromptSubmit hook is the natural drain. It runs before the model sees
|
|
12
|
+
* the turn, which is the first moment a busy session is listening again, so
|
|
13
|
+
* anything queued is injected as context on the very next turn rather than
|
|
14
|
+
* whenever someone thinks to check.
|
|
15
|
+
*
|
|
16
|
+
* DRAINING IS DESTRUCTIVE — the daemon empties the mailbox as it reads it — so
|
|
17
|
+
* this must not swallow what it cannot deliver. If the output cannot be
|
|
18
|
+
* emitted, the messages are written to ~/.aibroker/undelivered.jsonl rather
|
|
19
|
+
* than lost, which is the whole failure this hook exists to end.
|
|
20
|
+
*
|
|
21
|
+
* Silent when there is nothing waiting, and silent on every error: a hook that
|
|
22
|
+
* fails loudly on every prompt is a hook someone disables.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import net from "node:net";
|
|
26
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
const SOCKET = "/tmp/aibroker.sock";
|
|
31
|
+
const TIMEOUT_MS = 1500;
|
|
32
|
+
const UNDELIVERED = join(homedir(), ".aibroker", "undelivered.jsonl");
|
|
33
|
+
|
|
34
|
+
/** The session this hook is running inside. Without it there is nothing to drain. */
|
|
35
|
+
function sessionId() {
|
|
36
|
+
const raw = process.env.TMUX_PANE ?? process.env.ITERM_SESSION_ID;
|
|
37
|
+
if (!raw) return undefined;
|
|
38
|
+
return raw.includes(":") ? raw.split(":").pop() : raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function call(method, params) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let done = false;
|
|
44
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
45
|
+
const timer = setTimeout(() => { try { sock.destroy(); } catch {} finish(null); }, TIMEOUT_MS);
|
|
46
|
+
const sock = net.createConnection(SOCKET, () => {
|
|
47
|
+
sock.write(JSON.stringify({ id: "drain", method, params }) + "\n");
|
|
48
|
+
});
|
|
49
|
+
let buf = "";
|
|
50
|
+
sock.on("data", (d) => {
|
|
51
|
+
buf += d;
|
|
52
|
+
if (!buf.includes("\n")) return;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
try { finish(JSON.parse(buf.trim())); } catch { finish(null); }
|
|
55
|
+
sock.end();
|
|
56
|
+
});
|
|
57
|
+
// The daemon being down is not this hook's problem to report.
|
|
58
|
+
sock.on("error", () => { clearTimeout(timer); finish(null); });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const id = sessionId();
|
|
63
|
+
if (!id) process.exit(0);
|
|
64
|
+
|
|
65
|
+
const res = await call("session_mailbox_receive", { sessionId: id });
|
|
66
|
+
const messages = res?.ok ? (res.result?.messages ?? []) : [];
|
|
67
|
+
if (messages.length === 0) process.exit(0);
|
|
68
|
+
|
|
69
|
+
const lines = messages.map((m) => {
|
|
70
|
+
const when = m.timestamp ? new Date(m.timestamp).toISOString().slice(11, 19) : "";
|
|
71
|
+
const waited = m.timestamp ? Math.round((Date.now() - m.timestamp) / 60000) : null;
|
|
72
|
+
const age = waited !== null && waited >= 1 ? ` (waited ${waited} min)` : "";
|
|
73
|
+
return `[Session:${m.from}] ${when}${age}\n${m.content}`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const out =
|
|
77
|
+
`<system-reminder>\n` +
|
|
78
|
+
`${messages.length} message(s) were waiting in this session's mailbox and have been delivered now.\n` +
|
|
79
|
+
`They arrived while this session was busy. Treat each as if it had just been sent: reply to the\n` +
|
|
80
|
+
`sender with aibroker_send_to_session, not in the terminal.\n\n` +
|
|
81
|
+
lines.join("\n\n") +
|
|
82
|
+
`\n</system-reminder>`;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
process.stdout.write(out + "\n");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// The drain already emptied the mailbox. Losing them here would be the exact
|
|
88
|
+
// silent drop this hook was written to stop.
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(join(homedir(), ".aibroker"), { recursive: true });
|
|
91
|
+
appendFileSync(UNDELIVERED, messages.map((m) => JSON.stringify({ ...m, failedAt: new Date().toISOString(), error: String(e) })).join("\n") + "\n");
|
|
92
|
+
} catch { /* nothing left to try */ }
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aibroker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Platform-agnostic AI message broker — routes between user channels and AI backends",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"dist",
|
|
10
10
|
"templates",
|
|
11
11
|
"README.md",
|
|
12
|
-
"LICENSE"
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"hooks"
|
|
13
14
|
],
|
|
14
15
|
"bin": {
|
|
15
16
|
"aibroker": "dist/daemon/cli.js",
|