@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/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;
@@ -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("remnic", remnicStatusLabel(probe, config.namespace));
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("context", async (event, ctx) => {
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 (recallTarget.dedupeKey === state.lastInjectedRecallKey) return;
813
- try {
814
- const recalled = await client.recall(query, session.sessionKey, session.cwd, {
815
- timeoutMs: config.turnRequestTimeoutMs
816
- });
817
- client.markReachable();
818
- const context = trimContext(recalled.context ?? "", config.recallBudgetChars);
819
- if (!context) return;
820
- state.lastInjectedRecallKey = recallTarget.dedupeKey;
821
- return {
822
- messages: [
823
- ...event.messages,
824
- {
825
- role: "user",
826
- content: [{ type: "text", text: `Remnic recalled context for this turn:
827
-
828
- ${context}` }],
829
- remnicInjected: true,
830
- timestamp: Date.now()
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
- } catch (err) {
835
- if (isDaemonUnreachableError(err)) client.markUnreachable(config.daemonCooldownMs);
836
- session.notify(`Remnic recall unavailable: ${errorMessage(err)}`, "warning");
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
- const result = await client.recall(query, session.sessionKey, session.cwd, {
935
- timeoutMs: config.requestTimeoutMs
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, _signal, _onUpdate, ctx) {
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 result = await client.mcpTool(tool.name, {
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
- lastInjectedRecallKey: ""
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,