sentinelayer-cli 0.19.0 → 0.20.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,135 @@
1
+ // Wake pump — the live event source for the sentid daemon (Wake-Up Bus L2).
2
+ //
3
+ // Connects a session's event stream to the dispatcher: fetch new events, run
4
+ // them through dispatcher.dispatchBatch (which applies resolveTarget routing,
5
+ // the at-least-once retry/DLQ contract, and the in-memory RESUME cursor), then
6
+ // persist that cursor for restart RESUME.
7
+ //
8
+ // The event source `pollEvents` is INJECTED, not imported: importing
9
+ // sync.js/listener.js would drag the heavy auth/`open` module graph into the
10
+ // daemon and make this untestable. The thin sentid runtime entrypoint adapts
11
+ // the real pollSessionEvents to the contract below; tests inject a fake.
12
+ //
13
+ // At-least-once end to end: the fetch cursor advances only to the cursor of the
14
+ // last COMMITTED event (one whose seq the dispatcher actually advanced past). A
15
+ // retryable/uncommitted failure stops the batch, the fetch cursor stays behind
16
+ // it, and the next poll re-delivers that event — so a transient wake failure is
17
+ // retried, never silently skipped.
18
+
19
+ import { readWakeCursor, writeWakeCursor } from "./cursor-store.js";
20
+
21
+ function seqOf(event) {
22
+ const raw = event?.sequenceId ?? event?.seq ?? event?.payload?.sequenceId;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) ? n : null;
25
+ }
26
+
27
+ function cursorOf(event) {
28
+ const c = event?.cursor;
29
+ return typeof c === "string" && c ? c : null;
30
+ }
31
+
32
+ const defaultSleep = (ms, { signal } = {}) =>
33
+ new Promise((resolve) => {
34
+ const t = setTimeout(resolve, ms);
35
+ if (typeof t.unref === "function") t.unref();
36
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true });
37
+ });
38
+
39
+ /**
40
+ * @param {object} opts
41
+ * @param {string} opts.sessionId
42
+ * @param {{ dispatchBatch:Function, getCursor:Function, setCursor:Function }} opts.dispatcher
43
+ * @param {(sessionId:string, o:{afterCursor:string|null}) => Promise<{events:object[], cursor?:string|null}>} opts.pollEvents
44
+ * @param {{ targetPath?:string, idleMs?:number, readCursor?:Function, writeCursor?:Function, sleep?:Function, logger?:Function }} [opts]
45
+ */
46
+ export function createWakePump({
47
+ sessionId,
48
+ dispatcher,
49
+ pollEvents,
50
+ targetPath,
51
+ idleMs = 1500,
52
+ readCursor = readWakeCursor,
53
+ writeCursor = writeWakeCursor,
54
+ sleep = defaultSleep,
55
+ logger,
56
+ } = {}) {
57
+ if (typeof sessionId !== "string" || sessionId.trim() === "") {
58
+ throw new TypeError("wake pump: sessionId must be a non-empty string");
59
+ }
60
+ if (!dispatcher || typeof dispatcher.dispatchBatch !== "function" || typeof dispatcher.getCursor !== "function") {
61
+ throw new TypeError("wake pump: dispatcher with dispatchBatch()/getCursor() is required");
62
+ }
63
+ if (typeof pollEvents !== "function") {
64
+ throw new TypeError("wake pump: pollEvents function is required");
65
+ }
66
+ const log = typeof logger === "function" ? logger : () => {};
67
+ let seeded = false;
68
+
69
+ // Seed the dispatcher's RESUME cursor from disk once, on first use.
70
+ async function ensureSeeded() {
71
+ if (seeded) return;
72
+ seeded = true;
73
+ if (typeof dispatcher.setCursor === "function") {
74
+ dispatcher.setCursor(await readCursor(sessionId, { targetPath }));
75
+ }
76
+ }
77
+
78
+ // The fetch cursor advances only to the last COMMITTED event's cursor.
79
+ function nextFetchCursor(events, committedSeq, fallback) {
80
+ let best = fallback;
81
+ let bestSeq = -Infinity;
82
+ for (const event of events) {
83
+ const seq = seqOf(event);
84
+ const cursor = cursorOf(event);
85
+ if (cursor !== null && seq !== null && seq <= committedSeq && seq > bestSeq) {
86
+ bestSeq = seq;
87
+ best = cursor;
88
+ }
89
+ }
90
+ return best;
91
+ }
92
+
93
+ /**
94
+ * One fetch -> dispatch -> persist cycle. Returns the next fetch cursor and the
95
+ * dispatch results so the caller (or a test) can observe progress.
96
+ */
97
+ async function tickOnce({ fetchCursor = null, deps = {} } = {}) {
98
+ await ensureSeeded();
99
+ const { events = [], cursor: latestCursor = null } = (await pollEvents(sessionId, { afterCursor: fetchCursor })) || {};
100
+ if (!Array.isArray(events) || events.length === 0) {
101
+ return { fetchCursor, results: [], idle: true };
102
+ }
103
+ const results = await dispatcher.dispatchBatch(events, deps);
104
+ const committedSeq = dispatcher.getCursor();
105
+ // Only adopt the poller's latest cursor if the WHOLE batch committed.
106
+ const allCommitted = results.length === events.length && results.every((r) => !r.retryable);
107
+ const advanced = nextFetchCursor(events, committedSeq, fetchCursor);
108
+ const next = allCommitted && typeof latestCursor === "string" && latestCursor ? latestCursor : advanced;
109
+ await writeCursor(sessionId, committedSeq, { targetPath });
110
+ log("debug", "wake pump tick", { committedSeq, fetched: events.length, advanced: next !== fetchCursor });
111
+ return { fetchCursor: next, results, idle: false };
112
+ }
113
+
114
+ /** Run the fetch loop until `signal` aborts. */
115
+ async function start({ signal, fetchCursor = null, deps = {} } = {}) {
116
+ let cursor = fetchCursor;
117
+ while (!signal?.aborted) {
118
+ let idle = true;
119
+ try {
120
+ const tick = await tickOnce({ fetchCursor: cursor, deps });
121
+ cursor = tick.fetchCursor;
122
+ idle = tick.idle;
123
+ } catch (error) {
124
+ if (signal?.aborted || error?.name === "AbortError") break;
125
+ log("error", "wake pump tick failed; backing off", { reason: error?.message });
126
+ }
127
+ if (!signal?.aborted) await sleep(idle ? idleMs : 0, { signal });
128
+ }
129
+ return { fetchCursor: cursor };
130
+ }
131
+
132
+ return { tickOnce, start, ensureSeeded };
133
+ }
134
+
135
+ export default createWakePump;
@@ -0,0 +1,80 @@
1
+ // Wake adapter registry for the Senti Wake-Up & Notification Bus (L2).
2
+ //
3
+ // Decouples the `sentid` daemon from individual host adapters: the daemon
4
+ // depends only on the LOCKED adapter interface --
5
+ // { hostName: string, wake(target[, deps]) -> Promise<{ok, hostName, ...}> }
6
+ // (optionally installWakeHook(opts))
7
+ // -- and concrete adapters (wake/claude.js, wake/codex.js, ...) are registered
8
+ // at wiring time. This means the daemon can be built and unit-tested against a
9
+ // fake adapter with no dependency on which adapter PRs have merged.
10
+
11
+ function assertValidAdapter(adapter) {
12
+ if (!adapter || typeof adapter !== "object") {
13
+ throw new TypeError("wake registry: adapter must be an object");
14
+ }
15
+ if (typeof adapter.hostName !== "string" || adapter.hostName.trim() === "") {
16
+ throw new TypeError("wake registry: adapter.hostName must be a non-empty string");
17
+ }
18
+ if (typeof adapter.wake !== "function") {
19
+ throw new TypeError(`wake registry: adapter "${adapter.hostName}" must expose a wake() function`);
20
+ }
21
+ return adapter;
22
+ }
23
+
24
+ /**
25
+ * Create a wake-adapter registry, optionally seeded with adapters.
26
+ *
27
+ * @param {Array<{hostName: string, wake: Function}>} [adapters]
28
+ */
29
+ export function createWakeRegistry(adapters = []) {
30
+ const map = new Map();
31
+
32
+ function hosts() {
33
+ return [...map.keys()];
34
+ }
35
+
36
+ function register(adapter) {
37
+ assertValidAdapter(adapter);
38
+ if (map.has(adapter.hostName)) {
39
+ throw new Error(`wake registry: adapter for host "${adapter.hostName}" is already registered`);
40
+ }
41
+ map.set(adapter.hostName, adapter);
42
+ return adapter;
43
+ }
44
+
45
+ function resolve(hostName) {
46
+ const adapter = map.get(hostName);
47
+ if (!adapter) {
48
+ throw new Error(
49
+ `wake registry: no adapter registered for host "${hostName}" (known: ${hosts().join(", ") || "none"})`
50
+ );
51
+ }
52
+ return adapter;
53
+ }
54
+
55
+ function has(hostName) {
56
+ return map.has(hostName);
57
+ }
58
+
59
+ if (!Array.isArray(adapters)) {
60
+ throw new TypeError("wake registry: adapters must be an array");
61
+ }
62
+ adapters.forEach(register);
63
+
64
+ return { register, resolve, has, hosts };
65
+ }
66
+
67
+ /**
68
+ * Wake an agent through the registry in one call. Forwards `deps` (e.g. an
69
+ * injected execFileImpl) to the resolved adapter's wake().
70
+ *
71
+ * @param {{resolve: Function}} registry
72
+ * @param {{ host: string, sessionId: string, message: string }} target
73
+ * @param {object} [deps]
74
+ */
75
+ export async function wakeVia(registry, { host, sessionId, message } = {}, deps = {}) {
76
+ const adapter = registry.resolve(host);
77
+ return adapter.wake({ sessionId, message }, deps);
78
+ }
79
+
80
+ export default createWakeRegistry;
@@ -0,0 +1,146 @@
1
+ // Wake target resolver for the sentid daemon (Wake-Up Bus L2 live-wiring).
2
+ //
3
+ // A sentid instance serves one local agent. This factory builds the
4
+ // resolveTarget(event) the dispatcher (dispatcher.js) calls to decide whether a
5
+ // session event should wake THAT agent, and if so with what host/sessionId.
6
+ //
7
+ // Targeting policy (returning null = "intentionally unroutable", which the
8
+ // dispatcher treats as a clean skip that advances the cursor):
9
+ // - only wake event TYPES count (a session_message), not acks/reactions/views;
10
+ // - never wake the agent on its OWN events (no self-wake loop);
11
+ // - wake when the message is directed to this agent, is a broadcast, or is an
12
+ // untargeted room message; a message directed to a DIFFERENT agent is null.
13
+ //
14
+ // Routing mirrors listener.js's eventMatchesAgent vocabulary, but is kept
15
+ // self-contained on purpose: importing listener.js would drag its whole
16
+ // transitive graph (auth/sync/`open`/...) into the wake daemon. The matcher
17
+ // here is small enough to own.
18
+
19
+ const BROADCAST_RECIPIENTS = new Set(["*", "all", "broadcast", "everyone", "anyone", "agents", "all-agents"]);
20
+ const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request"]);
21
+ const MAX_WAKE_MESSAGE_CHARS = 16_000;
22
+
23
+ function requireNonEmptyString(value, label) {
24
+ if (typeof value !== "string" || value.trim() === "") {
25
+ throw new TypeError(`resolve-target: ${label} must be a non-empty string`);
26
+ }
27
+ return value.trim();
28
+ }
29
+
30
+ function agentIdOf(event) {
31
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
32
+ const a = event?.agentId ?? event?.agent_id ?? event?.agent ?? payload.agentId ?? payload.agent_id;
33
+ if (a && typeof a === "object") return typeof a.id === "string" ? a.id : null;
34
+ return typeof a === "string" ? a : null;
35
+ }
36
+
37
+ function eventTypeOf(event) {
38
+ return event?.event || event?.type || event?.payload?.event || null;
39
+ }
40
+
41
+ function normalizeComparableId(value) {
42
+ return String(value || "")
43
+ .trim()
44
+ .toLowerCase()
45
+ .replace(/^@+/, "")
46
+ .replace(/[^a-z0-9._-]+/g, "-")
47
+ .replace(/^-+|-+$/g, "");
48
+ }
49
+
50
+ function addRecipientValue(out, value) {
51
+ if (value === undefined || value === null) return;
52
+ if (Array.isArray(value)) {
53
+ for (const item of value) addRecipientValue(out, item);
54
+ return;
55
+ }
56
+ if (value && typeof value === "object") {
57
+ addRecipientValue(out, value.id ?? value.agentId ?? value.agent_id ?? value.name);
58
+ return;
59
+ }
60
+ for (const token of String(value).split(/[\s,;]+/g)) {
61
+ const normalized = normalizeComparableId(token);
62
+ if (normalized) out.push(normalized);
63
+ }
64
+ }
65
+
66
+ // Collect recipient tokens from the shapes the session stream uses.
67
+ function recipientsOf(event) {
68
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
69
+ const out = [];
70
+ for (const src of [
71
+ event?.to,
72
+ event?.recipient,
73
+ event?.recipients,
74
+ event?.targetAgent,
75
+ event?.targetAgentId,
76
+ payload.to,
77
+ payload.recipient,
78
+ payload.recipients,
79
+ payload.targetAgent,
80
+ payload.targetAgentId,
81
+ ]) {
82
+ addRecipientValue(out, src);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // Mirrors listener.js eventMatchesAgent: broadcast flag, broadcast tokens,
88
+ // untargeted (no recipients) => match, or a directed match.
89
+ function matchesAgent(event, selfLower) {
90
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
91
+ if (event?.broadcast === true || payload.broadcast === true) return true;
92
+ const recipients = recipientsOf(event);
93
+ if (recipients.length === 0) return true;
94
+ return recipients.some((r) => BROADCAST_RECIPIENTS.has(r) || r === selfLower);
95
+ }
96
+
97
+ function defaultFormatMessage(event, agentId) {
98
+ const author = agentIdOf(event) || "unknown";
99
+ const text = event?.payload?.message ?? event?.message ?? "";
100
+ const body = typeof text === "string" ? text : "";
101
+ const head = `Senti wake for ${agentId}: new message from ${author}.`;
102
+ const combined = body ? `${head}\n\n${body}` : head;
103
+ return combined.length > MAX_WAKE_MESSAGE_CHARS ? combined.slice(0, MAX_WAKE_MESSAGE_CHARS) : combined;
104
+ }
105
+
106
+ /**
107
+ * @param {object} opts
108
+ * @param {string} opts.agentId the local agent this daemon wakes (e.g. "claude-mythos")
109
+ * @param {string} opts.host host adapter name (e.g. "claude")
110
+ * @param {string} opts.sessionId resume session id passed to the host adapter
111
+ * @param {Set<string>} [opts.wakeEventTypes]
112
+ * @param {(event:object, agentId:string) => string} [opts.formatMessage]
113
+ * @returns {(event:object) => ({host:string, sessionId:string, message:string}|null)}
114
+ */
115
+ export function createResolveTarget({
116
+ agentId,
117
+ host,
118
+ sessionId,
119
+ wakeEventTypes = DEFAULT_WAKE_EVENT_TYPES,
120
+ formatMessage = defaultFormatMessage,
121
+ } = {}) {
122
+ const selfId = requireNonEmptyString(agentId, "agentId");
123
+ const hostName = requireNonEmptyString(host, "host");
124
+ const resumeId = requireNonEmptyString(sessionId, "sessionId");
125
+ const selfLower = normalizeComparableId(selfId);
126
+
127
+ return function resolveTarget(event) {
128
+ // Only wake on real message events; acks/reactions/views/system are skips.
129
+ const type = eventTypeOf(event);
130
+ if (!type || !wakeEventTypes.has(type)) return null;
131
+
132
+ // No self-wake: never wake the agent on its own message. Normalize both
133
+ // sides (handles @-prefix / casing / punctuation) so the loop-guard can't
134
+ // be slipped by a non-canonical author id.
135
+ const author = agentIdOf(event);
136
+ if (author && normalizeComparableId(author) === selfLower) return null;
137
+
138
+ // Routing: directed-to-me / broadcast / untargeted-room wakes us; a message
139
+ // aimed at a different specific agent is intentionally unroutable.
140
+ if (!matchesAgent(event, selfLower)) return null;
141
+
142
+ return { host: hostName, sessionId: resumeId, message: formatMessage(event, selfId) };
143
+ };
144
+ }
145
+
146
+ export default createResolveTarget;
@@ -0,0 +1,103 @@
1
+ // sentid runtime entrypoint — assembles the Wake-Up Bus (L2) into a daemon.
2
+ //
3
+ // Wires the merged pieces into one local-agent waker:
4
+ // registry(host adapters) + resolveTarget(routing) + dispatcher(at-least-once
5
+ // + DLQ + RESUME cursor) + pump(live fetch -> dispatch -> persist).
6
+ // A sentid instance serves ONE local agent (agentId) and wakes it (host +
7
+ // resumeSessionId) when the session stream has a message for it.
8
+ //
9
+ // The real event source is pollSessionEvents (sync.js), but it is LAZY-imported
10
+ // inside the default poll adapter so this module stays unit-testable: importing
11
+ // sync.js at module load would drag the heavy auth/`open` graph in. Tests inject
12
+ // `pollImpl` (and fake adapters) and the lazy import never fires.
13
+
14
+ import { createWakeRegistry } from "./registry.js";
15
+ import { createResolveTarget } from "./resolve-target.js";
16
+ import { createWakeDispatcher } from "./dispatcher.js";
17
+ import { createWakePump } from "./pump.js";
18
+ import claudeWakeAdapter from "./claude.js";
19
+ import codexWakeAdapter from "./codex.js";
20
+
21
+ function requireNonEmptyString(value, label) {
22
+ if (typeof value !== "string" || value.trim() === "") {
23
+ throw new TypeError(`sentid: ${label} must be a non-empty string`);
24
+ }
25
+ return value.trim();
26
+ }
27
+
28
+ // Adapt pollSessionEvents(sessionId, {since, targetPath}) -> the pump's
29
+ // pollEvents(sessionId, {afterCursor}) -> { events, cursor } contract. A failed
30
+ // poll (ok:false: circuit open / auth / network) yields no events, which the
31
+ // pump treats as idle and retries on the next tick — never a thrown crash.
32
+ function makePollEvents(pollImpl, targetPath) {
33
+ let poll = pollImpl;
34
+ return async (sessionId, { afterCursor = null } = {}) => {
35
+ if (!poll) {
36
+ ({ pollSessionEvents: poll } = await import("../sync.js"));
37
+ }
38
+ const result = (await poll(sessionId, { since: afterCursor, targetPath })) || {};
39
+ return {
40
+ events: Array.isArray(result.events) ? result.events : [],
41
+ cursor: typeof result.cursor === "string" ? result.cursor : afterCursor,
42
+ };
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {object} opts
48
+ * @param {string} opts.sessionId Senti session to watch
49
+ * @param {string} opts.agentId local agent this daemon wakes (e.g. "claude-mythos")
50
+ * @param {string} opts.host host adapter name (e.g. "claude")
51
+ * @param {string} opts.resumeSessionId host session id to resume on wake
52
+ * @param {Array} [opts.adapters] defaults to [claudeWakeAdapter, codexWakeAdapter]
53
+ * @param {string} [opts.targetPath]
54
+ * @param {number} [opts.maxAttempts] dispatcher retry budget before DLQ
55
+ * @param {number} [opts.idleMs] pump idle backoff
56
+ * @param {Function} [opts.deadLetter] durable DLQ sink
57
+ * @param {Function} [opts.logger]
58
+ * @param {Function} [opts.pollImpl] injected poller (default: lazy pollSessionEvents)
59
+ * @param {object} [opts.wakeDeps] deps forwarded to adapter.wake() (e.g. execFileImpl)
60
+ */
61
+ export function createSentid({
62
+ sessionId,
63
+ agentId,
64
+ host,
65
+ resumeSessionId,
66
+ adapters = [claudeWakeAdapter, codexWakeAdapter],
67
+ targetPath,
68
+ maxAttempts,
69
+ idleMs,
70
+ deadLetter,
71
+ logger,
72
+ pollImpl,
73
+ wakeDeps = {},
74
+ } = {}) {
75
+ const sid = requireNonEmptyString(sessionId, "sessionId");
76
+ const localAgent = requireNonEmptyString(agentId, "agentId");
77
+ const hostName = requireNonEmptyString(host, "host");
78
+ const resumeId = requireNonEmptyString(resumeSessionId, "resumeSessionId");
79
+
80
+ const registry = createWakeRegistry(adapters);
81
+ if (!registry.has(hostName)) {
82
+ throw new Error(`sentid: no adapter registered for host "${hostName}" (have: ${registry.hosts().join(", ") || "none"})`);
83
+ }
84
+
85
+ const resolveTarget = createResolveTarget({ agentId: localAgent, host: hostName, sessionId: resumeId });
86
+ const dispatcher = createWakeDispatcher({ registry, resolveTarget, maxAttempts, deadLetter, logger });
87
+ const pollEvents = makePollEvents(pollImpl, targetPath);
88
+ const pump = createWakePump({ sessionId: sid, dispatcher, pollEvents, targetPath, idleMs, logger });
89
+
90
+ return {
91
+ sessionId: sid,
92
+ agentId: localAgent,
93
+ host: hostName,
94
+ registry,
95
+ dispatcher,
96
+ pump,
97
+ start: (o = {}) => pump.start({ ...o, deps: wakeDeps }),
98
+ tickOnce: (o = {}) => pump.tickOnce({ ...o, deps: wakeDeps }),
99
+ getCursor: () => dispatcher.getCursor(),
100
+ };
101
+ }
102
+
103
+ export default createSentid;