@remnic/plugin-pi 9.37.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 +231 -218
- package/dist/index.js.map +1 -1
- package/dist/publisher.js +4 -1
- 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;
|
|
@@ -762,6 +615,61 @@ function stableObservedMessageIdentity(rawContent) {
|
|
|
762
615
|
return null;
|
|
763
616
|
}
|
|
764
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
|
+
|
|
765
673
|
// src/index.ts
|
|
766
674
|
var STATE_CUSTOM_TYPE = "remnic_state";
|
|
767
675
|
var MAX_OBSERVED_HASHES = 2e3;
|
|
@@ -769,10 +677,38 @@ var MAX_SESSION_STATES = 50;
|
|
|
769
677
|
var MAX_CONTEXT_CHARS = 12e3;
|
|
770
678
|
var TRUNCATION_NOTICE = "\n\n[Remnic context truncated]";
|
|
771
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
|
+
}
|
|
772
699
|
function createRemnicPiExtension(options = {}) {
|
|
773
|
-
const config = options.config ?? loadConfig(options);
|
|
700
|
+
const config = { ...DEFAULT_CONFIG, ...options.config ?? loadConfig(options) };
|
|
774
701
|
const client = new RemnicClient(config);
|
|
775
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
|
+
}
|
|
776
712
|
return async function remnicPiExtension2(pi) {
|
|
777
713
|
pi.on("session_start", async (_event, ctx) => {
|
|
778
714
|
const session = snapshotPiContext(ctx, { includeSessionHistory: true });
|
|
@@ -783,7 +719,10 @@ function createRemnicPiExtension(options = {}) {
|
|
|
783
719
|
state.recallCompleted = false;
|
|
784
720
|
const probe = await probeDaemonHealth(client, config);
|
|
785
721
|
if (config.statusEnabled) {
|
|
786
|
-
session.setStatus(
|
|
722
|
+
session.setStatus(
|
|
723
|
+
"remnic",
|
|
724
|
+
recallTimeoutBreaker.isTripped() ? RECALL_DISABLED_STATUS : remnicStatusLabel(probe, config.namespace)
|
|
725
|
+
);
|
|
787
726
|
}
|
|
788
727
|
await runNamespacePreflight(pi, session, client, config);
|
|
789
728
|
});
|
|
@@ -796,15 +735,23 @@ function createRemnicPiExtension(options = {}) {
|
|
|
796
735
|
if (promptText) {
|
|
797
736
|
state.recallCompleted = true;
|
|
798
737
|
try {
|
|
799
|
-
const recalled = await
|
|
800
|
-
|
|
801
|
-
|
|
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
|
+
);
|
|
802
748
|
client.markReachable();
|
|
803
749
|
const context = trimContext(recalled.context ?? "", config.recallBudgetChars);
|
|
804
750
|
if (context) {
|
|
805
751
|
state.cachedContext = context;
|
|
806
752
|
}
|
|
807
753
|
} catch (err) {
|
|
754
|
+
if (recallTimeoutBreaker.isTripped()) return;
|
|
808
755
|
if (isDaemonUnreachableError(err)) client.markUnreachable(config.daemonCooldownMs);
|
|
809
756
|
session.notify(`Remnic recall unavailable: ${errorMessage(err)}`, "warning");
|
|
810
757
|
}
|
|
@@ -887,19 +834,20 @@ ${state.cachedContext}`
|
|
|
887
834
|
}
|
|
888
835
|
};
|
|
889
836
|
});
|
|
890
|
-
registerCommands(pi, client, config);
|
|
837
|
+
registerCommands(pi, client, config, recallTimeoutBreaker);
|
|
891
838
|
if (config.mcpToolsEnabled && config.authToken) {
|
|
892
|
-
await registerMcpTools(pi, client, config);
|
|
839
|
+
await registerMcpTools(pi, client, config, recallTimeoutBreaker);
|
|
893
840
|
}
|
|
894
841
|
};
|
|
895
842
|
}
|
|
896
843
|
async function remnicPiExtension(pi) {
|
|
897
844
|
await createRemnicPiExtension()(pi);
|
|
898
845
|
}
|
|
899
|
-
function registerCommands(pi, client, config) {
|
|
846
|
+
function registerCommands(pi, client, config, recallTimeoutBreaker) {
|
|
900
847
|
pi.registerCommand("remnic-status", {
|
|
901
848
|
description: "Check Remnic daemon status",
|
|
902
849
|
handler: commandHandler(async (_args, _ctx, session) => {
|
|
850
|
+
if (recallTimeoutBreaker.isTripped()) notifyRecallDisabled(session);
|
|
903
851
|
const health = await client.health();
|
|
904
852
|
client.markReachable();
|
|
905
853
|
session.notify(`Remnic ${health.ok ? "healthy" : "unhealthy"} at ${config.remnicDaemonUrl}`, health.ok ? "success" : "warning");
|
|
@@ -913,9 +861,20 @@ function registerCommands(pi, client, config) {
|
|
|
913
861
|
session.notify("Usage: /remnic-recall <query>", "warning");
|
|
914
862
|
return;
|
|
915
863
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
+
);
|
|
919
878
|
client.markReachable();
|
|
920
879
|
session.notify(trimContext(result.context ?? "(no Remnic context)", MAX_CONTEXT_CHARS), "info");
|
|
921
880
|
})
|
|
@@ -970,7 +929,7 @@ function commandHandler(handler) {
|
|
|
970
929
|
}
|
|
971
930
|
};
|
|
972
931
|
}
|
|
973
|
-
async function registerMcpTools(pi, client, config) {
|
|
932
|
+
async function registerMcpTools(pi, client, config, recallTimeoutBreaker) {
|
|
974
933
|
let tools = [];
|
|
975
934
|
try {
|
|
976
935
|
tools = await client.mcpListTools({ timeoutMs: config.startupRequestTimeoutMs });
|
|
@@ -985,7 +944,7 @@ async function registerMcpTools(pi, client, config) {
|
|
|
985
944
|
label: tool.name,
|
|
986
945
|
description: tool.description ?? `Call ${tool.name}`,
|
|
987
946
|
parameters: toPiToolParametersSchema(tool.inputSchema),
|
|
988
|
-
async execute(_toolCallId, params,
|
|
947
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
989
948
|
const session = snapshotPiContext(ctx);
|
|
990
949
|
if (!session) {
|
|
991
950
|
return {
|
|
@@ -993,13 +952,29 @@ async function registerMcpTools(pi, client, config) {
|
|
|
993
952
|
details: { skipped: true, reason: "stale_context" }
|
|
994
953
|
};
|
|
995
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
|
+
}
|
|
996
962
|
const safeParams = stripSessionOwnedRuntimeFields(params ?? {});
|
|
997
|
-
const
|
|
963
|
+
const toolArguments = {
|
|
998
964
|
...safeParams,
|
|
999
965
|
sessionKey: session.sessionKey,
|
|
1000
966
|
namespace: config.namespace,
|
|
1001
967
|
cwd: session.cwd
|
|
1002
|
-
}
|
|
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 });
|
|
1003
978
|
return {
|
|
1004
979
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1005
980
|
details: result
|
|
@@ -1376,6 +1351,43 @@ function isDaemonUnreachableError(err) {
|
|
|
1376
1351
|
function errorMessage(err) {
|
|
1377
1352
|
return err instanceof Error ? err.message : String(err);
|
|
1378
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
|
+
}
|
|
1379
1391
|
function finiteTokenCount(value) {
|
|
1380
1392
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
1381
1393
|
}
|
|
@@ -1388,6 +1400,7 @@ export {
|
|
|
1388
1400
|
remnicPiExtension as default,
|
|
1389
1401
|
isDaemonUnreachableError,
|
|
1390
1402
|
observeMessages,
|
|
1403
|
+
resetProcessRecallBreakerForTest,
|
|
1391
1404
|
stripSessionOwnedRuntimeFields,
|
|
1392
1405
|
stripSessionOwnedSchemaFields,
|
|
1393
1406
|
textFromMessage,
|