robrain 2.4.9 → 2.4.11

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.
Files changed (51) hide show
  1. package/dist/commands/check.js +2 -2
  2. package/dist/commands/check.js.map +1 -1
  3. package/dist/commands/explain.js +2 -2
  4. package/dist/commands/explain.js.map +1 -1
  5. package/dist/commands/export-memory.js +2 -2
  6. package/dist/commands/export-memory.js.map +1 -1
  7. package/dist/commands/init-project.js +4 -4
  8. package/dist/commands/init-project.js.map +1 -1
  9. package/dist/commands/inject.d.ts +10 -0
  10. package/dist/commands/inject.d.ts.map +1 -1
  11. package/dist/commands/inject.js +52 -6
  12. package/dist/commands/inject.js.map +1 -1
  13. package/dist/commands/inject.test.d.ts +2 -0
  14. package/dist/commands/inject.test.d.ts.map +1 -0
  15. package/dist/commands/inject.test.js +170 -0
  16. package/dist/commands/inject.test.js.map +1 -0
  17. package/dist/commands/install.js +5 -5
  18. package/dist/commands/install.js.map +1 -1
  19. package/dist/commands/outcomes.js +4 -4
  20. package/dist/commands/outcomes.js.map +1 -1
  21. package/dist/commands/review.js +7 -7
  22. package/dist/commands/review.js.map +1 -1
  23. package/dist/commands/status.d.ts.map +1 -1
  24. package/dist/commands/status.js +13 -3
  25. package/dist/commands/status.js.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/lib/config.d.ts +17 -0
  29. package/dist/lib/config.d.ts.map +1 -1
  30. package/dist/lib/config.js +45 -0
  31. package/dist/lib/config.js.map +1 -1
  32. package/dist/lib/config.test.d.ts +2 -0
  33. package/dist/lib/config.test.d.ts.map +1 -0
  34. package/dist/lib/config.test.js +75 -0
  35. package/dist/lib/config.test.js.map +1 -0
  36. package/dist/lib/editor.d.ts.map +1 -1
  37. package/dist/lib/editor.js +2 -0
  38. package/dist/lib/editor.js.map +1 -1
  39. package/dist/lib/spinner.d.ts +27 -0
  40. package/dist/lib/spinner.d.ts.map +1 -0
  41. package/dist/lib/spinner.js +41 -0
  42. package/dist/lib/spinner.js.map +1 -0
  43. package/dist/lib/spinner.test.d.ts +2 -0
  44. package/dist/lib/spinner.test.d.ts.map +1 -0
  45. package/dist/lib/spinner.test.js +39 -0
  46. package/dist/lib/spinner.test.js.map +1 -0
  47. package/package.json +2 -2
  48. package/vendor/sensing-mcp/dist/index.js +128 -48
  49. package/vendor/sensing-mcp/package.json +1 -1
  50. package/vendor/synthesis/dist/index.js +48 -14
  51. package/vendor/synthesis/package.json +1 -1
