@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/dist/index.js CHANGED
@@ -1,198 +1,11 @@
1
1
  import {
2
- REMNIC_PI_EXTENSION_DIR_NAME,
3
- resolvePiAgentHome
4
- } from "./chunk-ASGQGBO2.js";
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 path2 = `/engram/v1/namespace/writable${qs ? `?${qs}` : ""}`;
85
+ const path = `/engram/v1/namespace/writable${qs ? `?${qs}` : ""}`;
259
86
  try {
260
87
  const payload = await this.request(
261
88
  "GET",
262
- path2,
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 = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : this.config.requestTimeoutMs;
398
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
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 (isAbortError(err)) {
436
- throw new Error(`Remnic request timed out after ${timeoutMs}ms`);
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
- return new Promise((resolve) => setTimeout(resolve, ms));
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("remnic", remnicStatusLabel(probe, config.namespace));
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 client.recall(promptText, session.sessionKey, session.cwd, {
800
- timeoutMs: config.turnRequestTimeoutMs
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
- const result = await client.recall(query, session.sessionKey, session.cwd, {
917
- timeoutMs: config.requestTimeoutMs
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, _signal, _onUpdate, ctx) {
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 result = await client.mcpTool(tool.name, {
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,