@remnic/plugin-pi 9.36.0 → 9.38.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/README.md +5 -1
- package/dist/chunk-HRZBFDYV.js +262 -0
- package/dist/chunk-HRZBFDYV.js.map +1 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +257 -261
- package/dist/index.js.map +1 -1
- package/dist/publisher.js +5 -2
- package/dist/publisher.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-ASGQGBO2.js +0 -51
- package/dist/chunk-ASGQGBO2.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,198 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "./chunk-
|
|
2
|
+
DEFAULT_CONFIG,
|
|
3
|
+
loadConfig
|
|
4
|
+
} from "./chunk-HRZBFDYV.js";
|
|
5
5
|
|
|
6
6
|
// src/index.ts
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
-
|
|
9
|
-
// src/config.ts
|
|
10
|
-
import { existsSync, readFileSync } from "fs";
|
|
11
|
-
import path from "path";
|
|
12
|
-
import { expandTildePath } from "@remnic/core/utils/path";
|
|
13
|
-
var DEFAULT_CONFIG = {
|
|
14
|
-
remnicDaemonUrl: "http://127.0.0.1:4318",
|
|
15
|
-
recallMode: "auto",
|
|
16
|
-
recallTopK: 8,
|
|
17
|
-
recallBudgetChars: 12e3,
|
|
18
|
-
recallEnabled: true,
|
|
19
|
-
observeEnabled: true,
|
|
20
|
-
observeSkipExtraction: false,
|
|
21
|
-
compactionEnabled: true,
|
|
22
|
-
mcpToolsEnabled: true,
|
|
23
|
-
statusEnabled: true,
|
|
24
|
-
requestTimeoutMs: 6e4,
|
|
25
|
-
startupRequestTimeoutMs: 1e3,
|
|
26
|
-
// Default 20 s is comfortably under the Pi/omp 30 s handler budget (#1626).
|
|
27
|
-
turnRequestTimeoutMs: 2e4,
|
|
28
|
-
// Default 100 KiB leaves headroom under the daemon's 128 KiB default (#1600).
|
|
29
|
-
observeMaxBytes: 102400,
|
|
30
|
-
observeMaxRetries: 2,
|
|
31
|
-
// Base cooldown for the circuit breaker; doubles on consecutive failures (#1626).
|
|
32
|
-
daemonCooldownMs: 5e3
|
|
33
|
-
};
|
|
34
|
-
function defaultConfigPath(env) {
|
|
35
|
-
return path.join(resolvePiAgentHome(env), "extensions", REMNIC_PI_EXTENSION_DIR_NAME, "remnic.config.json");
|
|
36
|
-
}
|
|
37
|
-
function coerceBoolean(value, fallback, fieldName) {
|
|
38
|
-
if (value === void 0 || value === null) return fallback;
|
|
39
|
-
if (typeof value === "boolean") return value;
|
|
40
|
-
if (typeof value === "string") {
|
|
41
|
-
const normalized = value.trim().toLowerCase();
|
|
42
|
-
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
|
43
|
-
if (["false", "0", "no", "off"].includes(normalized)) return false;
|
|
44
|
-
}
|
|
45
|
-
throw new Error(`Invalid boolean value for Remnic Pi config field ${fieldName}`);
|
|
46
|
-
}
|
|
47
|
-
function coercePositiveInt(value, fallback, max, fieldName) {
|
|
48
|
-
if (value === void 0 || value === null || value === "") return fallback;
|
|
49
|
-
let parsed;
|
|
50
|
-
if (typeof value === "number") {
|
|
51
|
-
parsed = value;
|
|
52
|
-
} else if (typeof value === "string") {
|
|
53
|
-
const trimmed = value.trim();
|
|
54
|
-
if (trimmed.length === 0) return fallback;
|
|
55
|
-
if (!/^[+-]?\d+$/.test(trimmed)) {
|
|
56
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 1 to ${max}`);
|
|
57
|
-
}
|
|
58
|
-
parsed = Number(trimmed);
|
|
59
|
-
} else {
|
|
60
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 1 to ${max}`);
|
|
61
|
-
}
|
|
62
|
-
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > max) {
|
|
63
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 1 to ${max}`);
|
|
64
|
-
}
|
|
65
|
-
return parsed;
|
|
66
|
-
}
|
|
67
|
-
function coerceNonNegativeInt(value, fallback, max, fieldName) {
|
|
68
|
-
if (value === void 0 || value === null || value === "") return fallback;
|
|
69
|
-
let parsed;
|
|
70
|
-
if (typeof value === "number") {
|
|
71
|
-
parsed = value;
|
|
72
|
-
} else if (typeof value === "string") {
|
|
73
|
-
const trimmed = value.trim();
|
|
74
|
-
if (trimmed.length === 0) return fallback;
|
|
75
|
-
if (!/^[+-]?\d+$/.test(trimmed)) {
|
|
76
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 0 to ${max}`);
|
|
77
|
-
}
|
|
78
|
-
parsed = Number(trimmed);
|
|
79
|
-
} else {
|
|
80
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 0 to ${max}`);
|
|
81
|
-
}
|
|
82
|
-
if (!Number.isInteger(parsed) || parsed < 0 || parsed > max) {
|
|
83
|
-
throw new Error(`Invalid numeric value for Remnic Pi config field ${fieldName}: expected an integer from 0 to ${max}`);
|
|
84
|
-
}
|
|
85
|
-
return parsed;
|
|
86
|
-
}
|
|
87
|
-
function coerceOptionalNonEmptyString(value, fieldName) {
|
|
88
|
-
if (value === void 0 || value === null) return void 0;
|
|
89
|
-
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
90
|
-
throw new Error(`Invalid string value for Remnic Pi config field ${fieldName}`);
|
|
91
|
-
}
|
|
92
|
-
function coerceOptionalString(value, fieldName) {
|
|
93
|
-
if (value === void 0 || value === null) return void 0;
|
|
94
|
-
if (typeof value === "string") {
|
|
95
|
-
const trimmed = value.trim();
|
|
96
|
-
return trimmed.length > 0 ? trimmed : void 0;
|
|
97
|
-
}
|
|
98
|
-
throw new Error(`Invalid string value for Remnic Pi config field ${fieldName}`);
|
|
99
|
-
}
|
|
100
|
-
function coerceOptionalHttpUrl(value, fieldName) {
|
|
101
|
-
if (value === void 0 || value === null) return void 0;
|
|
102
|
-
if (typeof value !== "string") {
|
|
103
|
-
throw new Error(`Invalid URL value for Remnic Pi config field ${fieldName}: expected an http or https URL`);
|
|
104
|
-
}
|
|
105
|
-
const trimmed = value.trim();
|
|
106
|
-
if (trimmed.length === 0) return void 0;
|
|
107
|
-
try {
|
|
108
|
-
const parsed = new URL(trimmed);
|
|
109
|
-
if (parsed.protocol === "http:" || parsed.protocol === "https:") return trimTrailingSlashes(trimmed);
|
|
110
|
-
} catch {
|
|
111
|
-
}
|
|
112
|
-
throw new Error(`Invalid URL value for Remnic Pi config field ${fieldName}: expected an http or https URL`);
|
|
113
|
-
}
|
|
114
|
-
function coerceRecallMode(value) {
|
|
115
|
-
if (value === void 0 || value === null || value === "") return DEFAULT_CONFIG.recallMode;
|
|
116
|
-
if (value === "minimal" || value === "full" || value === "graph_mode" || value === "no_recall" || value === "auto") {
|
|
117
|
-
return value;
|
|
118
|
-
}
|
|
119
|
-
throw new Error(`Invalid recallMode value for Remnic Pi config: ${JSON.stringify(value)}`);
|
|
120
|
-
}
|
|
121
|
-
function readConfigFile(configPath) {
|
|
122
|
-
if (!existsSync(configPath)) return {};
|
|
123
|
-
try {
|
|
124
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
125
|
-
const parsed = JSON.parse(raw);
|
|
126
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
127
|
-
return parsed;
|
|
128
|
-
}
|
|
129
|
-
throw new Error("expected a JSON object");
|
|
130
|
-
} catch (err) {
|
|
131
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
132
|
-
throw new Error(`Failed to load Remnic Pi config at ${configPath}: ${reason}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
function trimTrailingSlashes(value) {
|
|
136
|
-
let end = value.length;
|
|
137
|
-
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
138
|
-
return value.slice(0, end);
|
|
139
|
-
}
|
|
140
|
-
function resolveConfigPath(options = {}) {
|
|
141
|
-
const env = options.env ?? process.env;
|
|
142
|
-
return expandTildePath(
|
|
143
|
-
options.configPath || env.REMNIC_PI_CONFIG || env.REMNIC_OMP_CONFIG || defaultConfigPath(env)
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
function loadConfig(options = {}) {
|
|
147
|
-
const env = options.env ?? process.env;
|
|
148
|
-
const fileConfig = readConfigFile(resolveConfigPath(options));
|
|
149
|
-
const daemonUrl = coerceOptionalHttpUrl(fileConfig.remnicDaemonUrl, "remnicDaemonUrl") ?? coerceOptionalHttpUrl(env.REMNIC_DAEMON_URL, "REMNIC_DAEMON_URL") ?? DEFAULT_CONFIG.remnicDaemonUrl;
|
|
150
|
-
const authToken = coerceOptionalString(fileConfig.authToken, "authToken") ?? coerceOptionalString(env.REMNIC_PI_AUTH_TOKEN, "REMNIC_PI_AUTH_TOKEN");
|
|
151
|
-
const namespace = coerceOptionalNonEmptyString(fileConfig.namespace, "namespace");
|
|
152
|
-
const requestTimeoutMs = coercePositiveInt(
|
|
153
|
-
fileConfig.requestTimeoutMs,
|
|
154
|
-
DEFAULT_CONFIG.requestTimeoutMs,
|
|
155
|
-
6e4,
|
|
156
|
-
"requestTimeoutMs"
|
|
157
|
-
);
|
|
158
|
-
const turnFallback = Math.min(requestTimeoutMs, DEFAULT_CONFIG.turnRequestTimeoutMs);
|
|
159
|
-
const turnRequestTimeoutMs = coercePositiveInt(
|
|
160
|
-
fileConfig.turnRequestTimeoutMs,
|
|
161
|
-
turnFallback,
|
|
162
|
-
25e3,
|
|
163
|
-
"turnRequestTimeoutMs"
|
|
164
|
-
);
|
|
165
|
-
return {
|
|
166
|
-
remnicDaemonUrl: daemonUrl,
|
|
167
|
-
authToken,
|
|
168
|
-
namespace,
|
|
169
|
-
recallMode: coerceRecallMode(fileConfig.recallMode),
|
|
170
|
-
recallTopK: coercePositiveInt(fileConfig.recallTopK, DEFAULT_CONFIG.recallTopK, 50, "recallTopK"),
|
|
171
|
-
recallBudgetChars: coercePositiveInt(fileConfig.recallBudgetChars, DEFAULT_CONFIG.recallBudgetChars, 64e3, "recallBudgetChars"),
|
|
172
|
-
recallEnabled: coerceBoolean(fileConfig.recallEnabled, DEFAULT_CONFIG.recallEnabled, "recallEnabled"),
|
|
173
|
-
observeEnabled: coerceBoolean(fileConfig.observeEnabled, DEFAULT_CONFIG.observeEnabled, "observeEnabled"),
|
|
174
|
-
observeSkipExtraction: coerceBoolean(fileConfig.observeSkipExtraction, DEFAULT_CONFIG.observeSkipExtraction, "observeSkipExtraction"),
|
|
175
|
-
compactionEnabled: coerceBoolean(fileConfig.compactionEnabled, DEFAULT_CONFIG.compactionEnabled, "compactionEnabled"),
|
|
176
|
-
mcpToolsEnabled: coerceBoolean(fileConfig.mcpToolsEnabled, DEFAULT_CONFIG.mcpToolsEnabled, "mcpToolsEnabled"),
|
|
177
|
-
statusEnabled: coerceBoolean(fileConfig.statusEnabled, DEFAULT_CONFIG.statusEnabled, "statusEnabled"),
|
|
178
|
-
requestTimeoutMs,
|
|
179
|
-
startupRequestTimeoutMs: coercePositiveInt(
|
|
180
|
-
fileConfig.startupRequestTimeoutMs,
|
|
181
|
-
DEFAULT_CONFIG.startupRequestTimeoutMs,
|
|
182
|
-
6e4,
|
|
183
|
-
"startupRequestTimeoutMs"
|
|
184
|
-
),
|
|
185
|
-
turnRequestTimeoutMs,
|
|
186
|
-
observeMaxBytes: coercePositiveInt(
|
|
187
|
-
fileConfig.observeMaxBytes,
|
|
188
|
-
DEFAULT_CONFIG.observeMaxBytes,
|
|
189
|
-
8388608,
|
|
190
|
-
"observeMaxBytes"
|
|
191
|
-
),
|
|
192
|
-
observeMaxRetries: coerceNonNegativeInt(fileConfig.observeMaxRetries, DEFAULT_CONFIG.observeMaxRetries, 5, "observeMaxRetries"),
|
|
193
|
-
daemonCooldownMs: coercePositiveInt(fileConfig.daemonCooldownMs, DEFAULT_CONFIG.daemonCooldownMs, 6e4, "daemonCooldownMs")
|
|
194
|
-
};
|
|
195
|
-
}
|
|
8
|
+
import { createHash as createHash2 } from "crypto";
|
|
196
9
|
|
|
197
10
|
// src/client.ts
|
|
198
11
|
var RemnicHttpError = class extends Error {
|
|
@@ -204,6 +17,20 @@ var RemnicHttpError = class extends Error {
|
|
|
204
17
|
status;
|
|
205
18
|
code;
|
|
206
19
|
};
|
|
20
|
+
var RemnicRequestTimeoutError = class extends Error {
|
|
21
|
+
timeoutMs;
|
|
22
|
+
constructor(timeoutMs) {
|
|
23
|
+
super(`Remnic request timed out after ${timeoutMs}ms`);
|
|
24
|
+
this.name = "RemnicRequestTimeoutError";
|
|
25
|
+
this.timeoutMs = timeoutMs;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var RemnicRequestAbortedError = class extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("Remnic request aborted");
|
|
31
|
+
this.name = "RemnicRequestAbortedError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
207
34
|
var encoder = new TextEncoder();
|
|
208
35
|
var RETRY_BASE_DELAY_MS = 200;
|
|
209
36
|
var MAX_COOLDOWN_MS = 6e4;
|
|
@@ -255,11 +82,11 @@ var RemnicClient = class {
|
|
|
255
82
|
if (sessionKey) params.set("session", sessionKey);
|
|
256
83
|
params.set("op", this.config.observeEnabled ? "observe" : "memory_store");
|
|
257
84
|
const qs = params.toString();
|
|
258
|
-
const
|
|
85
|
+
const path = `/engram/v1/namespace/writable${qs ? `?${qs}` : ""}`;
|
|
259
86
|
try {
|
|
260
87
|
const payload = await this.request(
|
|
261
88
|
"GET",
|
|
262
|
-
|
|
89
|
+
path,
|
|
263
90
|
void 0,
|
|
264
91
|
options
|
|
265
92
|
);
|
|
@@ -310,6 +137,7 @@ var RemnicClient = class {
|
|
|
310
137
|
const turnBudgetMs = options.timeoutMs ?? this.config.turnRequestTimeoutMs;
|
|
311
138
|
const retryOptions = {
|
|
312
139
|
timeoutMs: turnBudgetMs,
|
|
140
|
+
signal: options.signal,
|
|
313
141
|
maxRetries: options.maxRetries ?? this.config.observeMaxRetries
|
|
314
142
|
};
|
|
315
143
|
const chunks = chunkObservePayload(this.config, sessionKey, cwd, messages, maxBytes);
|
|
@@ -381,11 +209,11 @@ var RemnicClient = class {
|
|
|
381
209
|
const tools = result.tools;
|
|
382
210
|
return Array.isArray(tools) ? tools.filter(isMcpTool) : [];
|
|
383
211
|
}
|
|
384
|
-
async mcpTool(name, args) {
|
|
212
|
+
async mcpTool(name, args, options = {}) {
|
|
385
213
|
return this.mcpRequest("tools/call", {
|
|
386
214
|
name,
|
|
387
215
|
arguments: args
|
|
388
|
-
});
|
|
216
|
+
}, options);
|
|
389
217
|
}
|
|
390
218
|
/**
|
|
391
219
|
* Single HTTP attempt with the configured timeout. No retry — retry of
|
|
@@ -393,9 +221,23 @@ var RemnicClient = class {
|
|
|
393
221
|
*/
|
|
394
222
|
async request(method, pathname, body, options = {}) {
|
|
395
223
|
const controller = new AbortController();
|
|
224
|
+
let timedOut = false;
|
|
225
|
+
const onExternalAbort = () => controller.abort();
|
|
226
|
+
if (options.signal?.aborted) {
|
|
227
|
+
throw new RemnicRequestAbortedError();
|
|
228
|
+
}
|
|
229
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
396
230
|
const override = options.timeoutMs;
|
|
397
|
-
const timeoutMs =
|
|
398
|
-
|
|
231
|
+
const timeoutMs = override === void 0 ? this.config.requestTimeoutMs : (() => {
|
|
232
|
+
if (!Number.isInteger(override) || override <= 0) {
|
|
233
|
+
throw new TypeError("Request timeoutMs must be a positive integer");
|
|
234
|
+
}
|
|
235
|
+
return override;
|
|
236
|
+
})();
|
|
237
|
+
const timeout = setTimeout(() => {
|
|
238
|
+
timedOut = true;
|
|
239
|
+
controller.abort();
|
|
240
|
+
}, timeoutMs);
|
|
399
241
|
try {
|
|
400
242
|
const response = await fetch(`${this.config.remnicDaemonUrl}${pathname}`, {
|
|
401
243
|
method,
|
|
@@ -432,12 +274,12 @@ var RemnicClient = class {
|
|
|
432
274
|
}
|
|
433
275
|
return payload;
|
|
434
276
|
} catch (err) {
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
}
|
|
277
|
+
if (timedOut) throw new RemnicRequestTimeoutError(timeoutMs);
|
|
278
|
+
if (options.signal?.aborted) throw new RemnicRequestAbortedError();
|
|
438
279
|
throw err;
|
|
439
280
|
} finally {
|
|
440
281
|
clearTimeout(timeout);
|
|
282
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
441
283
|
}
|
|
442
284
|
}
|
|
443
285
|
/**
|
|
@@ -469,7 +311,7 @@ var RemnicClient = class {
|
|
|
469
311
|
);
|
|
470
312
|
}
|
|
471
313
|
}
|
|
472
|
-
await sleep(delayMs);
|
|
314
|
+
await sleep(delayMs, options.signal);
|
|
473
315
|
attempt += 1;
|
|
474
316
|
if (hasDeadline) {
|
|
475
317
|
const remaining = deadline - Date.now();
|
|
@@ -523,8 +365,19 @@ function isTransientNetworkError(err) {
|
|
|
523
365
|
}
|
|
524
366
|
return false;
|
|
525
367
|
}
|
|
526
|
-
function sleep(ms) {
|
|
527
|
-
|
|
368
|
+
function sleep(ms, signal) {
|
|
369
|
+
if (signal?.aborted) return Promise.reject(new RemnicRequestAbortedError());
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const onAbort = () => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
reject(new RemnicRequestAbortedError());
|
|
374
|
+
};
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
resolve();
|
|
378
|
+
}, ms);
|
|
379
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
380
|
+
});
|
|
528
381
|
}
|
|
529
382
|
function jsonBytes(value) {
|
|
530
383
|
return encoder.encode(JSON.stringify(value)).length;
|
|
@@ -661,21 +514,6 @@ function textFromMessage(message) {
|
|
|
661
514
|
}
|
|
662
515
|
return textFromContent(obj.content).trim();
|
|
663
516
|
}
|
|
664
|
-
function latestUserRecallTarget(messages) {
|
|
665
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
666
|
-
const message = messages[index];
|
|
667
|
-
if (isExcludedFromContext(message) || isRemnicInjected(message)) continue;
|
|
668
|
-
if (message?.role !== "user") continue;
|
|
669
|
-
const query = textFromMessage(message);
|
|
670
|
-
if (query.length === 0) continue;
|
|
671
|
-
const identity = stableObservedMessageIdentity(message);
|
|
672
|
-
return {
|
|
673
|
-
query,
|
|
674
|
-
dedupeKey: identity ? `message:${identity}:${query}` : `query:${query}`
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
return null;
|
|
678
|
-
}
|
|
679
517
|
function toObserveMessage(message) {
|
|
680
518
|
if (!message || typeof message !== "object") return null;
|
|
681
519
|
const obj = message;
|
|
@@ -777,6 +615,61 @@ function stableObservedMessageIdentity(rawContent) {
|
|
|
777
615
|
return null;
|
|
778
616
|
}
|
|
779
617
|
|
|
618
|
+
// src/recall-timeout-breaker.ts
|
|
619
|
+
function isRecallTimeoutError(err) {
|
|
620
|
+
return err instanceof RemnicRequestTimeoutError;
|
|
621
|
+
}
|
|
622
|
+
var RecallTimeoutBreaker = class {
|
|
623
|
+
tripped = false;
|
|
624
|
+
results = [];
|
|
625
|
+
threshold;
|
|
626
|
+
windowSize;
|
|
627
|
+
abortController = new AbortController();
|
|
628
|
+
constructor(options) {
|
|
629
|
+
const { threshold, window } = options;
|
|
630
|
+
if (typeof threshold !== "number" || !Number.isInteger(threshold) || threshold <= 0 || typeof window !== "number" || !Number.isInteger(window) || window <= 0 || threshold > window) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`Invalid RecallTimeoutBreaker options: threshold and window must be positive integers with threshold <= window (got threshold=${threshold}, window=${window})`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
this.threshold = threshold;
|
|
636
|
+
this.windowSize = window;
|
|
637
|
+
}
|
|
638
|
+
get signal() {
|
|
639
|
+
return this.abortController.signal;
|
|
640
|
+
}
|
|
641
|
+
isTripped() {
|
|
642
|
+
return this.tripped;
|
|
643
|
+
}
|
|
644
|
+
recordSuccess() {
|
|
645
|
+
this.record("success");
|
|
646
|
+
}
|
|
647
|
+
recordTimeout() {
|
|
648
|
+
return this.record("timeout");
|
|
649
|
+
}
|
|
650
|
+
recordFailure() {
|
|
651
|
+
this.record("failure");
|
|
652
|
+
}
|
|
653
|
+
record(result) {
|
|
654
|
+
if (this.tripped) return false;
|
|
655
|
+
this.results.push(result === "timeout");
|
|
656
|
+
if (this.results.length > this.windowSize) this.results.shift();
|
|
657
|
+
if (this.timeoutCount() >= this.threshold) {
|
|
658
|
+
this.tripped = true;
|
|
659
|
+
this.abortController.abort();
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
timeoutCount() {
|
|
665
|
+
let count = 0;
|
|
666
|
+
for (const timedOut of this.results) {
|
|
667
|
+
if (timedOut) count += 1;
|
|
668
|
+
}
|
|
669
|
+
return count;
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
780
673
|
// src/index.ts
|
|
781
674
|
var STATE_CUSTOM_TYPE = "remnic_state";
|
|
782
675
|
var MAX_OBSERVED_HASHES = 2e3;
|
|
@@ -784,57 +677,93 @@ var MAX_SESSION_STATES = 50;
|
|
|
784
677
|
var MAX_CONTEXT_CHARS = 12e3;
|
|
785
678
|
var TRUNCATION_NOTICE = "\n\n[Remnic context truncated]";
|
|
786
679
|
var SESSION_OWNED_FIELDS = /* @__PURE__ */ new Set(["sessionKey", "namespace", "cwd"]);
|
|
680
|
+
var RECALL_BREAKER_REGISTRY_KEY = /* @__PURE__ */ Symbol.for("remnic.plugin-pi.recall-timeout-breakers");
|
|
681
|
+
var recallBreakerRegistry = globalThis;
|
|
682
|
+
var PROCESS_SCOPED_RECALL_BREAKERS = recallBreakerRegistry[RECALL_BREAKER_REGISTRY_KEY] ??= /* @__PURE__ */ new Map();
|
|
683
|
+
var RECALL_PRODUCING_TOOL_NAMES = {
|
|
684
|
+
"remnic.recall": true,
|
|
685
|
+
"remnic.recall_xray": true
|
|
686
|
+
};
|
|
687
|
+
function recallTimeoutBreakerCacheKey(config) {
|
|
688
|
+
return JSON.stringify([
|
|
689
|
+
config.remnicDaemonUrl,
|
|
690
|
+
config.namespace ?? "",
|
|
691
|
+
createHash2("sha256").update(config.authToken ?? "").digest("hex"),
|
|
692
|
+
config.recallTimeoutThreshold,
|
|
693
|
+
config.recallTimeoutWindow
|
|
694
|
+
]);
|
|
695
|
+
}
|
|
696
|
+
function resetProcessRecallBreakerForTest(config) {
|
|
697
|
+
PROCESS_SCOPED_RECALL_BREAKERS.delete(recallTimeoutBreakerCacheKey(config));
|
|
698
|
+
}
|
|
787
699
|
function createRemnicPiExtension(options = {}) {
|
|
788
|
-
const config = options.config ?? loadConfig(options);
|
|
700
|
+
const config = { ...DEFAULT_CONFIG, ...options.config ?? loadConfig(options) };
|
|
789
701
|
const client = new RemnicClient(config);
|
|
790
702
|
const sessionStates = /* @__PURE__ */ new Map();
|
|
703
|
+
const breakerKey = recallTimeoutBreakerCacheKey(config);
|
|
704
|
+
let recallTimeoutBreaker = PROCESS_SCOPED_RECALL_BREAKERS.get(breakerKey);
|
|
705
|
+
if (!recallTimeoutBreaker) {
|
|
706
|
+
recallTimeoutBreaker = new RecallTimeoutBreaker({
|
|
707
|
+
threshold: config.recallTimeoutThreshold,
|
|
708
|
+
window: config.recallTimeoutWindow
|
|
709
|
+
});
|
|
710
|
+
PROCESS_SCOPED_RECALL_BREAKERS.set(breakerKey, recallTimeoutBreaker);
|
|
711
|
+
}
|
|
791
712
|
return async function remnicPiExtension2(pi) {
|
|
792
713
|
pi.on("session_start", async (_event, ctx) => {
|
|
793
714
|
const session = snapshotPiContext(ctx, { includeSessionHistory: true });
|
|
794
715
|
if (!session) return;
|
|
795
716
|
const { state } = getSessionState(session.sessionKey, sessionStates);
|
|
796
717
|
restoreObservedState(session, state.observedHashes);
|
|
718
|
+
state.cachedContext = null;
|
|
719
|
+
state.recallCompleted = false;
|
|
797
720
|
const probe = await probeDaemonHealth(client, config);
|
|
798
721
|
if (config.statusEnabled) {
|
|
799
|
-
session.setStatus(
|
|
722
|
+
session.setStatus(
|
|
723
|
+
"remnic",
|
|
724
|
+
recallTimeoutBreaker.isTripped() ? RECALL_DISABLED_STATUS : remnicStatusLabel(probe, config.namespace)
|
|
725
|
+
);
|
|
800
726
|
}
|
|
801
727
|
await runNamespacePreflight(pi, session, client, config);
|
|
802
728
|
});
|
|
803
|
-
pi.on("
|
|
729
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
804
730
|
const session = snapshotPiContext(ctx);
|
|
805
731
|
if (!session) return;
|
|
806
|
-
if (!config.recallEnabled || !config.authToken) return;
|
|
807
|
-
if (!client.isReachable()) return;
|
|
808
|
-
const recallTarget = latestUserRecallTarget(Array.isArray(event.messages) ? event.messages : []);
|
|
809
|
-
if (!recallTarget) return;
|
|
810
|
-
const { query } = recallTarget;
|
|
811
732
|
const { state } = getSessionState(session.sessionKey, sessionStates);
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
733
|
+
if (!state.recallCompleted && config.recallEnabled && config.authToken && client.isReachable()) {
|
|
734
|
+
const promptText = typeof event.prompt === "string" && event.prompt.trim().length > 0 ? event.prompt : "";
|
|
735
|
+
if (promptText) {
|
|
736
|
+
state.recallCompleted = true;
|
|
737
|
+
try {
|
|
738
|
+
const recalled = await executeRecallWithBreaker(
|
|
739
|
+
(signal) => client.recall(promptText, session.sessionKey, session.cwd, {
|
|
740
|
+
timeoutMs: config.turnRequestTimeoutMs,
|
|
741
|
+
signal
|
|
742
|
+
}),
|
|
743
|
+
recallTimeoutBreaker,
|
|
744
|
+
pi,
|
|
745
|
+
session,
|
|
746
|
+
config
|
|
747
|
+
);
|
|
748
|
+
client.markReachable();
|
|
749
|
+
const context = trimContext(recalled.context ?? "", config.recallBudgetChars);
|
|
750
|
+
if (context) {
|
|
751
|
+
state.cachedContext = context;
|
|
831
752
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
753
|
+
} catch (err) {
|
|
754
|
+
if (recallTimeoutBreaker.isTripped()) return;
|
|
755
|
+
if (isDaemonUnreachableError(err)) client.markUnreachable(config.daemonCooldownMs);
|
|
756
|
+
session.notify(`Remnic recall unavailable: ${errorMessage(err)}`, "warning");
|
|
757
|
+
}
|
|
758
|
+
}
|
|
837
759
|
}
|
|
760
|
+
if (!state.cachedContext) return;
|
|
761
|
+
const basePrompt = typeof event.systemPrompt === "string" ? event.systemPrompt : "";
|
|
762
|
+
return {
|
|
763
|
+
systemPrompt: `${basePrompt}
|
|
764
|
+
|
|
765
|
+
${state.cachedContext}`
|
|
766
|
+
};
|
|
838
767
|
});
|
|
839
768
|
pi.on("message_end", async (event, ctx) => {
|
|
840
769
|
const session = snapshotPiContext(ctx);
|
|
@@ -905,19 +834,20 @@ ${context}` }],
|
|
|
905
834
|
}
|
|
906
835
|
};
|
|
907
836
|
});
|
|
908
|
-
registerCommands(pi, client, config);
|
|
837
|
+
registerCommands(pi, client, config, recallTimeoutBreaker);
|
|
909
838
|
if (config.mcpToolsEnabled && config.authToken) {
|
|
910
|
-
await registerMcpTools(pi, client, config);
|
|
839
|
+
await registerMcpTools(pi, client, config, recallTimeoutBreaker);
|
|
911
840
|
}
|
|
912
841
|
};
|
|
913
842
|
}
|
|
914
843
|
async function remnicPiExtension(pi) {
|
|
915
844
|
await createRemnicPiExtension()(pi);
|
|
916
845
|
}
|
|
917
|
-
function registerCommands(pi, client, config) {
|
|
846
|
+
function registerCommands(pi, client, config, recallTimeoutBreaker) {
|
|
918
847
|
pi.registerCommand("remnic-status", {
|
|
919
848
|
description: "Check Remnic daemon status",
|
|
920
849
|
handler: commandHandler(async (_args, _ctx, session) => {
|
|
850
|
+
if (recallTimeoutBreaker.isTripped()) notifyRecallDisabled(session);
|
|
921
851
|
const health = await client.health();
|
|
922
852
|
client.markReachable();
|
|
923
853
|
session.notify(`Remnic ${health.ok ? "healthy" : "unhealthy"} at ${config.remnicDaemonUrl}`, health.ok ? "success" : "warning");
|
|
@@ -931,9 +861,20 @@ function registerCommands(pi, client, config) {
|
|
|
931
861
|
session.notify("Usage: /remnic-recall <query>", "warning");
|
|
932
862
|
return;
|
|
933
863
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
864
|
+
if (recallTimeoutBreaker.isTripped()) {
|
|
865
|
+
notifyRecallDisabled(session);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const result = await executeRecallWithBreaker(
|
|
869
|
+
(signal) => client.recall(query, session.sessionKey, session.cwd, {
|
|
870
|
+
timeoutMs: config.requestTimeoutMs,
|
|
871
|
+
signal
|
|
872
|
+
}),
|
|
873
|
+
recallTimeoutBreaker,
|
|
874
|
+
pi,
|
|
875
|
+
session,
|
|
876
|
+
config
|
|
877
|
+
);
|
|
937
878
|
client.markReachable();
|
|
938
879
|
session.notify(trimContext(result.context ?? "(no Remnic context)", MAX_CONTEXT_CHARS), "info");
|
|
939
880
|
})
|
|
@@ -988,7 +929,7 @@ function commandHandler(handler) {
|
|
|
988
929
|
}
|
|
989
930
|
};
|
|
990
931
|
}
|
|
991
|
-
async function registerMcpTools(pi, client, config) {
|
|
932
|
+
async function registerMcpTools(pi, client, config, recallTimeoutBreaker) {
|
|
992
933
|
let tools = [];
|
|
993
934
|
try {
|
|
994
935
|
tools = await client.mcpListTools({ timeoutMs: config.startupRequestTimeoutMs });
|
|
@@ -1003,7 +944,7 @@ async function registerMcpTools(pi, client, config) {
|
|
|
1003
944
|
label: tool.name,
|
|
1004
945
|
description: tool.description ?? `Call ${tool.name}`,
|
|
1005
946
|
parameters: toPiToolParametersSchema(tool.inputSchema),
|
|
1006
|
-
async execute(_toolCallId, params,
|
|
947
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1007
948
|
const session = snapshotPiContext(ctx);
|
|
1008
949
|
if (!session) {
|
|
1009
950
|
return {
|
|
@@ -1011,13 +952,29 @@ async function registerMcpTools(pi, client, config) {
|
|
|
1011
952
|
details: { skipped: true, reason: "stale_context" }
|
|
1012
953
|
};
|
|
1013
954
|
}
|
|
955
|
+
if (recallTimeoutBreaker.isTripped() && RECALL_PRODUCING_TOOL_NAMES[tool.name] === true) {
|
|
956
|
+
const message = notifyRecallDisabled(session);
|
|
957
|
+
return {
|
|
958
|
+
content: [{ type: "text", text: message }],
|
|
959
|
+
details: { skipped: true, reason: "recall_timeout_breaker" }
|
|
960
|
+
};
|
|
961
|
+
}
|
|
1014
962
|
const safeParams = stripSessionOwnedRuntimeFields(params ?? {});
|
|
1015
|
-
const
|
|
963
|
+
const toolArguments = {
|
|
1016
964
|
...safeParams,
|
|
1017
965
|
sessionKey: session.sessionKey,
|
|
1018
966
|
namespace: config.namespace,
|
|
1019
967
|
cwd: session.cwd
|
|
1020
|
-
}
|
|
968
|
+
};
|
|
969
|
+
const result = RECALL_PRODUCING_TOOL_NAMES[tool.name] === true ? await executeRecallWithBreaker(
|
|
970
|
+
(breakerSignal) => client.mcpTool(tool.name, toolArguments, {
|
|
971
|
+
signal: signal ? AbortSignal.any([signal, breakerSignal]) : breakerSignal
|
|
972
|
+
}),
|
|
973
|
+
recallTimeoutBreaker,
|
|
974
|
+
pi,
|
|
975
|
+
session,
|
|
976
|
+
config
|
|
977
|
+
) : await client.mcpTool(tool.name, toolArguments, { signal });
|
|
1021
978
|
return {
|
|
1022
979
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1023
980
|
details: result
|
|
@@ -1094,7 +1051,8 @@ function getSessionState(sessionKey, states) {
|
|
|
1094
1051
|
state = {
|
|
1095
1052
|
observedHashes: /* @__PURE__ */ new Set(),
|
|
1096
1053
|
liveObservedReplayKeys: /* @__PURE__ */ new Map(),
|
|
1097
|
-
|
|
1054
|
+
cachedContext: null,
|
|
1055
|
+
recallCompleted: false
|
|
1098
1056
|
};
|
|
1099
1057
|
states.set(sessionKey, state);
|
|
1100
1058
|
pruneSessionStates(states);
|
|
@@ -1393,6 +1351,43 @@ function isDaemonUnreachableError(err) {
|
|
|
1393
1351
|
function errorMessage(err) {
|
|
1394
1352
|
return err instanceof Error ? err.message : String(err);
|
|
1395
1353
|
}
|
|
1354
|
+
function notifyRecallDisabled(session) {
|
|
1355
|
+
const message = "Remnic recall is disabled for this process because timeouts delayed operations; memory recall is unavailable until restart.";
|
|
1356
|
+
session.notify(message, "warning");
|
|
1357
|
+
return message;
|
|
1358
|
+
}
|
|
1359
|
+
var RECALL_DISABLED_STATUS = "Remnic recall disabled until restart (timeouts delayed operations)";
|
|
1360
|
+
function emitRecallTimeoutTrip(pi, session, config, err) {
|
|
1361
|
+
const message = `Remnic recall has been disabled for this process because repeated timeouts delayed operations. Memory recall is unavailable until restart (${config.recallTimeoutThreshold} timeouts in the last ${config.recallTimeoutWindow} recall attempts).`;
|
|
1362
|
+
session.notify(message, "warning");
|
|
1363
|
+
if (config.statusEnabled) {
|
|
1364
|
+
session.setStatus("remnic", RECALL_DISABLED_STATUS);
|
|
1365
|
+
}
|
|
1366
|
+
pi.appendEntry(STATE_CUSTOM_TYPE, {
|
|
1367
|
+
level: "warning",
|
|
1368
|
+
code: "RECALL_TIMEOUT_TRIP",
|
|
1369
|
+
threshold: config.recallTimeoutThreshold,
|
|
1370
|
+
window: config.recallTimeoutWindow,
|
|
1371
|
+
reason: "Recall disabled until restart because timeouts delayed operations",
|
|
1372
|
+
message,
|
|
1373
|
+
error: errorMessage(err),
|
|
1374
|
+
disabledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1375
|
+
persistent: true
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
async function executeRecallWithBreaker(operation, breaker, pi, session, config) {
|
|
1379
|
+
try {
|
|
1380
|
+
const result = await operation(breaker.signal);
|
|
1381
|
+
breaker.recordSuccess();
|
|
1382
|
+
return result;
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
if (!(breaker.isTripped() && err instanceof RemnicRequestAbortedError)) {
|
|
1385
|
+
const tripped = breaker.record(isRecallTimeoutError(err) ? "timeout" : "failure");
|
|
1386
|
+
if (tripped) emitRecallTimeoutTrip(pi, session, config, err);
|
|
1387
|
+
}
|
|
1388
|
+
throw err;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1396
1391
|
function finiteTokenCount(value) {
|
|
1397
1392
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
1398
1393
|
}
|
|
@@ -1405,6 +1400,7 @@ export {
|
|
|
1405
1400
|
remnicPiExtension as default,
|
|
1406
1401
|
isDaemonUnreachableError,
|
|
1407
1402
|
observeMessages,
|
|
1403
|
+
resetProcessRecallBreakerForTest,
|
|
1408
1404
|
stripSessionOwnedRuntimeFields,
|
|
1409
1405
|
stripSessionOwnedSchemaFields,
|
|
1410
1406
|
textFromMessage,
|