@forwardimpact/libbridge 0.1.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,88 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
4
+
5
+ /**
6
+ * In-memory registry of pending bridge → workflow callbacks. Hosts persist
7
+ * the (token, correlationId) pairs via DiscussionContextStore so the registry
8
+ * can be rehydrated after a restart; this class only owns the live token →
9
+ * metadata mapping and TTL sweep.
10
+ */
11
+ export class CallbackRegistry {
12
+ #ttlMs;
13
+ #entries = new Map();
14
+
15
+ /**
16
+ * @param {object} [options]
17
+ * @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
18
+ */
19
+ constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
20
+ this.#ttlMs = ttlMs;
21
+ }
22
+
23
+ /** @returns {number} */
24
+ get size() {
25
+ return this.#entries.size;
26
+ }
27
+
28
+ /**
29
+ * @param {string} correlationId
30
+ * @param {object} [meta] - Caller-defined metadata stored alongside the token
31
+ * @returns {string} The newly issued callback token
32
+ */
33
+ register(correlationId, meta = {}) {
34
+ if (typeof correlationId !== "string" || !correlationId) {
35
+ throw new Error("correlationId is required");
36
+ }
37
+ const token = randomUUID();
38
+ this.#entries.set(token, {
39
+ correlationId,
40
+ meta,
41
+ createdAt: Date.now(),
42
+ });
43
+ return token;
44
+ }
45
+
46
+ /**
47
+ * Atomic lookup + delete. Returns null if the token is unknown.
48
+ * @param {string} token
49
+ * @returns {{correlationId: string, meta: object, createdAt: number} | null}
50
+ */
51
+ consume(token) {
52
+ const entry = this.#entries.get(token);
53
+ if (!entry) return null;
54
+ this.#entries.delete(token);
55
+ return entry;
56
+ }
57
+
58
+ /**
59
+ * Returns a shallow clone of the stored metadata for a token without
60
+ * consuming it. Cloning prevents callers from corrupting internal state
61
+ * via the returned reference; diagnostic code paths that need to read
62
+ * `correlationId`, `meta`, or `createdAt` work unchanged.
63
+ * @param {string} token
64
+ * @returns {{correlationId: string, meta: object, createdAt: number} | null}
65
+ */
66
+ peek(token) {
67
+ const entry = this.#entries.get(token);
68
+ if (!entry) return null;
69
+ return { ...entry };
70
+ }
71
+
72
+ /**
73
+ * Drop entries older than ttlMs. Caller drives the clock so tests stay
74
+ * deterministic.
75
+ * @param {number} [now]
76
+ * @returns {number} Number of entries evicted
77
+ */
78
+ sweep(now = Date.now()) {
79
+ let evicted = 0;
80
+ for (const [token, entry] of this.#entries) {
81
+ if (now - entry.createdAt > this.#ttlMs) {
82
+ this.#entries.delete(token);
83
+ evicted++;
84
+ }
85
+ }
86
+ return evicted;
87
+ }
88
+ }
@@ -0,0 +1,126 @@
1
+ import { BufferedIndex } from "@forwardimpact/libindex";
2
+
3
+ const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
4
+ const DEFAULT_MAX_BUFFER_SIZE = 1_000;
5
+ const DEFAULT_CONVERSATION_TTL_MS = 24 * 60 * 60 * 1000;
6
+ const DEFAULT_SWEEP_INTERVAL_MS = 60_000;
7
+
8
+ /**
9
+ * Persisted thread state keyed by `(channel, discussion_id)`. Both
10
+ * `services/ghbridge` and `services/msbridge` write into the same store so
11
+ * the channel-agnostic `kata-dispatch.yml` workflow can resume conversations
12
+ * from either side.
13
+ *
14
+ * Record shape:
15
+ * {
16
+ * id: "<channel>:<discussion_id>",
17
+ * channel: "github-discussions" | "msteams",
18
+ * discussion_id: string,
19
+ * history: Array<{role: "user"|"assistant", text: string}>,
20
+ * participants: Array<{name, kind: "agent"|"human", external_id?, metadata?}>,
21
+ * open_rfcs: Record<correlationId, {trigger, opened_at, history_index_at_open}>,
22
+ * lead: string,
23
+ * pending_callbacks: Record<token, correlationId>,
24
+ * last_active_at: number,
25
+ * }
26
+ *
27
+ * @augments BufferedIndex
28
+ */
29
+ export class DiscussionContextStore extends BufferedIndex {
30
+ #conversationTtlMs;
31
+ #sweepTimer;
32
+
33
+ /**
34
+ * @param {import("@forwardimpact/libstorage").StorageInterface} storage
35
+ * @param {object} [options]
36
+ * @param {string} [options.indexKey] - JSONL file name (default `discussions.jsonl`)
37
+ * @param {number} [options.flushIntervalMs]
38
+ * @param {number} [options.maxBufferSize]
39
+ * @param {number} [options.conversationTtlMs] - Eviction window (default 24h)
40
+ * @param {number} [options.sweepIntervalMs] - Sweep cadence (default 60s)
41
+ */
42
+ constructor(
43
+ storage,
44
+ {
45
+ indexKey = "discussions.jsonl",
46
+ flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
47
+ maxBufferSize = DEFAULT_MAX_BUFFER_SIZE,
48
+ conversationTtlMs = DEFAULT_CONVERSATION_TTL_MS,
49
+ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
50
+ } = {},
51
+ ) {
52
+ super(storage, indexKey, {
53
+ flush_interval: flushIntervalMs,
54
+ max_buffer_size: maxBufferSize,
55
+ });
56
+ this.#conversationTtlMs = conversationTtlMs;
57
+ this.#sweepTimer = setInterval(
58
+ () => this.#sweep(Date.now()),
59
+ sweepIntervalMs,
60
+ );
61
+ this.#sweepTimer.unref();
62
+ }
63
+
64
+ /**
65
+ * Compose the `id` field for a `(channel, discussion_id)` pair.
66
+ * @param {string} channel
67
+ * @param {string} discussionId
68
+ * @returns {string}
69
+ */
70
+ static keyOf(channel, discussionId) {
71
+ return `${channel}:${discussionId}`;
72
+ }
73
+
74
+ /**
75
+ * @param {string} channel
76
+ * @param {string} discussionId
77
+ * @returns {Promise<object | null>}
78
+ */
79
+ async loadByChannel(channel, discussionId) {
80
+ if (!this.loaded) await this.loadData();
81
+ const id = DiscussionContextStore.keyOf(channel, discussionId);
82
+ return this.index.get(id) ?? null;
83
+ }
84
+
85
+ /**
86
+ * Stop the periodic sweep timer. Called on host shutdown alongside
87
+ * `shutdown()` to release the interval.
88
+ */
89
+ stopSweep() {
90
+ if (this.#sweepTimer) {
91
+ clearInterval(this.#sweepTimer);
92
+ this.#sweepTimer = null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Flush buffered writes and stop the sweep timer.
98
+ * @returns {Promise<void>}
99
+ */
100
+ async shutdown() {
101
+ this.stopSweep();
102
+ await super.shutdown();
103
+ }
104
+
105
+ /**
106
+ * Evict records whose `last_active_at` is older than `conversationTtlMs`.
107
+ * Caller-driven `now` keeps unit tests deterministic.
108
+ * @param {number} now
109
+ * @returns {number}
110
+ */
111
+ sweepNow(now) {
112
+ return this.#sweep(now);
113
+ }
114
+
115
+ #sweep(now) {
116
+ let evicted = 0;
117
+ for (const [id, record] of this.index) {
118
+ const lastActive = record?.last_active_at ?? 0;
119
+ if (now - lastActive > this.#conversationTtlMs) {
120
+ this.index.delete(id);
121
+ evicted++;
122
+ }
123
+ }
124
+ return evicted;
125
+ }
126
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Trigger a GitHub Actions `workflow_dispatch` for the channel-agnostic
3
+ * Kata dispatch workflow. Both `services/ghbridge` and `services/msbridge`
4
+ * route through this helper so the request body and headers stay byte-
5
+ * identical across bridges.
6
+ *
7
+ * Only includes `discussion_id` and `resume_context` in the `inputs` body
8
+ * when defined, so callers that omit them stay byte-identical to the legacy
9
+ * msteams dispatcher.
10
+ *
11
+ * @param {object} params
12
+ * @param {string} params.workflowFile - Workflow filename, e.g. `"kata-dispatch.yml"`
13
+ * @param {string} [params.ref] - Git ref to dispatch against (default `"main"`)
14
+ * @param {string} params.repo - `"owner/repo"`
15
+ * @param {string} params.token - GitHub installation/access token
16
+ * @param {string} params.prompt - The facilitator prompt
17
+ * @param {string} params.callbackUrl - Where the workflow posts the reply
18
+ * @param {string} params.correlationId - UUID linking dispatch → callback
19
+ * @param {string} [params.discussionId] - For trace linkage in libeval
20
+ * @param {string} [params.resumeContext] - JSON string carried across resumes
21
+ * @returns {Promise<void>}
22
+ */
23
+ export async function dispatchWorkflow({
24
+ workflowFile,
25
+ ref = "main",
26
+ repo,
27
+ token,
28
+ prompt,
29
+ callbackUrl,
30
+ correlationId,
31
+ discussionId,
32
+ resumeContext,
33
+ }) {
34
+ if (!workflowFile) throw new Error("workflowFile is required");
35
+ if (!repo) throw new Error("repo is required");
36
+ if (!token) throw new Error("token is required");
37
+
38
+ const inputs = {
39
+ prompt,
40
+ callback_url: callbackUrl,
41
+ correlation_id: correlationId,
42
+ };
43
+ if (discussionId !== undefined) inputs.discussion_id = discussionId;
44
+ if (resumeContext !== undefined) inputs.resume_context = resumeContext;
45
+
46
+ const url = `https://api.github.com/repos/${repo}/actions/workflows/${workflowFile}/dispatches`;
47
+ const res = await fetch(url, {
48
+ method: "POST",
49
+ headers: {
50
+ Authorization: `Bearer ${token}`,
51
+ Accept: "application/vnd.github+json",
52
+ "X-GitHub-Api-Version": "2022-11-28",
53
+ "Content-Type": "application/json",
54
+ },
55
+ body: JSON.stringify({ ref, inputs }),
56
+ });
57
+
58
+ if (!res.ok) {
59
+ // GitHub's error body sometimes echoes the dispatched inputs, including
60
+ // the callback URL which carries a single-use token. Surface only the
61
+ // status — operators can inspect the run log if they need more.
62
+ throw new Error(
63
+ `workflow_dispatch failed: ${res.status} ${res.statusText}`,
64
+ );
65
+ }
66
+ }
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { dispatchWorkflow } from "./dispatch.js";
4
+ import { appendHistory } from "./history.js";
5
+
6
+ /**
7
+ * The standard "dispatch dance" both bridges perform: generate a
8
+ * correlation ID, register the callback token, start the acknowledgement
9
+ * (if `ackTarget` is supplied), fire the kata-dispatch workflow, append
10
+ * history, push the dispatch timestamp, and flush the store. On failure
11
+ * the acknowledgement is finished and the callback registration is rolled
12
+ * back before the error rethrows.
13
+ *
14
+ * The caller still owns: loading/creating the context, checking the rate
15
+ * limiter, and deciding the user-facing action when `dispatch()` throws.
16
+ *
17
+ * @example
18
+ * const { token, correlationId } = await dispatcher.dispatch({
19
+ * ctx,
20
+ * prompt,
21
+ * ackTarget: { subjectId: nodeId },
22
+ * historyText: text,
23
+ * callbackMeta: { discussionId },
24
+ * workflowInputs: { discussionId },
25
+ * });
26
+ */
27
+ export class Dispatcher {
28
+ #callbacks;
29
+ #ack;
30
+ #store;
31
+ #callbackBaseUrl;
32
+ #workflowFile;
33
+ #githubRepo;
34
+ #getGithubToken;
35
+
36
+ /**
37
+ * @param {object} options
38
+ * @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
39
+ * @param {import("./acknowledgement.js").Acknowledgement} options.ack
40
+ * @param {import("./discussion-context.js").DiscussionContextStore} options.store
41
+ * @param {string} options.callbackBaseUrl - Already normalised
42
+ * @param {string} options.workflowFile
43
+ * @param {string} options.githubRepo
44
+ * @param {() => Promise<string> | string} options.getGithubToken
45
+ */
46
+ constructor({
47
+ callbacks,
48
+ ack,
49
+ store,
50
+ callbackBaseUrl,
51
+ workflowFile,
52
+ githubRepo,
53
+ getGithubToken,
54
+ }) {
55
+ if (!callbacks) throw new Error("callbacks is required");
56
+ if (!ack) throw new Error("ack is required");
57
+ if (!store) throw new Error("store is required");
58
+ if (typeof callbackBaseUrl !== "string") {
59
+ throw new Error("callbackBaseUrl is required");
60
+ }
61
+ if (!workflowFile) throw new Error("workflowFile is required");
62
+ if (!githubRepo) throw new Error("githubRepo is required");
63
+ if (typeof getGithubToken !== "function") {
64
+ throw new Error("getGithubToken is required");
65
+ }
66
+ this.#callbacks = callbacks;
67
+ this.#ack = ack;
68
+ this.#store = store;
69
+ this.#callbackBaseUrl = callbackBaseUrl;
70
+ this.#workflowFile = workflowFile;
71
+ this.#githubRepo = githubRepo;
72
+ this.#getGithubToken = getGithubToken;
73
+ }
74
+
75
+ /**
76
+ * @param {object} args
77
+ * @param {object} args.ctx - Discussion context record (mutated)
78
+ * @param {string} args.prompt
79
+ * @param {object} args.callbackMeta - Stored on the callback token
80
+ * @param {unknown} [args.ackTarget] - If omitted, no acknowledgement is started
81
+ * @param {string} [args.historyText] - Appended to ctx.history as the user turn on success
82
+ * @param {object} [args.workflowInputs] - Extra fields for `dispatchWorkflow`
83
+ * @returns {Promise<{token: string, correlationId: string}>}
84
+ */
85
+ async dispatch({
86
+ ctx,
87
+ prompt,
88
+ callbackMeta,
89
+ ackTarget,
90
+ historyText,
91
+ workflowInputs,
92
+ }) {
93
+ if (!ctx) throw new Error("ctx is required");
94
+ if (typeof prompt !== "string") throw new Error("prompt is required");
95
+
96
+ const correlationId = randomUUID();
97
+ const token = this.#callbacks.register(correlationId, callbackMeta ?? {});
98
+ ctx.pending_callbacks[token] = correlationId;
99
+ const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${token}`;
100
+
101
+ if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
102
+ try {
103
+ const ghToken = await this.#getGithubToken();
104
+ await dispatchWorkflow({
105
+ workflowFile: this.#workflowFile,
106
+ repo: this.#githubRepo,
107
+ token: ghToken,
108
+ prompt,
109
+ callbackUrl,
110
+ correlationId,
111
+ ...(workflowInputs ?? {}),
112
+ });
113
+ if (historyText !== undefined) {
114
+ appendHistory(ctx.history, { role: "user", text: historyText });
115
+ }
116
+ ctx.dispatches.push(Date.now());
117
+ ctx.last_active_at = Date.now();
118
+ await this.#store.add(ctx);
119
+ await this.#store.flush();
120
+ return { token, correlationId };
121
+ } catch (err) {
122
+ if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
123
+ this.#callbacks.consume(token);
124
+ delete ctx.pending_callbacks[token];
125
+ throw err;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,82 @@
1
+ const CHUNK_CAP_MS = 7 * 24 * 60 * 60 * 1000;
2
+
3
+ /**
4
+ * In-memory scheduler for `elapsed` resume triggers. JS `setTimeout`'s
5
+ * practical cap is ~24.8 days; this scheduler chunks longer durations
6
+ * into <= 7-day rearm segments so future >24-day windows work, while
7
+ * the persistent `due_at` in `open_rfcs` is the source of truth across
8
+ * restarts. Channel-agnostic — used by `ResumeScheduler` for both
9
+ * bridges.
10
+ */
11
+ export class ElapsedScheduler {
12
+ #timers = new Map();
13
+ #onFire;
14
+ #onError;
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {(correlationId: string) => Promise<void>} options.onFire - Invoked when the deadline passes.
19
+ * @param {(err: Error, correlationId: string) => void} [options.onError] - Invoked when `onFire` rejects.
20
+ */
21
+ constructor({ onFire, onError = () => {} }) {
22
+ if (typeof onFire !== "function") throw new Error("onFire is required");
23
+ this.#onFire = onFire;
24
+ this.#onError = onError;
25
+ }
26
+
27
+ /** @returns {number} */
28
+ get size() {
29
+ return this.#timers.size;
30
+ }
31
+
32
+ /**
33
+ * Schedule a timer that fires at `dueAt` (absolute ms epoch).
34
+ * Replaces any existing timer for `correlationId`.
35
+ *
36
+ * @param {string} correlationId
37
+ * @param {number} dueAt
38
+ */
39
+ schedule(correlationId, dueAt) {
40
+ this.cancel(correlationId);
41
+ const remaining = dueAt - Date.now();
42
+ if (remaining <= 0) {
43
+ this.#fire(correlationId);
44
+ return;
45
+ }
46
+ const delay = Math.min(remaining, CHUNK_CAP_MS);
47
+ const timer = setTimeout(() => {
48
+ this.#timers.delete(correlationId);
49
+ if (remaining > CHUNK_CAP_MS) {
50
+ this.schedule(correlationId, dueAt);
51
+ } else {
52
+ this.#fire(correlationId);
53
+ }
54
+ }, delay);
55
+ timer.unref?.();
56
+ this.#timers.set(correlationId, timer);
57
+ }
58
+
59
+ /**
60
+ * Cancel the scheduled fire for `correlationId`. No-op if absent.
61
+ * @param {string} correlationId
62
+ */
63
+ cancel(correlationId) {
64
+ const timer = this.#timers.get(correlationId);
65
+ if (timer) {
66
+ clearTimeout(timer);
67
+ this.#timers.delete(correlationId);
68
+ }
69
+ }
70
+
71
+ /** Cancel every scheduled timer. */
72
+ clear() {
73
+ for (const timer of this.#timers.values()) clearTimeout(timer);
74
+ this.#timers.clear();
75
+ }
76
+
77
+ #fire(correlationId) {
78
+ this.#onFire(correlationId).catch((err) =>
79
+ this.#onError(err, correlationId),
80
+ );
81
+ }
82
+ }
package/src/history.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Append a message to a bounded history, dropping the oldest entries when
3
+ * the cap is exceeded. Mutates `history` in place to match the legacy
4
+ * msteams bridge behaviour preserved in services/msbridge.
5
+ *
6
+ * @param {Array<{role: "user"|"assistant", text: string}>} history
7
+ * @param {{role: "user"|"assistant", text: string}} entry
8
+ * @param {object} [options]
9
+ * @param {number} [options.maxEntries] - Default 10
10
+ */
11
+ export function appendHistory(history, entry, { maxEntries = 10 } = {}) {
12
+ history.push(entry);
13
+ while (history.length > maxEntries) history.shift();
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Canonical configuration contract every bridge consumes. Channel-specific
3
+ * fields (e.g. `app_webhook_secret` on ghbridge, `msAppId()` on msbridge)
4
+ * extend this — see each bridge's README for the channel-specific surface.
5
+ *
6
+ * @typedef {object} BridgeConfig
7
+ * @property {string} host - Bind host (typically "127.0.0.1" or "0.0.0.0")
8
+ * @property {number} port - Bind port; 0 selects a free port
9
+ * @property {string} callback_base_url - Public URL the dispatched workflow posts back to
10
+ * @property {string} github_repo - "owner/repo" hosting the kata-dispatch workflow
11
+ */
12
+
13
+ export { createBridgeServer } from "./server.js";
14
+ export { CallbackRegistry } from "./callback-registry.js";
15
+ export { buildPrompt } from "./prompt.js";
16
+ export { appendHistory } from "./history.js";
17
+ export { RateLimiter } from "./rate-limit.js";
18
+ export { dispatchWorkflow } from "./dispatch.js";
19
+ export { DiscussionContextStore } from "./discussion-context.js";
20
+ export { ProgressTicker } from "./progress-ticker.js";
21
+ export {
22
+ Acknowledgement,
23
+ DEFAULT_TYPING_VERBS,
24
+ } from "./acknowledgement.js";
25
+ export { Dispatcher } from "./dispatcher.js";
26
+ export {
27
+ CallbackHandlerError,
28
+ createCallbackHandler,
29
+ } from "./callback-handler.js";
30
+ export { ElapsedScheduler } from "./elapsed-scheduler.js";
31
+ export { ResumeScheduler } from "./resume-scheduler.js";
32
+ export {
33
+ MAX_FIELD_LENGTH,
34
+ newDiscussionContext,
35
+ normalizeBaseUrl,
36
+ validateCallbackPayload,
37
+ } from "./callback-payload.js";
38
+ export { evaluateTrigger, parseIsoDuration } from "./triggers.js";
@@ -0,0 +1,62 @@
1
+ const DEFAULT_INTERVAL_MS = 25_000;
2
+
3
+ /**
4
+ * Channel-agnostic ticker. Hosts call `start(token, tick)` after dispatching
5
+ * a workflow; `tick()` runs every `intervalMs` until the host calls
6
+ * `stop(token)` or until `tick()` rejects (which auto-stops the ticker —
7
+ * matching the legacy msteams ticker behaviour preserved in services/msbridge).
8
+ *
9
+ * Per-channel rendering (Teams typing activity, GitHub reaction) lives in
10
+ * the adapter; this class only owns the timer lifecycle.
11
+ */
12
+ export class ProgressTicker {
13
+ #intervalMs;
14
+ #timers = new Map();
15
+
16
+ /**
17
+ * @param {object} [options]
18
+ * @param {number} [options.intervalMs] - Tick cadence in ms (default 12_000)
19
+ */
20
+ constructor({ intervalMs = DEFAULT_INTERVAL_MS } = {}) {
21
+ this.#intervalMs = intervalMs;
22
+ }
23
+
24
+ /** @returns {number} */
25
+ get size() {
26
+ return this.#timers.size;
27
+ }
28
+
29
+ /**
30
+ * Start ticking for `token`. Replaces any existing ticker on the same
31
+ * token. Errors thrown by `tick` are swallowed and stop the ticker.
32
+ * @param {string} token
33
+ * @param {() => Promise<void> | void} tick
34
+ */
35
+ start(token, tick) {
36
+ if (typeof tick !== "function") {
37
+ throw new Error("tick must be a function");
38
+ }
39
+ this.stop(token);
40
+ const timer = setInterval(async () => {
41
+ try {
42
+ await tick();
43
+ } catch {
44
+ this.stop(token);
45
+ }
46
+ }, this.#intervalMs);
47
+ timer.unref?.();
48
+ this.#timers.set(token, timer);
49
+ }
50
+
51
+ /**
52
+ * Stop ticking for `token`. No-op if the token has no active ticker.
53
+ * @param {string} token
54
+ */
55
+ stop(token) {
56
+ const timer = this.#timers.get(token);
57
+ if (timer) {
58
+ clearInterval(timer);
59
+ this.#timers.delete(token);
60
+ }
61
+ }
62
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Build a facilitator prompt from the current message text and a rolling
3
+ * conversation history. History is bounded to the last `maxExchanges`
4
+ * exchanges (2x entries) and the total prompt size is capped at `charCap`
5
+ * characters by dropping the oldest history entries until it fits.
6
+ *
7
+ * @param {string} text - The current user message
8
+ * @param {Array<{role: "user"|"assistant", text: string}>} history - Prior
9
+ * exchanges in chronological order. Most recent last.
10
+ * @param {object} [options]
11
+ * @param {number} [options.maxExchanges] - Default 5 (10 entries kept)
12
+ * @param {number} [options.charCap] - Default 4000 characters total
13
+ * @returns {string}
14
+ */
15
+ export function buildPrompt(
16
+ text,
17
+ history,
18
+ { maxExchanges = 5, charCap = 4000 } = {},
19
+ ) {
20
+ const trimmed = history.slice(-maxExchanges * 2);
21
+ while (trimmed.length > 0) {
22
+ const block = trimmed
23
+ .map((h) => `${h.role === "user" ? "User" : "Agent"}: ${h.text}`)
24
+ .join("\n\n");
25
+ const composed = `Prior conversation:\n${block}\n\nCurrent message: ${text}`;
26
+ if (composed.length <= charCap) return composed;
27
+ trimmed.shift();
28
+ }
29
+ return text;
30
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sliding-window rate limiter. Operates on a caller-owned `dispatches: number[]`
3
+ * array of timestamps and returns a structured result so callers can both
4
+ * gate dispatch and surface retry-after hints.
5
+ */
6
+ export class RateLimiter {
7
+ #windowMs;
8
+ #max;
9
+
10
+ /**
11
+ * @param {object} [options]
12
+ * @param {number} [options.windowMs] - Sliding window length in ms (default: 60_000)
13
+ * @param {number} [options.max] - Max dispatches allowed in the window (default: 5)
14
+ */
15
+ constructor({ windowMs = 60_000, max = 5 } = {}) {
16
+ this.#windowMs = windowMs;
17
+ this.#max = max;
18
+ }
19
+
20
+ /**
21
+ * Evaluate rate-limit state for a thread. Mutates `dispatches` to drop
22
+ * timestamps outside the window before measuring.
23
+ *
24
+ * @param {string} threadId - For diagnostic/host bookkeeping; unused here.
25
+ * @param {number[]} dispatches - Caller-owned timestamps in ms epoch.
26
+ * @returns {{ allowed: boolean, retryAfterMs?: number }}
27
+ */
28
+ check(threadId, dispatches) {
29
+ if (!Array.isArray(dispatches)) {
30
+ throw new Error("dispatches must be an array");
31
+ }
32
+ const now = Date.now();
33
+ const cutoff = now - this.#windowMs;
34
+ let i = 0;
35
+ while (i < dispatches.length && dispatches[i] < cutoff) i++;
36
+ if (i > 0) dispatches.splice(0, i);
37
+
38
+ if (dispatches.length < this.#max) {
39
+ return { allowed: true };
40
+ }
41
+ const oldest = dispatches[0];
42
+ const retryAfterMs = Math.max(0, oldest + this.#windowMs - now);
43
+ return { allowed: false, retryAfterMs };
44
+ }
45
+ }