@@ -6440,6 +6440,21 @@ var require_llm = __commonJS({
6440
6440
  }
6441
6441
  var OPENAI_MAX_ATTEMPTS = 4;
6442
6442
  var OPENAI_BASE_DELAY_MS = 400;
6443
+ function waitBeforeRetry(attempt, signal) {
6444
+ signal?.throwIfAborted();
6445
+ const delayMs = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
6446
+ return new Promise((resolve, reject) => {
6447
+ const onAbort = () => {
6448
+ clearTimeout(timer);
6449
+ reject(signal?.reason);
6450
+ };
6451
+ const timer = setTimeout(() => {
6452
+ signal?.removeEventListener("abort", onAbort);
6453
+ resolve();
6454
+ }, delayMs);
6455
+ signal?.addEventListener("abort", onAbort, { once: true });
6456
+ });
6457
+ }
6443
6458
  async function openaiChat(params) {
6444
6459
  const baseUrl = params.baseUrl ?? resolveOpenAiBaseUrl2();
6445
6460
  if (!params.apiKey && baseUrl === exports.DEFAULT_OPENAI_BASE_URL) {
@@ -6463,10 +6478,16 @@ var require_llm = __commonJS({
6463
6478
  const res = await fetch(`${baseUrl}/chat/completions`, {
6464
6479
  method: "POST",
6465
6480
  headers,
6466
- body
6481
+ body,
6482
+ signal: params.signal
6467
6483
  });
6468
6484
  if (res.ok) {
6469
6485
  const data = await res.json();
6486
+ params.onUsage?.({
6487
+ inputTokens: data.usage?.prompt_tokens ?? 0,
6488
+ outputTokens: data.usage?.completion_tokens ?? 0,
6489
+ attempts: attempt + 1
6490
+ });
6470
6491
  return data.choices?.[0]?.message?.content ?? "";
6471
6492
  }
6472
6493
  lastErr = `${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
@@ -6474,8 +6495,7 @@ var require_llm = __commonJS({
6474
6495
  if (!retriable || attempt >= OPENAI_MAX_ATTEMPTS - 1) {
6475
6496
  throw new Error(`OpenAI chat failed: ${lastErr}`);
6476
6497
  }
6477
- const delay = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
6478
- await new Promise((r) => setTimeout(r, delay));
6498
+ await waitBeforeRetry(attempt, params.signal);
6479
6499
  }
6480
6500
  throw new Error(`OpenAI chat failed: ${lastErr}`);
6481
6501
  }
@@ -6499,10 +6519,16 @@ var require_llm = __commonJS({
6499
6519
  "anthropic-version": "2023-06-01",
6500
6520
  "Content-Type": "application/json"
6501
6521
  },
6502
- body
6522
+ body,
6523
+ signal: params.signal
6503
6524
  });
6504
6525
  if (res.ok) {
6505
6526
  const data = await res.json();
6527
+ params.onUsage?.({
6528
+ inputTokens: data.usage?.input_tokens ?? 0,
6529
+ outputTokens: data.usage?.output_tokens ?? 0,
6530
+ attempts: attempt + 1
6531
+ });
6506
6532
  const block = data.content?.[0];
6507
6533
  return block?.type === "text" ? block.text ?? "" : "";
6508
6534
  }
@@ -6511,8 +6537,7 @@ var require_llm = __commonJS({
6511
6537
  if (!retriable || attempt >= OPENAI_MAX_ATTEMPTS - 1) {
6512
6538
  throw new Error(`Anthropic chat failed: ${lastErr}`);
6513
6539
  }
6514
- const delay = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
6515
- await new Promise((r) => setTimeout(r, delay));
6540
+ await waitBeforeRetry(attempt, params.signal);
6516
6541
  }
6517
6542
  throw new Error(`Anthropic chat failed: ${lastErr}`);
6518
6543
  }
@@ -7170,7 +7195,7 @@ var require_extract_decision = __commonJS({
7170
7195
  "../shared/dist/extract-decision.js"(exports) {
7171
7196
  "use strict";
7172
7197
  Object.defineProperty(exports, "__esModule", { value: true });
7173
- exports.LlmKeyMissingError = exports.DECISION_EXTRACTION_SYSTEM_PROMPT = void 0;
7198
+ exports.LlmKeyMissingError = exports.SELF_HOSTED_USER_RULE_OVERRIDE = exports.DECISION_EXTRACTION_SYSTEM_PROMPT = void 0;
7174
7199
  exports.stripMarkdownJsonFence = stripMarkdownJsonFence;
7175
7200
  exports.parseExtractedDecision = parseExtractedDecision;
7176
7201
  exports.extractDecisionLlm = extractDecisionLlm2;
@@ -7200,6 +7225,9 @@ Fields:
7200
7225
  Output ONLY valid JSON. If no decision: {"decision": null, "rationale": null, "rejected": [], "confidence": 0}.
7201
7226
  Never add explanation outside the JSON.
7202
7227
  Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"option": string, "reason": string}], "confidence": number}`;
7228
+ exports.SELF_HOSTED_USER_RULE_OVERRIDE = `User-stated rule wins:
7229
+ - A durable rule is a standing instruction for future work in this repo or team (e.g. "from now on", "always", "never", "we no longer"). An instruction scoped to the current task, file, or step (e.g. "don't use that file", "stop logging here", "don't add tests for this one") is not durable; apply the rules above to it.
7230
+ - If the user states a durable rule in this turn, extract that rule even when the assistant refuses it, cites an older rule, or says it will not follow the new one. In that case do not return null, and do not extract the older rule the assistant restates.`;
7203
7231
  var LlmKeyMissingError2 = class extends Error {
7204
7232
  constructor(message) {
7205
7233
  super(message);
@@ -7207,10 +7235,9 @@ Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"optio
7207
7235
  }
7208
7236
  };
7209
7237
  exports.LlmKeyMissingError = LlmKeyMissingError2;
7238
+ var EXTRACTION_MAX_TOKENS = 600;
7210
7239
  function stripMarkdownJsonFence(raw) {
7211
- const t = raw.trim();
7212
- const m = t.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
7213
- return m?.[1]?.trim() ?? t;
7240
+ return raw.trim().replace(/^```[a-z]*\s*/i, "").replace(/\s*```\s*$/, "").trim();
7214
7241
  }
7215
7242
  function parseExtractedDecision(rawText) {
7216
7243
  const raw = JSON.parse(stripMarkdownJsonFence(rawText));
@@ -7227,6 +7254,9 @@ Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"optio
7227
7254
  const userPrompt = `Session turn:
7228
7255
  User: ${userMessage}
7229
7256
  Claude: ${claudeReply}`;
7257
+ const systemPrompt = cfg.extraSystemRules ? `${exports.DECISION_EXTRACTION_SYSTEM_PROMPT}
7258
+
7259
+ ${cfg.extraSystemRules}` : exports.DECISION_EXTRACTION_SYSTEM_PROMPT;
7230
7260
  let rawText;
7231
7261
  if (cfg.provider === "openai") {
7232
7262
  const baseUrl = cfg.openaiBaseUrl ?? (0, llm_js_1.resolveOpenAiBaseUrl)();
@@ -7236,9 +7266,9 @@ Claude: ${claudeReply}`;
7236
7266
  rawText = await (0, llm_js_1.openaiChat)({
7237
7267
  apiKey: cfg.openaiApiKey ?? "",
7238
7268
  model: cfg.openaiModel,
7239
- system: exports.DECISION_EXTRACTION_SYSTEM_PROMPT,
7269
+ system: systemPrompt,
7240
7270
  user: userPrompt,
7241
- maxTokens: 300,
7271
+ maxTokens: EXTRACTION_MAX_TOKENS,
7242
7272
  baseUrl,
7243
7273
  json: true
7244
7274
  });
@@ -7249,9 +7279,13 @@ Claude: ${claudeReply}`;
7249
7279
  rawText = await (0, llm_js_1.anthropicChat)({
7250
7280
  apiKey: cfg.anthropicApiKey.trim(),
7251
7281
  model: cfg.anthropicModel,
7252
- system: exports.DECISION_EXTRACTION_SYSTEM_PROMPT,
7282
+ system: systemPrompt,
7253
7283
  user: userPrompt,
7254
- maxTokens: 300
7284
+ maxTokens: EXTRACTION_MAX_TOKENS,
7285
+ // Same turn, same extraction: the Anthropic API samples at temperature 1
7286
+ // unless told otherwise, which made a clear decision come back as
7287
+ // "no decision" on some runs and get extracted on others.
7288
+ temperature: 0
7255
7289
  });
7256
7290
  }
7257
7291
  return parseExtractedDecision(rawText);
@@ -22899,7 +22933,8 @@ async function extractDecision(turn) {
22899
22933
  anthropicModel: config2.anthropicModel,
22900
22934
  openaiApiKey: config2.openaiApiKey,
22901
22935
  openaiModel: config2.openaiLlmModel,
22902
- openaiBaseUrl: config2.openaiBaseUrl
22936
+ openaiBaseUrl: config2.openaiBaseUrl,
22937
+ extraSystemRules: import_shared4.SELF_HOSTED_USER_RULE_OVERRIDE
22903
22938
  });
22904
22939
  lastClassifierFailure = null;
22905
22940
  return extracted;
@@ -23035,7 +23070,10 @@ var init_classifiers = __esm({
23035
23070
  /npm install|pnpm add|yarn add/
23036
23071
  ];
23037
23072
  IMPLICIT_USER_COMMITMENT_PATTERNS = [
23038
- /\blet['’]s\s+(standardize|standardise|use|go with|stick with|adopt)\b/i
23073
+ /\blet['’]s\s+(standardize|standardise|use|go with|stick with|adopt)\b/i,
23074
+ /\b(from now on|going forward)\b/i,
23075
+ /\b(always|never)\s+(use|write|add|call|prefer|put|name|run|import|suggest|commit|push|merge|install|mock)\b/i,
23076
+ /\b(don['’]t|do not|stop|no longer)\s+(use|using|write|writing|add|adding|call|calling|suggest|suggesting|import|importing)\b/i
23039
23077
  ];
23040
23078
  embeddingWindows = /* @__PURE__ */ new Map();
23041
23079
  recentFilesPerSession = /* @__PURE__ */ new Map();
@@ -23100,8 +23138,9 @@ async function routeDecisionSignal(signal, projectId) {
23100
23138
  userFacing: soft || "Perception did not persist this signal (discarded or below threshold)."
23101
23139
  };
23102
23140
  }
23103
- if (payload.action === "written" || payload.action === "deduped") {
23104
- return { persisted: true };
23141
+ if (payload.action === "written" || payload.action === "deduped" || payload.action === "conflict_flagged" || payload.action === "auto_resolved_supersession" || payload.action === "quarantined") {
23142
+ const notice = payload.conflict_notice?.trim();
23143
+ return { persisted: true, ...notice ? { conflictNotice: notice } : {} };
23105
23144
  }
23106
23145
  const softFallback = [payload.message].filter(Boolean).join(" ");
23107
23146
  console.error(
@@ -23187,6 +23226,25 @@ var init_router = __esm({
23187
23226
  }
23188
23227
  });
23189
23228
 
23229
+ // src/pending-notices.ts
23230
+ function rememberConflictNotice(notices, sessionId, notice, maxSessions = MAX_PENDING_CONFLICT_NOTICES) {
23231
+ const held = notices.get(sessionId) ?? [];
23232
+ const alreadyHeld = notices.has(sessionId);
23233
+ if (!held.includes(notice)) held.push(notice);
23234
+ if (!alreadyHeld && notices.size >= maxSessions) {
23235
+ const oldestSessionId = notices.keys().next().value;
23236
+ if (oldestSessionId !== void 0) notices.delete(oldestSessionId);
23237
+ }
23238
+ notices.set(sessionId, held);
23239
+ }
23240
+ var MAX_PENDING_CONFLICT_NOTICES;
23241
+ var init_pending_notices = __esm({
23242
+ "src/pending-notices.ts"() {
23243
+ "use strict";
23244
+ MAX_PENDING_CONFLICT_NOTICES = 1e3;
23245
+ }
23246
+ });
23247
+
23190
23248
  // src/session-registry.ts
23191
23249
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
23192
23250
  import { dirname } from "node:path";
@@ -23286,6 +23344,15 @@ __export(server_exports, {
23286
23344
  sessionRegistry: () => sessionRegistry
23287
23345
  });
23288
23346
  import { randomBytes } from "node:crypto";
23347
+ function holdConflictNotice(sessionId, notice) {
23348
+ if (!sessionRegistry.get(sessionId)) return;
23349
+ rememberConflictNotice(pendingConflictNotices, sessionId, notice, MAX_PENDING_CONFLICT_NOTICES);
23350
+ }
23351
+ function takeConflictNotice(sessionId) {
23352
+ const held = pendingConflictNotices.get(sessionId);
23353
+ pendingConflictNotices.delete(sessionId);
23354
+ return held?.length ? held.join(" ") : null;
23355
+ }
23289
23356
  function generateSessionId() {
23290
23357
  return `${(/* @__PURE__ */ new Date()).toISOString()}-${randomBytes(2).toString("hex")}`;
23291
23358
  }
@@ -23297,7 +23364,7 @@ function resolveSessionId(raw) {
23297
23364
  function buildServer() {
23298
23365
  const server = new McpServer({
23299
23366
  name: "sensing-mcp",
23300
- version: "2.4.9"
23367
+ version: "2.4.11"
23301
23368
  });
23302
23369
  server.tool(
23303
23370
  "sensing_start_session",
@@ -23332,9 +23399,10 @@ function buildServer() {
23332
23399
  };
23333
23400
  }
23334
23401
  );
23402
+ const recordTurnDescription = "Record a completed conversation turn (user message + Claude reply). Call this after every exchange. Survives server restarts \u2014 keep using the same session_id even if the server reconnected mid-conversation. Returns whether a topic shift was detected \u2014 if true, call your context injection tool." + (isThinMode() ? "" : " A new durable rule from the user is still recorded when it conflicts with an older one. If conflict_notice is set, tell the user that sentence; it can refer to a rule from an earlier turn. Both decisions were saved. It is a heads-up, not a stop.");
23335
23403
  server.tool(
23336
23404
  "sensing_record_turn",
23337
- "Record a completed conversation turn (user message + Claude reply). Call this after every exchange. Survives server restarts \u2014 keep using the same session_id even if the server reconnected mid-conversation. Returns whether a topic shift was detected \u2014 if true, call your context injection tool.",
23405
+ recordTurnDescription,
23338
23406
  {
23339
23407
  session_id: external_exports.string().describe("Session identifier from sensing_start_session"),
23340
23408
  sequence: external_exports.number().int().describe("Turn number within the session, starting at 1"),
@@ -23396,31 +23464,9 @@ function buildServer() {
23396
23464
  console.error("[Sensing] Topic shift classifier error:", err);
23397
23465
  }
23398
23466
  const projectId = session.project_id;
23399
- setImmediate(async () => {
23400
- try {
23401
- if (isThinMode()) {
23402
- const errors = await routeFlushTurns([turn], projectId);
23403
- if (errors.length === 0) {
23404
- lastDecisionShipFailure = null;
23405
- streamBuffer.markClassified(session_id, sequence);
23406
- } else {
23407
- lastDecisionShipFailure = `${session_id} seq ${sequence}: ${errors.join("; ")}`;
23408
- }
23409
- return;
23410
- }
23411
- const decisionSignal = await classifyDecision(turn, projectId);
23412
- if (decisionSignal) {
23413
- const outcome = await routeDecisionSignal(decisionSignal, projectId);
23414
- if (outcome.persisted) {
23415
- lastDecisionShipFailure = null;
23416
- streamBuffer.markClassified(session_id, sequence);
23417
- } else {
23418
- lastDecisionShipFailure = outcome.userFacing ?? `${session_id} seq ${sequence}: Perception did not persist signal (see Sensing stderr / Perception logs)`;
23419
- }
23420
- }
23421
- } catch (err) {
23422
- console.error("[Sensing] Decision classifier error:", err);
23423
- }
23467
+ const conflictNotice = takeConflictNotice(session_id);
23468
+ setImmediate(() => {
23469
+ void (isThinMode() ? shipThinModeTurn(turn, projectId, session_id, sequence) : shipRecordedTurn(turn, projectId, session_id, sequence));
23424
23470
  });
23425
23471
  if (injected_memory_ids.length > 0) {
23426
23472
  setImmediate(async () => {
@@ -23448,7 +23494,8 @@ function buildServer() {
23448
23494
  topic_shift: topicShift,
23449
23495
  task_description: taskDescription,
23450
23496
  secrets_redacted: secretsRedacted,
23451
- sequence
23497
+ sequence,
23498
+ ...conflictNotice ? { conflict_notice: conflictNotice } : {}
23452
23499
  })
23453
23500
  }]
23454
23501
  };
@@ -23481,6 +23528,7 @@ function buildServer() {
23481
23528
  }).catch((err) => console.error("[Sensing] Flush-on-close failed:", err));
23482
23529
  sessionRegistry.remove(session_id);
23483
23530
  clearSessionEmbeddings(session_id);
23531
+ const conflictNotice = takeConflictNotice(session_id);
23484
23532
  console.error(`[Sensing] Session ended: ${session_id} \u2014 flushing ${flushed} turn(s) in background`);
23485
23533
  return {
23486
23534
  content: [{
@@ -23489,7 +23537,8 @@ function buildServer() {
23489
23537
  session_id,
23490
23538
  flushed,
23491
23539
  pending: flushed,
23492
- summary: summary ?? null
23540
+ summary: summary ?? null,
23541
+ ...conflictNotice ? { conflict_notice: conflictNotice } : {}
23493
23542
  })
23494
23543
  }]
23495
23544
  };
@@ -23528,6 +23577,35 @@ function buildServer() {
23528
23577
  );
23529
23578
  return server;
23530
23579
  }
23580
+ async function shipThinModeTurn(turn, projectId, sessionId, sequence) {
23581
+ try {
23582
+ const errors = await routeFlushTurns([turn], projectId);
23583
+ if (errors.length === 0) {
23584
+ lastDecisionShipFailure = null;
23585
+ streamBuffer.markClassified(sessionId, sequence);
23586
+ } else {
23587
+ lastDecisionShipFailure = `${sessionId} seq ${sequence}: ${errors.join("; ")}`;
23588
+ }
23589
+ } catch (err) {
23590
+ console.error("[Sensing] Decision classifier error:", err);
23591
+ }
23592
+ }
23593
+ async function shipRecordedTurn(turn, projectId, sessionId, sequence) {
23594
+ try {
23595
+ const decisionSignal = await classifyDecision(turn, projectId);
23596
+ if (!decisionSignal) return;
23597
+ const outcome = await routeDecisionSignal(decisionSignal, projectId);
23598
+ if (outcome.persisted) {
23599
+ lastDecisionShipFailure = null;
23600
+ streamBuffer.markClassified(sessionId, sequence);
23601
+ if (outcome.conflictNotice) holdConflictNotice(sessionId, outcome.conflictNotice);
23602
+ return;
23603
+ }
23604
+ lastDecisionShipFailure = outcome.userFacing ?? `${sessionId} seq ${sequence}: Perception did not persist signal (see Sensing stderr / Perception logs)`;
23605
+ } catch (err) {
23606
+ console.error("[Sensing] Decision classifier error:", err);
23607
+ }
23608
+ }
23531
23609
  function withTimeout(promise, ms, fallback) {
23532
23610
  promise.catch((err) => {
23533
23611
  console.error("[Sensing] Suppressed late rejection after timeout:", err);
@@ -23572,7 +23650,7 @@ async function fetchAlwaysOnSummary(projectId) {
23572
23650
  return { text: `Project: ${projectId}` };
23573
23651
  }
23574
23652
  }
23575
- var import_shared5, sessionRegistry, lastDecisionShipFailure;
23653
+ var import_shared5, sessionRegistry, lastDecisionShipFailure, pendingConflictNotices;
23576
23654
  var init_server3 = __esm({
23577
23655
  "src/server.ts"() {
23578
23656
  "use strict";
@@ -23583,9 +23661,11 @@ var init_server3 = __esm({
23583
23661
  init_classifiers();
23584
23662
  init_router();
23585
23663
  init_config();
23664
+ init_pending_notices();
23586
23665
  init_session_registry();
23587
23666
  sessionRegistry = new SessionRegistry(config2.sessionRegistryPath);
23588
23667
  lastDecisionShipFailure = null;
23668
+ pendingConflictNotices = /* @__PURE__ */ new Map();
23589
23669
  }
23590
23670
  });
23591
23671
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robrain/sensing-mcp",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js"
@@ -11704,6 +11704,21 @@ var require_llm = __commonJS({
11704
11704
  }
11705
11705
  var OPENAI_MAX_ATTEMPTS = 4;
11706
11706
  var OPENAI_BASE_DELAY_MS = 400;
11707
+ function waitBeforeRetry(attempt, signal) {
11708
+ signal?.throwIfAborted();
11709
+ const delayMs = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
11710
+ return new Promise((resolve, reject) => {
11711
+ const onAbort = () => {
11712
+ clearTimeout(timer);
11713
+ reject(signal?.reason);
11714
+ };
11715
+ const timer = setTimeout(() => {
11716
+ signal?.removeEventListener("abort", onAbort);
11717
+ resolve();
11718
+ }, delayMs);
11719
+ signal?.addEventListener("abort", onAbort, { once: true });
11720
+ });
11721
+ }
11707
11722
  async function openaiChat2(params) {
11708
11723
  const baseUrl = params.baseUrl ?? resolveOpenAiBaseUrl2();
11709
11724
  if (!params.apiKey && baseUrl === exports.DEFAULT_OPENAI_BASE_URL) {
@@ -11727,10 +11742,16 @@ var require_llm = __commonJS({
11727
11742
  const res = await fetch(`${baseUrl}/chat/completions`, {
11728
11743
  method: "POST",
11729
11744
  headers,
11730
- body
11745
+ body,
11746
+ signal: params.signal
11731
11747
  });
11732
11748
  if (res.ok) {
11733
11749
  const data = await res.json();
11750
+ params.onUsage?.({
11751
+ inputTokens: data.usage?.prompt_tokens ?? 0,
11752
+ outputTokens: data.usage?.completion_tokens ?? 0,
11753
+ attempts: attempt + 1
11754
+ });
11734
11755
  return data.choices?.[0]?.message?.content ?? "";
11735
11756
  }
11736
11757
  lastErr = `${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
@@ -11738,8 +11759,7 @@ var require_llm = __commonJS({
11738
11759
  if (!retriable || attempt >= OPENAI_MAX_ATTEMPTS - 1) {
11739
11760
  throw new Error(`OpenAI chat failed: ${lastErr}`);
11740
11761
  }
11741
- const delay = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
11742
- await new Promise((r2) => setTimeout(r2, delay));
11762
+ await waitBeforeRetry(attempt, params.signal);
11743
11763
  }
11744
11764
  throw new Error(`OpenAI chat failed: ${lastErr}`);
11745
11765
  }
@@ -11763,10 +11783,16 @@ var require_llm = __commonJS({
11763
11783
  "anthropic-version": "2023-06-01",
11764
11784
  "Content-Type": "application/json"
11765
11785
  },
11766
- body
11786
+ body,
11787
+ signal: params.signal
11767
11788
  });
11768
11789
  if (res.ok) {
11769
11790
  const data = await res.json();
11791
+ params.onUsage?.({
11792
+ inputTokens: data.usage?.input_tokens ?? 0,
11793
+ outputTokens: data.usage?.output_tokens ?? 0,
11794
+ attempts: attempt + 1
11795
+ });
11770
11796
  const block = data.content?.[0];
11771
11797
  return block?.type === "text" ? block.text ?? "" : "";
11772
11798
  }
@@ -11775,8 +11801,7 @@ var require_llm = __commonJS({
11775
11801
  if (!retriable || attempt >= OPENAI_MAX_ATTEMPTS - 1) {
11776
11802
  throw new Error(`Anthropic chat failed: ${lastErr}`);
11777
11803
  }
11778
- const delay = OPENAI_BASE_DELAY_MS * 2 ** attempt + Math.floor(Math.random() * 150);
11779
- await new Promise((r2) => setTimeout(r2, delay));
11804
+ await waitBeforeRetry(attempt, params.signal);
11780
11805
  }
11781
11806
  throw new Error(`Anthropic chat failed: ${lastErr}`);
11782
11807
  }
@@ -12434,7 +12459,7 @@ var require_extract_decision = __commonJS({
12434
12459
  "../shared/dist/extract-decision.js"(exports) {
12435
12460
  "use strict";
12436
12461
  Object.defineProperty(exports, "__esModule", { value: true });
12437
- exports.LlmKeyMissingError = exports.DECISION_EXTRACTION_SYSTEM_PROMPT = void 0;
12462
+ exports.LlmKeyMissingError = exports.SELF_HOSTED_USER_RULE_OVERRIDE = exports.DECISION_EXTRACTION_SYSTEM_PROMPT = void 0;
12438
12463
  exports.stripMarkdownJsonFence = stripMarkdownJsonFence2;
12439
12464
  exports.parseExtractedDecision = parseExtractedDecision;
12440
12465
  exports.extractDecisionLlm = extractDecisionLlm;
@@ -12464,6 +12489,9 @@ Fields:
12464
12489
  Output ONLY valid JSON. If no decision: {"decision": null, "rationale": null, "rejected": [], "confidence": 0}.
12465
12490
  Never add explanation outside the JSON.
12466
12491
  Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"option": string, "reason": string}], "confidence": number}`;
12492
+ exports.SELF_HOSTED_USER_RULE_OVERRIDE = `User-stated rule wins:
12493
+ - A durable rule is a standing instruction for future work in this repo or team (e.g. "from now on", "always", "never", "we no longer"). An instruction scoped to the current task, file, or step (e.g. "don't use that file", "stop logging here", "don't add tests for this one") is not durable; apply the rules above to it.
12494
+ - If the user states a durable rule in this turn, extract that rule even when the assistant refuses it, cites an older rule, or says it will not follow the new one. In that case do not return null, and do not extract the older rule the assistant restates.`;
12467
12495
  var LlmKeyMissingError = class extends Error {
12468
12496
  constructor(message) {
12469
12497
  super(message);
@@ -12471,10 +12499,9 @@ Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"optio
12471
12499
  }
12472
12500
  };
12473
12501
  exports.LlmKeyMissingError = LlmKeyMissingError;
12502
+ var EXTRACTION_MAX_TOKENS = 600;
12474
12503
  function stripMarkdownJsonFence2(raw) {
12475
- const t2 = raw.trim();
12476
- const m2 = t2.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
12477
- return m2?.[1]?.trim() ?? t2;
12504
+ return raw.trim().replace(/^```[a-z]*\s*/i, "").replace(/\s*```\s*$/, "").trim();
12478
12505
  }
12479
12506
  function parseExtractedDecision(rawText) {
12480
12507
  const raw = JSON.parse(stripMarkdownJsonFence2(rawText));
@@ -12491,6 +12518,9 @@ Schema: {"decision": string|null, "rationale": string|null, "rejected": [{"optio
12491
12518
  const userPrompt = `Session turn:
12492
12519
  User: ${userMessage}
12493
12520
  Claude: ${claudeReply}`;
12521
+ const systemPrompt = cfg.extraSystemRules ? `${exports.DECISION_EXTRACTION_SYSTEM_PROMPT}
12522
+
12523
+ ${cfg.extraSystemRules}` : exports.DECISION_EXTRACTION_SYSTEM_PROMPT;
12494
12524
  let rawText;
12495
12525
  if (cfg.provider === "openai") {
12496
12526
  const baseUrl = cfg.openaiBaseUrl ?? (0, llm_js_1.resolveOpenAiBaseUrl)();
@@ -12500,9 +12530,9 @@ Claude: ${claudeReply}`;
12500
12530
  rawText = await (0, llm_js_1.openaiChat)({
12501
12531
  apiKey: cfg.openaiApiKey ?? "",
12502
12532
  model: cfg.openaiModel,
12503
- system: exports.DECISION_EXTRACTION_SYSTEM_PROMPT,
12533
+ system: systemPrompt,
12504
12534
  user: userPrompt,
12505
- maxTokens: 300,
12535
+ maxTokens: EXTRACTION_MAX_TOKENS,
12506
12536
  baseUrl,
12507
12537
  json: true
12508
12538
  });
@@ -12513,9 +12543,13 @@ Claude: ${claudeReply}`;
12513
12543
  rawText = await (0, llm_js_1.anthropicChat)({
12514
12544
  apiKey: cfg.anthropicApiKey.trim(),
12515
12545
  model: cfg.anthropicModel,
12516
- system: exports.DECISION_EXTRACTION_SYSTEM_PROMPT,
12546
+ system: systemPrompt,
12517
12547
  user: userPrompt,
12518
- maxTokens: 300
12548
+ maxTokens: EXTRACTION_MAX_TOKENS,
12549
+ // Same turn, same extraction: the Anthropic API samples at temperature 1
12550
+ // unless told otherwise, which made a clear decision come back as
12551
+ // "no decision" on some runs and get extracted on others.
12552
+ temperature: 0
12519
12553
  });
12520
12554
  }
12521
12555
  return parseExtractedDecision(rawText);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robrain/synthesis",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js"