@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.
- package/LICENSE +201 -0
- package/README.md +24 -0
- package/package.json +58 -0
- package/src/acknowledgement.js +141 -0
- package/src/callback-handler.js +167 -0
- package/src/callback-payload.js +79 -0
- package/src/callback-registry.js +88 -0
- package/src/discussion-context.js +126 -0
- package/src/dispatch.js +66 -0
- package/src/dispatcher.js +128 -0
- package/src/elapsed-scheduler.js +82 -0
- package/src/history.js +14 -0
- package/src/index.js +38 -0
- package/src/progress-ticker.js +62 -0
- package/src/prompt.js +30 -0
- package/src/rate-limit.js +45 -0
- package/src/resume-scheduler.js +254 -0
- package/src/server.js +105 -0
- package/src/triggers.js +105 -0
|
@@ -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
|
+
}
|
package/src/dispatch.js
ADDED
|
@@ -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
|
+
}
|