@gakr-gakr/msteams 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/api.ts +3 -0
- package/autobot.plugin.json +15 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +20 -0
- package/package.json +72 -0
- package/runtime-api.ts +66 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.ts +348 -0
- package/src/attachments/download.ts +328 -0
- package/src/attachments/graph.ts +489 -0
- package/src/attachments/html.ts +122 -0
- package/src/attachments/payload.ts +14 -0
- package/src/attachments/remote-media.ts +86 -0
- package/src/attachments/shared.ts +655 -0
- package/src/attachments/types.ts +47 -0
- package/src/attachments.ts +18 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.ts +1176 -0
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +40 -0
- package/src/conversation-store-fs.ts +149 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +51 -0
- package/src/conversation-store.ts +71 -0
- package/src/directory-live.ts +111 -0
- package/src/doctor.ts +27 -0
- package/src/errors.ts +270 -0
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +113 -0
- package/src/feedback-reflection.ts +271 -0
- package/src/file-consent-helpers.ts +115 -0
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.ts +223 -0
- package/src/graph-chat.ts +36 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.ts +531 -0
- package/src/graph-users.ts +29 -0
- package/src/graph.ts +308 -0
- package/src/inbound.ts +148 -0
- package/src/index.ts +4 -0
- package/src/media-helpers.ts +105 -0
- package/src/mentions.ts +114 -0
- package/src/messenger.ts +608 -0
- package/src/monitor-handler/access.ts +136 -0
- package/src/monitor-handler/inbound-media.ts +180 -0
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.test-support.ts +102 -0
- package/src/monitor-handler/message-handler.ts +1015 -0
- package/src/monitor-handler/reaction-handler.ts +124 -0
- package/src/monitor-handler/thread-session.ts +30 -0
- package/src/monitor-handler.ts +538 -0
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +6 -0
- package/src/monitor.ts +476 -0
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.token.ts +162 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.ts +198 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.ts +121 -0
- package/src/policy.ts +245 -0
- package/src/polls-store-memory.ts +32 -0
- package/src/polls.ts +312 -0
- package/src/presentation.ts +93 -0
- package/src/probe.ts +132 -0
- package/src/reply-dispatcher.ts +523 -0
- package/src/reply-stream-controller.ts +334 -0
- package/src/resolve-allowlist.ts +309 -0
- package/src/revoked-context.ts +17 -0
- package/src/runtime.ts +12 -0
- package/src/sdk-types.ts +59 -0
- package/src/sdk.ts +916 -0
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +7 -0
- package/src/send-context.ts +269 -0
- package/src/send.ts +697 -0
- package/src/sent-message-cache.ts +174 -0
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +162 -0
- package/src/setup-surface.ts +319 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +25 -0
- package/src/store-fs.ts +42 -0
- package/src/streaming-message.ts +327 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token-response.ts +11 -0
- package/src/token.ts +194 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatUnknownError(err: unknown): string {
|
|
6
|
+
if (err instanceof Error) {
|
|
7
|
+
return err.message;
|
|
8
|
+
}
|
|
9
|
+
if (typeof err === "string") {
|
|
10
|
+
return err;
|
|
11
|
+
}
|
|
12
|
+
if (err === null) {
|
|
13
|
+
return "null";
|
|
14
|
+
}
|
|
15
|
+
if (err === undefined) {
|
|
16
|
+
return "undefined";
|
|
17
|
+
}
|
|
18
|
+
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
|
19
|
+
return String(err);
|
|
20
|
+
}
|
|
21
|
+
if (typeof err === "symbol") {
|
|
22
|
+
return err.description ?? err.toString();
|
|
23
|
+
}
|
|
24
|
+
if (typeof err === "function") {
|
|
25
|
+
return err.name ? `[function ${err.name}]` : "[function]";
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(err) ?? "unknown error";
|
|
29
|
+
} catch {
|
|
30
|
+
return "unknown error";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractStatusCode(err: unknown): number | null {
|
|
35
|
+
if (!isRecord(err)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const direct = err.statusCode ?? err.status;
|
|
39
|
+
if (typeof direct === "number" && Number.isFinite(direct)) {
|
|
40
|
+
return direct;
|
|
41
|
+
}
|
|
42
|
+
if (typeof direct === "string") {
|
|
43
|
+
const parsed = Number.parseInt(direct, 10);
|
|
44
|
+
if (Number.isFinite(parsed)) {
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const response = err.response;
|
|
50
|
+
if (isRecord(response)) {
|
|
51
|
+
const status = response.status;
|
|
52
|
+
if (typeof status === "number" && Number.isFinite(status)) {
|
|
53
|
+
return status;
|
|
54
|
+
}
|
|
55
|
+
if (typeof status === "string") {
|
|
56
|
+
const parsed = Number.parseInt(status, 10);
|
|
57
|
+
if (Number.isFinite(parsed)) {
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractErrorCode(err: unknown): string | null {
|
|
67
|
+
if (!isRecord(err)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const direct = err.code;
|
|
72
|
+
if (typeof direct === "string" && direct.trim()) {
|
|
73
|
+
return direct;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const response = err.response;
|
|
77
|
+
if (!isRecord(response)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const body = response.body;
|
|
82
|
+
if (isRecord(body)) {
|
|
83
|
+
const error = body.error;
|
|
84
|
+
if (isRecord(error) && typeof error.code === "string" && error.code.trim()) {
|
|
85
|
+
return error.code;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractRetryAfterMs(err: unknown): number | null {
|
|
93
|
+
if (!isRecord(err)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const direct = err.retryAfterMs ?? err.retry_after_ms;
|
|
98
|
+
if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) {
|
|
99
|
+
return direct;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const retryAfter = err.retryAfter ?? err.retry_after;
|
|
103
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
104
|
+
return retryAfter >= 0 ? retryAfter * 1000 : null;
|
|
105
|
+
}
|
|
106
|
+
if (typeof retryAfter === "string") {
|
|
107
|
+
const parsed = Number.parseFloat(retryAfter);
|
|
108
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
109
|
+
return parsed * 1000;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const response = err.response;
|
|
114
|
+
if (!isRecord(response)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const headers = response.headers;
|
|
119
|
+
if (!headers) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isRecord(headers)) {
|
|
124
|
+
const raw = headers["retry-after"] ?? headers["Retry-After"];
|
|
125
|
+
if (typeof raw === "string") {
|
|
126
|
+
const parsed = Number.parseFloat(raw);
|
|
127
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
128
|
+
return parsed * 1000;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fetch Headers-like interface
|
|
134
|
+
if (
|
|
135
|
+
typeof headers === "object" &&
|
|
136
|
+
headers !== null &&
|
|
137
|
+
"get" in headers &&
|
|
138
|
+
typeof (headers as { get?: unknown }).get === "function"
|
|
139
|
+
) {
|
|
140
|
+
const raw = (headers as { get: (name: string) => string | null }).get("retry-after");
|
|
141
|
+
if (raw) {
|
|
142
|
+
const parsed = Number.parseFloat(raw);
|
|
143
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
144
|
+
return parsed * 1000;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type MSTeamsSendErrorKind =
|
|
153
|
+
| "auth"
|
|
154
|
+
| "throttled"
|
|
155
|
+
| "transient"
|
|
156
|
+
| "permanent"
|
|
157
|
+
| "network"
|
|
158
|
+
| "unknown";
|
|
159
|
+
|
|
160
|
+
type MSTeamsSendErrorClassification = {
|
|
161
|
+
kind: MSTeamsSendErrorKind;
|
|
162
|
+
statusCode?: number;
|
|
163
|
+
retryAfterMs?: number;
|
|
164
|
+
errorCode?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Classify outbound send errors for safe retries and actionable logs.
|
|
169
|
+
*
|
|
170
|
+
* Important: We only mark errors as retryable when we have an explicit HTTP
|
|
171
|
+
* status code that indicates the message was not accepted (e.g. 429, 5xx).
|
|
172
|
+
* For transport-level errors where delivery is ambiguous, we prefer to avoid
|
|
173
|
+
* retries to reduce the chance of duplicate posts.
|
|
174
|
+
*/
|
|
175
|
+
export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
|
|
176
|
+
const statusCode = extractStatusCode(err);
|
|
177
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
178
|
+
const errorCode = extractErrorCode(err) ?? undefined;
|
|
179
|
+
|
|
180
|
+
if (statusCode === 401) {
|
|
181
|
+
return { kind: "auth", statusCode, errorCode };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (statusCode === 403) {
|
|
185
|
+
if (errorCode === "ContentStreamNotAllowed") {
|
|
186
|
+
return { kind: "permanent", statusCode, errorCode };
|
|
187
|
+
}
|
|
188
|
+
return { kind: "auth", statusCode, errorCode };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (statusCode === 429) {
|
|
192
|
+
return {
|
|
193
|
+
kind: "throttled",
|
|
194
|
+
statusCode,
|
|
195
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
196
|
+
errorCode,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "transient",
|
|
203
|
+
statusCode,
|
|
204
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
205
|
+
errorCode,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (statusCode != null && statusCode >= 400) {
|
|
210
|
+
return { kind: "permanent", statusCode, errorCode };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Transport-level errors (no HTTP status code) — check for well-known
|
|
214
|
+
// network error codes that indicate egress is blocked (#77674).
|
|
215
|
+
if (statusCode == null) {
|
|
216
|
+
const networkCode = isRecord(err) && typeof err.code === "string" ? err.code : null;
|
|
217
|
+
if (
|
|
218
|
+
networkCode === "ECONNREFUSED" ||
|
|
219
|
+
networkCode === "ENOTFOUND" ||
|
|
220
|
+
networkCode === "EHOSTUNREACH" ||
|
|
221
|
+
networkCode === "ETIMEDOUT" ||
|
|
222
|
+
networkCode === "ECONNRESET"
|
|
223
|
+
) {
|
|
224
|
+
return { kind: "network", errorCode: networkCode };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
kind: "unknown",
|
|
230
|
+
statusCode: statusCode ?? undefined,
|
|
231
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
232
|
+
errorCode,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Detect whether an error is caused by a revoked Proxy.
|
|
238
|
+
*
|
|
239
|
+
* The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
|
|
240
|
+
* turn handler returns. Any later access (e.g. from a debounced callback)
|
|
241
|
+
* throws a TypeError whose message contains the distinctive "proxy that has
|
|
242
|
+
* been revoked" string.
|
|
243
|
+
*/
|
|
244
|
+
export function isRevokedProxyError(err: unknown): boolean {
|
|
245
|
+
if (!(err instanceof TypeError)) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return /proxy that has been revoked/i.test(err.message);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatMSTeamsSendErrorHint(
|
|
252
|
+
classification: MSTeamsSendErrorClassification,
|
|
253
|
+
): string | undefined {
|
|
254
|
+
if (classification.kind === "auth") {
|
|
255
|
+
return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
|
|
256
|
+
}
|
|
257
|
+
if (classification.errorCode === "ContentStreamNotAllowed") {
|
|
258
|
+
return "Teams expired the content stream; stop streaming earlier and fall back to normal message delivery";
|
|
259
|
+
}
|
|
260
|
+
if (classification.kind === "throttled") {
|
|
261
|
+
return "Teams throttled the bot; backing off may help";
|
|
262
|
+
}
|
|
263
|
+
if (classification.kind === "transient") {
|
|
264
|
+
return "transient Teams/Bot Framework error; retry may succeed";
|
|
265
|
+
}
|
|
266
|
+
if (classification.kind === "network") {
|
|
267
|
+
return "transport-level failure sending reply to Teams Bot Connector (smba.trafficmanager.net) — check egress firewall rules allow outbound HTTPS to smba.trafficmanager.net";
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { normalizeOptionalLowercaseString } from "autobot/plugin-sdk/string-coerce-runtime";
|
|
2
|
+
|
|
3
|
+
/** Max chars of the thumbed-down response to include in the reflection prompt. */
|
|
4
|
+
const MAX_RESPONSE_CHARS = 500;
|
|
5
|
+
|
|
6
|
+
type ParsedReflectionResponse = {
|
|
7
|
+
learning: string;
|
|
8
|
+
followUp: boolean;
|
|
9
|
+
userMessage?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function buildReflectionPrompt(params: {
|
|
13
|
+
thumbedDownResponse?: string;
|
|
14
|
+
userComment?: string;
|
|
15
|
+
}): string {
|
|
16
|
+
const parts: string[] = ["A user indicated your previous response wasn't helpful."];
|
|
17
|
+
|
|
18
|
+
if (params.thumbedDownResponse) {
|
|
19
|
+
const truncated =
|
|
20
|
+
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
|
|
21
|
+
? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...`
|
|
22
|
+
: params.thumbedDownResponse;
|
|
23
|
+
parts.push(`\nYour response was:\n> ${truncated}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (params.userComment) {
|
|
27
|
+
parts.push(`\nUser's comment: "${params.userComment}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
parts.push(
|
|
31
|
+
"\nBriefly reflect: what could you improve? Consider tone, length, " +
|
|
32
|
+
"accuracy, relevance, and specificity. Reply with a single JSON object " +
|
|
33
|
+
'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
|
|
34
|
+
'"followUp":false,"userMessage":""}\n' +
|
|
35
|
+
"- learning: a short internal adjustment note (1-2 sentences) for your " +
|
|
36
|
+
"future behavior in this conversation.\n" +
|
|
37
|
+
"- followUp: true only if the user needs a direct follow-up message.\n" +
|
|
38
|
+
"- userMessage: only the exact user-facing message to send; empty string " +
|
|
39
|
+
"when followUp is false.",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return parts.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseBooleanLike(value: unknown): boolean | undefined {
|
|
46
|
+
if (typeof value === "boolean") {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
const normalized = normalizeOptionalLowercaseString(value);
|
|
51
|
+
if (normalized === "true" || normalized === "yes") {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (normalized === "false" || normalized === "no") {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
|
|
62
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const candidate = value as {
|
|
67
|
+
learning?: unknown;
|
|
68
|
+
followUp?: unknown;
|
|
69
|
+
userMessage?: unknown;
|
|
70
|
+
};
|
|
71
|
+
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
|
|
72
|
+
if (!learning) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
learning,
|
|
78
|
+
followUp: parseBooleanLike(candidate.followUp) ?? false,
|
|
79
|
+
userMessage:
|
|
80
|
+
typeof candidate.userMessage === "string" && candidate.userMessage.trim()
|
|
81
|
+
? candidate.userMessage.trim()
|
|
82
|
+
: undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
|
|
87
|
+
const trimmed = text.trim();
|
|
88
|
+
if (!trimmed) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const candidates = [
|
|
93
|
+
trimmed,
|
|
94
|
+
...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (const candidateText of candidates) {
|
|
98
|
+
const candidate = candidateText.trim();
|
|
99
|
+
if (!candidate) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
|
|
104
|
+
if (parsed) {
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Fall through to the next parse strategy.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Safe fallback: keep the internal learning, but never auto-message the user.
|
|
113
|
+
return {
|
|
114
|
+
learning: trimmed,
|
|
115
|
+
followUp: false,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { writeJsonFileAtomically } from "autobot/plugin-sdk/json-store";
|
|
3
|
+
|
|
4
|
+
/** Default cooldown between reflections per session (5 minutes). */
|
|
5
|
+
export const DEFAULT_COOLDOWN_MS = 300_000;
|
|
6
|
+
|
|
7
|
+
/** Tracks last reflection time per session to enforce cooldown. */
|
|
8
|
+
const lastReflectionBySession = new Map<string, number>();
|
|
9
|
+
|
|
10
|
+
/** Maximum cooldown entries before pruning expired ones. */
|
|
11
|
+
const MAX_COOLDOWN_ENTRIES = 500;
|
|
12
|
+
|
|
13
|
+
function legacySanitizeSessionKey(sessionKey: string): string {
|
|
14
|
+
return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function encodeSessionKey(sessionKey: string): string {
|
|
18
|
+
return Buffer.from(sessionKey, "utf8").toString("base64url");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveLearningsFilePath(storePath: string, sessionKey: string): string {
|
|
22
|
+
return `${storePath}/${encodeSessionKey(sessionKey)}.learnings.json`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveLegacyLearningsFilePath(storePath: string, sessionKey: string): string {
|
|
26
|
+
return `${storePath}/${legacySanitizeSessionKey(sessionKey)}.learnings.json`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readLearningsFile(
|
|
30
|
+
filePath: string,
|
|
31
|
+
): Promise<{ exists: boolean; learnings: string[] }> {
|
|
32
|
+
try {
|
|
33
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
34
|
+
const parsed = JSON.parse(content);
|
|
35
|
+
return { exists: true, learnings: Array.isArray(parsed) ? parsed : [] };
|
|
36
|
+
} catch {
|
|
37
|
+
return { exists: false, learnings: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Prune expired cooldown entries to prevent unbounded memory growth. */
|
|
42
|
+
function pruneExpiredCooldowns(cooldownMs: number): void {
|
|
43
|
+
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
for (const [key, time] of lastReflectionBySession) {
|
|
48
|
+
if (now - time >= cooldownMs) {
|
|
49
|
+
lastReflectionBySession.delete(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if a reflection is allowed (cooldown not active). */
|
|
55
|
+
export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
|
|
56
|
+
const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
57
|
+
const lastTime = lastReflectionBySession.get(sessionKey);
|
|
58
|
+
if (lastTime == null) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return Date.now() - lastTime >= cooldown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Record that a reflection was run for a session. */
|
|
65
|
+
export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
|
|
66
|
+
lastReflectionBySession.set(sessionKey, Date.now());
|
|
67
|
+
pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Clear reflection cooldown tracking (for tests). */
|
|
71
|
+
export function clearReflectionCooldowns(): void {
|
|
72
|
+
lastReflectionBySession.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Store a learning derived from feedback reflection in a session companion file. */
|
|
76
|
+
export async function storeSessionLearning(params: {
|
|
77
|
+
storePath: string;
|
|
78
|
+
sessionKey: string;
|
|
79
|
+
learning: string;
|
|
80
|
+
}): Promise<void> {
|
|
81
|
+
const learningsFile = resolveLearningsFilePath(params.storePath, params.sessionKey);
|
|
82
|
+
const legacyLearningsFile = resolveLegacyLearningsFilePath(params.storePath, params.sessionKey);
|
|
83
|
+
const { exists, learnings: existingLearnings } = await readLearningsFile(learningsFile);
|
|
84
|
+
const { learnings: legacyLearnings } =
|
|
85
|
+
exists || legacyLearningsFile === learningsFile
|
|
86
|
+
? { learnings: [] as string[] }
|
|
87
|
+
: await readLearningsFile(legacyLearningsFile);
|
|
88
|
+
|
|
89
|
+
let learnings = exists ? existingLearnings : legacyLearnings;
|
|
90
|
+
|
|
91
|
+
learnings.push(params.learning);
|
|
92
|
+
if (learnings.length > 10) {
|
|
93
|
+
learnings = learnings.slice(-10);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await writeJsonFileAtomically(learningsFile, learnings);
|
|
97
|
+
if (!exists && legacyLearningsFile !== learningsFile) {
|
|
98
|
+
await fs.rm(legacyLearningsFile, { force: true }).catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load session learnings for injection into extraSystemPrompt. */
|
|
103
|
+
export async function loadSessionLearnings(
|
|
104
|
+
storePath: string,
|
|
105
|
+
sessionKey: string,
|
|
106
|
+
): Promise<string[]> {
|
|
107
|
+
const learningsFile = resolveLearningsFilePath(storePath, sessionKey);
|
|
108
|
+
const { exists, learnings } = await readLearningsFile(learningsFile);
|
|
109
|
+
if (exists) {
|
|
110
|
+
return learnings;
|
|
111
|
+
}
|
|
112
|
+
return (await readLearningsFile(resolveLegacyLearningsFilePath(storePath, sessionKey))).learnings;
|
|
113
|
+
}
|