switchroom 0.19.27 → 0.19.29

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 (44) hide show
  1. package/dist/agent-scheduler/index.js +16 -2
  2. package/dist/auth-broker/index.js +141 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +18 -2
  5. package/dist/cli/switchroom.js +864 -43
  6. package/dist/host-control/main.js +144 -9
  7. package/dist/vault/approvals/kernel-server.js +146 -9
  8. package/dist/vault/broker/server.js +146 -9
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +79 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +585 -50
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/CHANGELOG.md +50 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
  29. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  30. package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
  31. package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
  32. package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
  33. package/vendor/hindsight-memory/scripts/retain.py +41 -1
  34. package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
  36. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
  42. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
  43. package/vendor/hindsight-memory/settings.json +1 -1
  44. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.27", COMMIT_SHA = "42779242";
2123
+ var VERSION = "0.19.29", COMMIT_SHA = "c618451c";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -13571,13 +13571,46 @@ var init_zod = __esm(() => {
13571
13571
  init_external();
13572
13572
  });
13573
13573
 
13574
+ // src/memory/observation-scopes.ts
13575
+ function isObservationScope(value) {
13576
+ return typeof value === "string" && OBSERVATION_SCOPES.includes(value);
13577
+ }
13578
+ function observationScopesHint() {
13579
+ return OBSERVATION_SCOPES.join(", ");
13580
+ }
13581
+ function classifyObservationScope(raw) {
13582
+ if (raw === undefined)
13583
+ return { kind: "unset" };
13584
+ const value = raw.trim();
13585
+ if (!value)
13586
+ return { kind: "unset" };
13587
+ if (!isObservationScope(value))
13588
+ return { kind: "invalid", raw };
13589
+ return { kind: "set", scope: value };
13590
+ }
13591
+ function resolveObservationScope(configured, env2 = process.env) {
13592
+ if (configured !== undefined)
13593
+ return classifyObservationScope(configured);
13594
+ return classifyObservationScope(env2[OBSERVATION_SCOPES_ENV]);
13595
+ }
13596
+ var OBSERVATION_SCOPES, OBSERVATION_SCOPES_ENV = "HINDSIGHT_OBSERVATION_SCOPES";
13597
+ var init_observation_scopes = __esm(() => {
13598
+ OBSERVATION_SCOPES = [
13599
+ "per_tag",
13600
+ "combined",
13601
+ "all_combinations",
13602
+ "shared"
13603
+ ];
13604
+ });
13605
+
13574
13606
  // src/config/schema.ts
13575
13607
  function isValidTimezone(value) {
13576
13608
  return TIMEZONE_REGEX.test(value);
13577
13609
  }
13578
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
13610
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
13579
13611
  var init_schema = __esm(() => {
13580
13612
  init_zod();
13613
+ init_observation_scopes();
13581
13614
  CodeRepoEntrySchema = exports_external.object({
13582
13615
  name: exports_external.string().describe("Short alias used when claiming (e.g. 'switchroom')"),
13583
13616
  source: exports_external.string().describe("Absolute or home-relative path to the repo (e.g. ~/code/switchroom)"),
@@ -13689,6 +13722,7 @@ var init_schema = __esm(() => {
13689
13722
  allow: exports_external.array(exports_external.string()).default([]).describe("Allowed tools (use ['all'] for unrestricted)"),
13690
13723
  deny: exports_external.array(exports_external.string()).default([]).describe("Denied tools (overrides allow)")
13691
13724
  }).optional();
13725
+ ObservationScopesSchema = exports_external.enum(OBSERVATION_SCOPES).optional().describe("Per-row observation scope stamped on every memory this agent " + `retains. "shared" makes Hindsight's consolidation write the ` + "resulting observations into ONE global untagged scope instead of a " + "scope per tag \u2014 what several agents pooling one bank need so their " + "observations actually merge rather than sitting in parallel " + "per-tag silos. OMITTED BY DEFAULT: unset means the field never goes " + "on the wire and the engine's own default stands, which is the " + "shipped behaviour. Applies to every retain path (Stop hook, " + "sidechain, boot reconcile, queue drain, backfill, session-handoff " + "mirror) and is carried on the queued payload, so a retain that fails " + "now and drains later still lands in this scope. Accepted values: " + `${OBSERVATION_SCOPES.join(", ")}. ` + "Cascade: override (per-agent wins over default).");
13692
13726
  AgentMemorySchema = exports_external.object({
13693
13727
  collection: exports_external.string().describe("Hindsight collection name for this agent"),
13694
13728
  auto_recall: exports_external.boolean().default(true).describe("Auto-search memories before each response"),
@@ -13723,6 +13757,7 @@ var init_schema = __esm(() => {
13723
13757
  empathy: exports_external.number().int().min(1).max(5).optional().describe("How much the bank weights emotional/relational context (1-5; engine default 3).")
13724
13758
  }).optional().describe("Personality traits (1-5 each) steering how this bank frames recall, " + "reflect, and observation synthesis \u2014 a coach leans empathy-high, a " + "lawyer/analyst leans skepticism/literalism-high. Maps to the engine's " + "flat `disposition_skepticism`/`_literalism`/`_empathy` fields. " + "Cascade: per-key merge (an agent overrides individual traits and " + "inherits the rest, matching `recall`)."),
13725
13759
  directive_capture_nudge: exports_external.boolean().optional().describe("Deterministic directive-capture nudge (issue #2848 Stage B). When " + "on (switchroom default true \u2014 Stage A measured a ~55% miss rate on " + "durable corrections), the auto-recall hook regex-detects correction " + '/ standing-rule-shaped inbound ("always/never \u2026", "from now on \u2026", ' + `"stop doing \u2026", a stated preference, "that's wrong, it's \u2026") and ` + "appends a terse advisory to the turn's context telling the model to " + "persist the rule with mcp__hindsight__create_directive if it IS " + "durable. Detection is pure regex \u2014 the model does the judgment " + "in-session and calls create_directive itself (no model callsite, no " + "silent hook-side write). Set false to disable per-agent. " + "Cascade: override (per-agent wins over default)."),
13760
+ observation_scopes: ObservationScopesSchema,
13726
13761
  recall: exports_external.object({
13727
13762
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
13728
13763
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
@@ -13966,7 +14001,10 @@ var init_schema = __esm(() => {
13966
14001
  admin_key: exports_external.string().optional().describe("LiteLLM master/admin key used at apply time to provision the team + " + "virtual key. Supports a vault reference (e.g. " + "'vault:litellm/master-key') \u2014 resolution happens at apply time via " + "the vault-broker. Never injected into the agent container."),
13967
14002
  team: exports_external.string().optional().describe("LiteLLM team alias the per-agent key is created under. Defaults to " + "'switchroom' (applied in code, not as a schema default)."),
13968
14003
  small_fast_model: exports_external.string().optional().describe("Model id exported as ANTHROPIC_SMALL_FAST_MODEL for the claude CLI's " + "background/fast lane, e.g. 'claude-haiku-4-5-20251001'."),
13969
- tags: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Extra key/value metadata tags attached to the provisioned LiteLLM " + "virtual key. Merged per-key across cascade layers (agent wins).")
14004
+ tags: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Extra key/value metadata tags attached to the provisioned LiteLLM " + "virtual key. Merged per-key across cascade layers (agent wins)."),
14005
+ max_budget: exports_external.number().positive().optional().describe("HARD spend cap in USD for this agent's virtual key over one " + "`budget_duration` window. LiteLLM refuses the request once the key's " + "tracked spend exceeds it, so a runaway loop costs at most this much " + "before it is stopped. Defaults to " + "DEFAULT_KEY_MAX_BUDGET_USD (see src/litellm/budget.ts) \u2014 deliberately " + "conservative; raise it per-agent rather than removing it. Set 0 or " + "omit `budget_duration` at your own risk: an uncapped key is only as " + "bounded as the upstream account balance."),
14006
+ soft_budget: exports_external.number().positive().optional().describe("ADVISORY spend threshold in USD. LiteLLM keeps serving past it and " + "raises a budget alert instead. Must be < max_budget. NOTE: LiteLLM " + "accepts soft_budget only on POST /key/generate (GenerateKeyRequest); " + "UpdateKeyRequest does NOT carry it, so changing this value only takes " + "effect on a key that is (re)generated, not on an existing one."),
14007
+ budget_duration: exports_external.string().regex(/^\d+(s|m|h|d|mo)$/, "budget_duration must be a LiteLLM duration like '30d', '24h', '1mo'").optional().describe("Rolling window the budget resets on, in LiteLLM duration syntax " + "('30d', '24h', '1mo'). Defaults to DEFAULT_KEY_BUDGET_DURATION. " + "WITHOUT a duration LiteLLM treats max_budget as a LIFETIME cap that " + "never resets \u2014 the key silently dies for good once it is hit.")
13970
14008
  }).optional().describe("LiteLLM routing config \u2014 opt-in per-agent virtual-key auto-provisioning " + "+ routing env. Default OFF. See LiteLLMConfigSchema doc for the full flow.");
13971
14009
  HindsightPerOpLlmSchema = exports_external.object({
13972
14010
  model: exports_external.string().min(1).optional().describe("Per-op model (upstream `HINDSIGHT_API_<OP>_LLM_MODEL`). Absent \u2192 " + "inherit the global `hindsight.llm.model`."),
@@ -13985,7 +14023,7 @@ var init_schema = __esm(() => {
13985
14023
  reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
13986
14024
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
13987
14025
  }).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
13988
- env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_MAX_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
14026
+ env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_MAX_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_MAX_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_MAX_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
13989
14027
  });
13990
14028
  MicrosoftWorkspaceConfigSchema = exports_external.object({
13991
14029
  microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
@@ -14118,6 +14156,7 @@ var init_schema = __esm(() => {
14118
14156
  file: exports_external.boolean().optional(),
14119
14157
  isolation: exports_external.enum(["default", "strict"]).optional(),
14120
14158
  directive_capture_nudge: exports_external.boolean().optional(),
14159
+ observation_scopes: ObservationScopesSchema,
14121
14160
  recall: exports_external.object({
14122
14161
  max_memories: exports_external.number().int().min(0).optional(),
14123
14162
  cache_ttl_secs: exports_external.number().int().min(0).optional(),
@@ -15765,6 +15804,12 @@ var init_hindsight_pg_defaults = __esm(() => {
15765
15804
  });
15766
15805
 
15767
15806
  // src/setup/hindsight-perf-defaults.ts
15807
+ function hindsightConsolidationLlmMaxConcurrentDefault(globalMaxConcurrent = HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT, retainMaxConcurrent = HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT) {
15808
+ const globalCap = Number.isFinite(globalMaxConcurrent) && globalMaxConcurrent >= 1 ? Math.floor(globalMaxConcurrent) : HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT;
15809
+ const retainCap = Number.isFinite(retainMaxConcurrent) && retainMaxConcurrent >= 0 ? Math.floor(retainMaxConcurrent) : HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT;
15810
+ const headroomBound = Math.max(1, globalCap - 1);
15811
+ return Math.min(headroomBound, Math.max(1, globalCap - retainCap - 1));
15812
+ }
15768
15813
  function resolveHindsightPerfOverrides(configEnv, processEnv = process.env) {
15769
15814
  const out = new Map;
15770
15815
  for (const key of HINDSIGHT_PERF_ENV_KEYS) {
@@ -15791,6 +15836,14 @@ function hindsightPerfEnv(caps, overrides = new Map) {
15791
15836
  groups.push(HINDSIGHT_PERF_DEFAULTS_GPU);
15792
15837
  if (caps.localLlm === true)
15793
15838
  groups.push(HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM);
15839
+ const effectiveCap = (key, fallback) => {
15840
+ const raw = overrides.get(key);
15841
+ if (raw === undefined)
15842
+ return fallback;
15843
+ const parsed = Number.parseInt(raw, 10);
15844
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
15845
+ };
15846
+ const derivedConsolidationCap = String(hindsightConsolidationLlmMaxConcurrentDefault(effectiveCap("HINDSIGHT_API_LLM_MAX_CONCURRENT", HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT), effectiveCap("HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT", HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT)));
15794
15847
  const out = [];
15795
15848
  const emitted = new Set;
15796
15849
  for (const group of groups) {
@@ -15798,7 +15851,8 @@ function hindsightPerfEnv(caps, overrides = new Map) {
15798
15851
  if (emitted.has(key))
15799
15852
  continue;
15800
15853
  emitted.add(key);
15801
- out.push([key, overrides.get(key) ?? value]);
15854
+ const shipped = key === "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT" ? derivedConsolidationCap : value;
15855
+ out.push([key, overrides.get(key) ?? shipped]);
15802
15856
  }
15803
15857
  }
15804
15858
  for (const key of [...overrides.keys()].sort()) {
@@ -15812,10 +15866,11 @@ function hindsightPerfEnv(caps, overrides = new Map) {
15812
15866
  function findUnmanagedHindsightEnvKeys(configEnv) {
15813
15867
  return Object.keys(configEnv ?? {}).filter((key) => !HINDSIGHT_PERF_ENV_KEYS.has(key) && !HINDSIGHT_PG_ENV_KEYS.has(key)).sort();
15814
15868
  }
15815
- var HINDSIGHT_RERANKER_MAX_CANDIDATES_FOR_DERIVATION = 150, HINDSIGHT_DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE, HINDSIGHT_DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 50, HINDSIGHT_DEFAULT_LINK_EXPANSION_TIMEOUT_S = 2, HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT = "low", HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT = 4, HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT = 1, HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT = 1, HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16 = "true", HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 128, HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA = "true", HINDSIGHT_DEFAULT_LLM_MAX_RETRIES = 2, HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM = 2, HINDSIGHT_DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = 1000, HINDSIGHT_DEFAULT_RERANKER_BUCKET_BATCHING = "true", HINDSIGHT_DEFAULT_RERANKER_MAX_CANDIDATES = 150, HINDSIGHT_DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4, HINDSIGHT_DEFAULT_RECALL_MAX_CONCURRENT = 8, HINDSIGHT_DEFAULT_REFLECT_WALL_TIMEOUT_S = 600, HINDSIGHT_DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = 500, HINDSIGHT_DEFAULT_CONSOLIDATION_SLOT_LIMIT = 6, HINDSIGHT_DEFAULT_CONSOLIDATION_MAX_SLOTS = 1, HINDSIGHT_PERF_DEFAULTS_UNGATED, HINDSIGHT_PERF_DEFAULTS_GPU, HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM, HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS, HINDSIGHT_PERF_ENV_KEYS;
15869
+ var HINDSIGHT_RERANKER_MAX_CANDIDATES_FOR_DERIVATION = 150, HINDSIGHT_DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE, HINDSIGHT_DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 50, HINDSIGHT_DEFAULT_LINK_EXPANSION_TIMEOUT_S = 2, HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT = "low", HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT = 4, HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT = 1, HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT, HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16 = "true", HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 128, HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA = "true", HINDSIGHT_DEFAULT_LLM_MAX_RETRIES = 2, HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM = 2, HINDSIGHT_DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = 1000, HINDSIGHT_DEFAULT_RERANKER_BUCKET_BATCHING = "true", HINDSIGHT_DEFAULT_RERANKER_MAX_CANDIDATES = 150, HINDSIGHT_DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4, HINDSIGHT_DEFAULT_RECALL_MAX_CONCURRENT = 8, HINDSIGHT_DEFAULT_REFLECT_WALL_TIMEOUT_S = 600, HINDSIGHT_DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = 500, HINDSIGHT_DEFAULT_CONSOLIDATION_SLOT_LIMIT = 6, HINDSIGHT_DEFAULT_CONSOLIDATION_MAX_SLOTS = 1, HINDSIGHT_DEFAULT_RECENCY_DECAY_FUNCTION = "exponential", HINDSIGHT_DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS = 30, HINDSIGHT_PERF_DEFAULTS_UNGATED, HINDSIGHT_PERF_DEFAULTS_GPU, HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM, HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS, HINDSIGHT_PERF_ENV_KEYS;
15816
15870
  var init_hindsight_perf_defaults = __esm(() => {
15817
15871
  init_hindsight_pg_defaults();
15818
15872
  HINDSIGHT_DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = Math.ceil(HINDSIGHT_RERANKER_MAX_CANDIDATES_FOR_DERIVATION * 0.4);
15873
+ HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT = hindsightConsolidationLlmMaxConcurrentDefault();
15819
15874
  HINDSIGHT_PERF_DEFAULTS_UNGATED = [
15820
15875
  [
15821
15876
  "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE",
@@ -15869,6 +15924,14 @@ var init_hindsight_perf_defaults = __esm(() => {
15869
15924
  [
15870
15925
  "HINDSIGHT_API_CONSOLIDATION_MAX_MEMORIES_PER_ROUND",
15871
15926
  String(HINDSIGHT_DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND)
15927
+ ],
15928
+ [
15929
+ "HINDSIGHT_API_RECENCY_DECAY_FUNCTION",
15930
+ HINDSIGHT_DEFAULT_RECENCY_DECAY_FUNCTION
15931
+ ],
15932
+ [
15933
+ "HINDSIGHT_API_RECENCY_DECAY_HALFLIFE_DAYS",
15934
+ String(HINDSIGHT_DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS)
15872
15935
  ]
15873
15936
  ];
15874
15937
  HINDSIGHT_PERF_DEFAULTS_GPU = [
@@ -15893,7 +15956,10 @@ var init_hindsight_perf_defaults = __esm(() => {
15893
15956
  ];
15894
15957
  HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
15895
15958
  "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY",
15896
- "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP"
15959
+ "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP",
15960
+ "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS",
15961
+ "HINDSIGHT_API_WORKER_MAX_SLOTS",
15962
+ "HINDSIGHT_API_WORKER_RETAIN_MAX_SLOTS"
15897
15963
  ]);
15898
15964
  HINDSIGHT_PERF_ENV_KEYS = new Set([
15899
15965
  ...[
@@ -16983,22 +17049,35 @@ function isHindsightEnabled(config) {
16983
17049
  return false;
16984
17050
  return config?.memory?.backend === "hindsight";
16985
17051
  }
16986
- function isUpgradableRetainMission(current) {
16987
- if (current == null || current.trim() === "")
16988
- return true;
16989
- return SUPERSEDED_RETAIN_MISSIONS.includes(current);
16990
- }
16991
17052
  function decideRetainMissionUpgrade(configured, current) {
17053
+ return decideMissionUpgrade(configured, current, DEFAULT_RETAIN_MISSION, SUPERSEDED_RETAIN_MISSIONS);
17054
+ }
17055
+ function decideMissionUpgrade(configured, current, desired, shipped) {
16992
17056
  if (configured)
16993
17057
  return { action: "config", mission: configured };
16994
- if (current === DEFAULT_RETAIN_MISSION)
17058
+ if (current === desired)
16995
17059
  return { action: "none" };
16996
- if (isUpgradableRetainMission(current)) {
16997
- return { action: "upgrade", mission: DEFAULT_RETAIN_MISSION };
17060
+ if (current == null || current.trim() === "" || shipped.includes(current)) {
17061
+ return { action: "upgrade", mission: desired };
16998
17062
  }
16999
17063
  return { action: "none" };
17000
17064
  }
17065
+ function decideObservationsMissionUpgrade(configured, profileDefault, current) {
17066
+ const desired = profileDefault ?? DEFAULT_OBSERVATIONS_MISSION;
17067
+ const shipped = [
17068
+ ...SUPERSEDED_OBSERVATIONS_MISSIONS,
17069
+ DEFAULT_OBSERVATIONS_MISSION,
17070
+ ...Object.values(PROFILE_MEMORY_DEFAULTS).map((d) => d.observations_mission).filter((m) => m != null)
17071
+ ].filter((m) => m !== desired);
17072
+ return decideMissionUpgrade(configured, current, desired, shipped);
17073
+ }
17001
17074
  async function fetchBankRetainMission(apiUrl, bankId, opts) {
17075
+ return fetchBankMissionField(apiUrl, bankId, "retain_mission", opts);
17076
+ }
17077
+ async function fetchBankObservationsMission(apiUrl, bankId, opts) {
17078
+ return fetchBankMissionField(apiUrl, bankId, "observations_mission", opts);
17079
+ }
17080
+ async function fetchBankMissionField(apiUrl, bankId, field, opts) {
17002
17081
  const fetchImpl = opts?.fetchImpl ?? fetch;
17003
17082
  const timeoutMs = opts?.timeoutMs ?? 5000;
17004
17083
  const base = apiUrl.replace(/\/mcp\/?$/, "").replace(/\/$/, "");
@@ -17015,10 +17094,10 @@ async function fetchBankRetainMission(apiUrl, bankId, opts) {
17015
17094
  if (config == null || typeof config !== "object") {
17016
17095
  return { ok: false, reason: "Unexpected shape" };
17017
17096
  }
17018
- if (!("retain_mission" in config)) {
17097
+ if (!(field in config)) {
17019
17098
  return { ok: false, reason: "Unexpected shape" };
17020
17099
  }
17021
- const mission = config.retain_mission;
17100
+ const mission = config[field];
17022
17101
  if (mission != null && typeof mission !== "string") {
17023
17102
  return { ok: false, reason: "Unexpected shape" };
17024
17103
  }
@@ -17570,7 +17649,7 @@ async function addMemoryTag(apiUrl, bankId, memoryId, tag, opts) {
17570
17649
  return { ok: false, reason: String(err) };
17571
17650
  }
17572
17651
  }
17573
- var HINDSIGHT_SHIM_CLI_PATH = "/usr/local/bin/switchroom", HINDSIGHT_SHIM_AGENT_HOME = "/state/agent/home", DEFAULT_RETAIN_MISSION, SUPERSEDED_RETAIN_MISSIONS, PROFILE_MEMORY_DEFAULTS, USER_PROFILE_SOURCE_QUERY = "What are the key facts, preferences, context, and communication style about the user I talk to? Summarize what matters for making the agent feel like it knows them.", DEMOTE_FROM_RECALL_TAG = "[demote-from-recall]";
17652
+ var HINDSIGHT_SHIM_CLI_PATH = "/usr/local/bin/switchroom", HINDSIGHT_SHIM_AGENT_HOME = "/state/agent/home", DEFAULT_RETAIN_MISSION, SUPERSEDED_RETAIN_MISSIONS, DEFAULT_OBSERVATIONS_MISSION, SUPERSEDED_OBSERVATIONS_MISSIONS, PROFILE_MEMORY_DEFAULTS, USER_PROFILE_SOURCE_QUERY = "What are the key facts, preferences, context, and communication style about the user I talk to? Summarize what matters for making the agent feel like it knows them.", DEMOTE_FROM_RECALL_TAG = "[demote-from-recall]";
17574
17653
  var init_hindsight2 = __esm(() => {
17575
17654
  init_users();
17576
17655
  init_hindsight();
@@ -17599,6 +17678,16 @@ var init_hindsight2 = __esm(() => {
17599
17678
  ` + `- Hindsight's own errors, retries, backlogs, or internal state \u2014 the memory
17600
17679
  ` + ` system's self-reports are not memories.
17601
17680
  ` + `- Restatements of the user's current request or the task in progress.
17681
+ ` + `- Volatile state written as a timeless assertion. A version, count, size,
17682
+ ` + ` backlog, status, or any "X is running Y" / "X is at Y" / "X is currently Y"
17683
+ ` + ` claim is true only at the instant it was said. Concretely, never produce a
17684
+ ` + ` fact whose text resembles any of these: "Switchroom fleet is running image
17685
+ ` + ` version v0.18.19", "The switchroom repo is at /path/to/fleet, version
17686
+ ` + ` v0.19.5", "Bank overlord has 43155 pending consolidations", "The build is
17687
+ ` + ` currently green". If the claim is worth keeping, put the date INSIDE the
17688
+ ` + ` fact text ("As of 2026-07-19 the fleet was running v0.18.19"); if you
17689
+ ` + ` cannot date it, drop it. An undated one is recalled forever as though it
17690
+ ` + ` were still true, which is worse than not remembering it at all.
17602
17691
  ` + `- Transient state (unread counts, build status, what is running right now) unless
17603
17692
  ` + ` the fact is explicitly dated, in which case record it as a dated observation.
17604
17693
  ` + `- Greetings, acknowledgements, and routine operational chatter.
@@ -17620,18 +17709,108 @@ var init_hindsight2 = __esm(() => {
17620
17709
  ` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
17621
17710
  ` + `- Greetings, acknowledgements, and routine operational chatter.
17622
17711
 
17623
- ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list."
17712
+ ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.",
17713
+ `Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable \u2014 record the preference (what the user likes, wants, or always does), not the request itself.
17714
+ ` + `
17715
+ ` + `A TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this
17716
+ ` + `candidate a file path, a command/process/agent/session id, a temp directory, or
17717
+ ` + `the location where some output was written? If yes, drop it \u2014 it is transcript
17718
+ ` + `exhaust, not memory.
17719
+ ` + `
17720
+ ` + `NEVER extract:
17721
+ ` + `- Tool results verbatim or paraphrased. Concretely, never produce a fact whose
17722
+ ` + ` text resembles any of these: "File created successfully at /path/to/file",
17723
+ ` + ` "A background command with ID bctz4yskm is running, and its output will be
17724
+ ` + ` written to /tmp/...", "Async agent a745598ba84e71df1 was launched successfully
17725
+ ` + ` and is running in the background", "User executed a Bash command to sleep for
17726
+ ` + ` 200 seconds", "The assistant used grep to locate 'truncateSync' in src/foo.ts".
17727
+ ` + `- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.
17728
+ ` + `- Agent tool-use traces or narration of what the assistant did (e.g. "the
17729
+ ` + ` assistant used X to query Y", "ran a search", "sent the message").
17730
+ ` + `- In-flight workflow/process narration (a sub-task started, paused, or is still
17731
+ ` + ` running) \u2014 retain the outcome only once the task completes or a decision is made.
17732
+ ` + `- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.
17733
+ ` + `- Slash commands the user typed and their effects (e.g. "User issued /clear to
17734
+ ` + ` reset assistant state").
17735
+ ` + `- Hindsight's own errors, retries, backlogs, or internal state \u2014 the memory
17736
+ ` + ` system's self-reports are not memories.
17737
+ ` + `- Restatements of the user's current request or the task in progress.
17738
+ ` + `- Transient state (unread counts, build status, what is running right now) unless
17739
+ ` + ` the fact is explicitly dated, in which case record it as a dated observation.
17740
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
17741
+ ` + `
17742
+ ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
17743
+ ` + "nothing durable remains, return an empty facts list."
17744
+ ];
17745
+ DEFAULT_OBSERVATIONS_MISSION = `Synthesise durable, standing knowledge about the people, projects, and systems this agent works with: preferences and standing rules, roles and relationships, skills and recurring patterns, technical and operational decisions with their rationale, and the state of long-running work once it lands.
17746
+ ` + `
17747
+ ` + `The test is durability, not notability: an observation must still be worth reading weeks from now. A single dated event belongs in an observation only when it establishes or changes a standing fact.
17748
+ ` + `
17749
+ ` + `Do NOT synthesise observations from:
17750
+ ` + `- Transcript exhaust \u2014 tool calls and their results, file paths, temp or scratchpad directories, where some output was written, or narration of what an assistant did.
17751
+ ` + `- Identifiers with no standing meaning: session, agent, request, batch or command IDs, UUIDs, hashes, error codes.
17752
+ ` + `- In-flight process narration \u2014 a task started, paused, or still running. Record the outcome once it lands, not the running state.
17753
+ ` + `- The memory system's own errors, retries, backlogs, or internal state.
17754
+ ` + `- Transient state (what is running right now, unread counts, build status) unless the fact is explicitly dated, in which case record it as dated.
17755
+ ` + `
17756
+ ` + "If the new facts contain nothing durable, record nothing rather than synthesising a weak observation.";
17757
+ SUPERSEDED_OBSERVATIONS_MISSIONS = [
17758
+ "Synthesise the person's wellbeing patterns, motivations, and emotional " + "context \u2014 how habits, setbacks, and encouragement connect over time."
17624
17759
  ];
17625
17760
  PROFILE_MEMORY_DEFAULTS = {
17626
17761
  "health-coach": {
17627
17762
  disposition: { skepticism: 2, literalism: 2, empathy: 5 },
17628
- observations_mission: "Synthesise the person's wellbeing patterns, motivations, and emotional " + "context \u2014 how habits, setbacks, and encouragement connect over time."
17763
+ observations_mission: `You consolidate the memory of a health and fitness coach working with one person. This bank records how that person actually lives and trains.
17764
+ ` + `
17765
+ ` + `Synthesise into durable observations:
17766
+ ` + `- Goals, targets, and the plan currently in force, with the reasoning behind each.
17767
+ ` + `- Training, nutrition, sleep, and alcohol patterns as they hold over weeks \u2014 what the person reliably does, not what they did once.
17768
+ ` + `- Constraints that shape the plan: injuries, medical guidance, schedule, equipment, foods and sessions they refuse.
17769
+ ` + `- Motivations, and what actually helps or backfires when they slip.
17770
+ ` + `- Trends in the numbers: direction and range over time, not any single reading.
17771
+ ` + `- Corrections to earlier beliefs, recorded explicitly as corrections.
17772
+ ` + `
17773
+ ` + `A single day's log, weight reading, or session is evidence, not an observation. Record one only when it establishes or changes a standing pattern, target, or constraint \u2014 then fold it into the observation for that pattern and say what changed and roughly when.
17774
+ ` + `
17775
+ ` + `Granularity: one observation per habit, target, constraint, or trend. Aggregate repeated daily evidence into the observation for that pattern rather than creating one per day, and keep training, nutrition, and sleep as separate observations.
17776
+ ` + `
17777
+ ` + "Word observations as the person's own pattern and framing, never as a verdict on them."
17629
17778
  },
17630
17779
  "executive-assistant": {
17631
- disposition: { skepticism: 4, literalism: 4, empathy: 3 }
17780
+ disposition: { skepticism: 4, literalism: 4, empathy: 3 },
17781
+ observations_mission: `You consolidate the memory of an executive assistant working for one person. This bank records that person's commitments, people, and standing arrangements.
17782
+ ` + `
17783
+ ` + `Synthesise into durable observations:
17784
+ ` + `- Standing rules and preferences: how they want things scheduled, written, and filed, and when they want to be interrupted.
17785
+ ` + `- People and organisations, and the relationship: who they are, what they are involved in, how to reach them.
17786
+ ` + `- Recurring commitments and routines, and the constraints around them.
17787
+ ` + `- Obligations and their state: what was promised to whom, the deadline, and what is still outstanding.
17788
+ ` + `- Decisions made and decisions deferred, with the reasoning and the trade accepted.
17789
+ ` + `- Corrections to earlier beliefs, recorded explicitly as corrections.
17790
+ ` + `
17791
+ ` + `Live commitment state IS durable knowledge here, not ephemeral chatter. An outstanding obligation, a travel window, an unanswered request, a "currently X" arrangement \u2014 these are precisely what this agent must recall later. Keep them, and embed the date inside the observation text so a later reader can judge staleness. Do not drop them as ephemeral.
17792
+ ` + `
17793
+ ` + `Granularity: one observation per person, arrangement, or obligation. Aggregate repeated mentions into that observation rather than creating siblings; never merge two different people or two different commitments into one.
17794
+ ` + `
17795
+ ` + "Do not synthesise from transcript exhaust: tool calls and their results, message or event identifiers, or narration of what the assistant did."
17632
17796
  },
17633
17797
  coding: {
17634
- disposition: { skepticism: 4, literalism: 5, empathy: 2 }
17798
+ disposition: { skepticism: 4, literalism: 5, empathy: 2 },
17799
+ observations_mission: `You consolidate the memory of a software-engineering agent. This bank records real work on real codebases.
17800
+ ` + `
17801
+ ` + `Synthesise into durable observations:
17802
+ ` + `- Architecture and design decisions, each with its rationale and the trade accepted.
17803
+ ` + `- Root causes, with the evidence chain, and negative results \u2014 what was ruled out matters as much as what was found.
17804
+ ` + `- How the repository works: build, test, and lint commands, conventions, CI gates, and where things live.
17805
+ ` + `- Outcomes of code work: issue and PR numbers, what changed, whether it merged, what review found.
17806
+ ` + `- The user's standing rules, preferences, and corrections to this agent's behaviour.
17807
+ ` + `- Corrections to earlier beliefs, recorded explicitly as corrections.
17808
+ ` + `
17809
+ ` + `Repository and service state IS durable knowledge here, not ephemeral chatter. Versions, a failing gate, an open PR, a measured number, a "currently X" claim \u2014 these are precisely what this agent must recall later. Keep them, and embed the date inside the observation text so a later reader can judge staleness. Do not drop them as ephemeral.
17810
+ ` + `
17811
+ ` + `Granularity: one observation per distinct decision, cause, convention, or work item. Aggregate repeated evidence about the same one into that observation rather than creating siblings; never merge two separate decisions into a single summary.
17812
+ ` + `
17813
+ ` + "Do not synthesise from transcript exhaust: tool calls and their results, scratch paths, session or request identifiers, or narration of what the agent did. Prefer specific and falsifiable \u2014 naming the file, the number, the commit, or the decision beats summarising the topic."
17635
17814
  }
17636
17815
  };
17637
17816
  });
@@ -27998,6 +28177,21 @@ async function resolveRetainMissionPush(apiUrl, bankId, configured, label) {
27998
28177
  }
27999
28178
  return decision.mission;
28000
28179
  }
28180
+ async function resolveObservationsMissionPush(apiUrl, bankId, configured, profileName, label) {
28181
+ if (configured)
28182
+ return configured;
28183
+ const profileDefault = PROFILE_MEMORY_DEFAULTS[profileName]?.observations_mission;
28184
+ const current = await fetchBankObservationsMission(apiUrl, bankId, { timeoutMs: 5000 });
28185
+ if (!current.ok) {
28186
+ console.warn(` ${source_default.yellow("\u26a0")} Could not read current observations_mission for ${label} (${current.reason}) \u2014 leaving it untouched`);
28187
+ return;
28188
+ }
28189
+ const decision = decideObservationsMissionUpgrade(configured, profileDefault, current.mission);
28190
+ if (decision.action === "upgrade") {
28191
+ console.log(` ${source_default.green("\u2713")} Seeding default observations_mission for ${label}`);
28192
+ }
28193
+ return decision.mission;
28194
+ }
28001
28195
  function parseDurationToSeconds(d) {
28002
28196
  if (!d)
28003
28197
  return;
@@ -28710,6 +28904,7 @@ function buildWorkspaceContext(args) {
28710
28904
  hindsightRecallTypes,
28711
28905
  hindsightRecallSkipTrivial,
28712
28906
  hindsightDirectiveCaptureNudge,
28907
+ hindsightObservationScopes,
28713
28908
  hindsightTopicAliasesJson,
28714
28909
  hindsightSenderBanksJson,
28715
28910
  hindsightTopicFilterMode
@@ -28763,6 +28958,7 @@ function buildWorkspaceContext(args) {
28763
28958
  hindsightRecallTypes,
28764
28959
  hindsightRecallSkipTrivial,
28765
28960
  hindsightDirectiveCaptureNudge,
28961
+ hindsightObservationScopesQ: hindsightObservationScopes ? shellSingleQuote(hindsightObservationScopes) : undefined,
28766
28962
  hindsightTopicAliasesJsonQ: hindsightTopicAliasesJson ? shellSingleQuote(hindsightTopicAliasesJson) : undefined,
28767
28963
  hindsightSenderBanksJsonQ: hindsightSenderBanksJson ? shellSingleQuote(hindsightSenderBanksJson) : undefined,
28768
28964
  hindsightTopicFilterMode,
@@ -29027,6 +29223,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
29027
29223
  const hindsightRecallTypes = agentConfig.memory?.recall?.types?.length ? agentConfig.memory.recall.types.join(",") : undefined;
29028
29224
  const hindsightRecallSkipTrivial = agentConfig.memory?.recall?.skip_trivial === undefined ? undefined : String(agentConfig.memory.recall.skip_trivial);
29029
29225
  const hindsightDirectiveCaptureNudge = agentConfig.memory?.directive_capture_nudge === undefined ? undefined : String(agentConfig.memory.directive_capture_nudge);
29226
+ const hindsightObservationScopes = agentConfig.memory?.observation_scopes;
29030
29227
  const topicAliases = agentConfig.channels?.telegram?.topic_aliases;
29031
29228
  const hindsightTopicAliasesJson = topicAliases && Object.keys(topicAliases).length > 0 ? JSON.stringify(topicAliases) : undefined;
29032
29229
  const senderBanks = switchroomConfig ? resolveUsers(switchroomConfig, name).senderBanks : agentConfig.memory?.recall?.sender_banks ?? {};
@@ -29062,6 +29259,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
29062
29259
  hindsightRecallTypes,
29063
29260
  hindsightRecallSkipTrivial,
29064
29261
  hindsightDirectiveCaptureNudge,
29262
+ hindsightObservationScopes,
29065
29263
  hindsightTopicAliasesJson,
29066
29264
  hindsightSenderBanksJson,
29067
29265
  hindsightTopicFilterMode
@@ -29504,6 +29702,11 @@ ${body}
29504
29702
  if (seededRetainMission) {
29505
29703
  missions.retain_mission = seededRetainMission;
29506
29704
  }
29705
+ delete missions.observations_mission;
29706
+ const seededObservationsMission = await resolveObservationsMissionPush(apiUrl, hindsightBankId, agentConfig.memory?.observations_mission, agentConfig.extends ?? DEFAULT_PROFILE, formatAgentBankLabel(name, hindsightBankId));
29707
+ if (seededObservationsMission) {
29708
+ missions.observations_mission = seededObservationsMission;
29709
+ }
29507
29710
  if (userBankMission) {
29508
29711
  missions.bank_mission = userBankMission;
29509
29712
  }
@@ -30001,6 +30204,7 @@ function reconcileAgentInner(name, agentConfigRaw, agentsDir, telegramConfig, sw
30001
30204
  const hindsightRecallTypes = agentConfig.memory?.recall?.types?.length ? agentConfig.memory.recall.types.join(",") : undefined;
30002
30205
  const hindsightRecallSkipTrivial = agentConfig.memory?.recall?.skip_trivial === undefined ? undefined : String(agentConfig.memory.recall.skip_trivial);
30003
30206
  const hindsightDirectiveCaptureNudge = agentConfig.memory?.directive_capture_nudge === undefined ? undefined : String(agentConfig.memory.directive_capture_nudge);
30207
+ const hindsightObservationScopes = agentConfig.memory?.observation_scopes;
30004
30208
  const topicAliases = agentConfig.channels?.telegram?.topic_aliases;
30005
30209
  const hindsightTopicAliasesJson = topicAliases && Object.keys(topicAliases).length > 0 ? JSON.stringify(topicAliases) : undefined;
30006
30210
  const senderBanks = switchroomConfig ? resolveUsers(switchroomConfig, name).senderBanks : agentConfig.memory?.recall?.sender_banks ?? {};
@@ -30049,6 +30253,7 @@ function reconcileAgentInner(name, agentConfigRaw, agentsDir, telegramConfig, sw
30049
30253
  hindsightRecallTypes,
30050
30254
  hindsightRecallSkipTrivial,
30051
30255
  hindsightDirectiveCaptureNudge,
30256
+ hindsightObservationScopesQ: hindsightObservationScopes ? shellSingleQuote(hindsightObservationScopes) : undefined,
30052
30257
  hindsightTopicAliasesJsonQ: hindsightTopicAliasesJson ? shellSingleQuote(hindsightTopicAliasesJson) : undefined,
30053
30258
  hindsightSenderBanksJsonQ: hindsightSenderBanksJson ? shellSingleQuote(hindsightSenderBanksJson) : undefined,
30054
30259
  hindsightTopicFilterMode,
@@ -30471,6 +30676,7 @@ ${body}
30471
30676
  hindsightRecallTypes,
30472
30677
  hindsightRecallSkipTrivial,
30473
30678
  hindsightDirectiveCaptureNudge,
30679
+ hindsightObservationScopes,
30474
30680
  hindsightTopicAliasesJson,
30475
30681
  hindsightSenderBanksJson,
30476
30682
  hindsightTopicFilterMode
@@ -30574,6 +30780,10 @@ ${body}
30574
30780
  const retainMission = await resolveRetainMissionPush(apiUrl, hindsightBankId, agentConfig.memory?.retain_mission, formatAgentBankLabel(name, hindsightBankId));
30575
30781
  if (retainMission)
30576
30782
  missions.retain_mission = retainMission;
30783
+ delete missions.observations_mission;
30784
+ const observationsMission = await resolveObservationsMissionPush(apiUrl, hindsightBankId, agentConfig.memory?.observations_mission, agentConfig.extends ?? DEFAULT_PROFILE, formatAgentBankLabel(name, hindsightBankId));
30785
+ if (observationsMission)
30786
+ missions.observations_mission = observationsMission;
30577
30787
  if (Object.keys(missions).length > 0) {
30578
30788
  await updateBankMissions(apiUrl, hindsightBankId, missions, { timeoutMs: 5000 }).then((result) => {
30579
30789
  if (result.ok) {
@@ -45612,6 +45822,7 @@ var exports_provision = {};
45612
45822
  __export(exports_provision, {
45613
45823
  validateKey: () => validateKey,
45614
45824
  updateKeyModels: () => updateKeyModels,
45825
+ updateKeyBudget: () => updateKeyBudget,
45615
45826
  ensureTeam: () => ensureTeam,
45616
45827
  ensureKey: () => ensureKey,
45617
45828
  bindKeyToTeam: () => bindKeyToTeam,
@@ -45733,6 +45944,12 @@ async function generateKeyOnce(opts, fetchFn) {
45733
45944
  if (opts.metadata && Object.keys(opts.metadata).length > 0) {
45734
45945
  payload.metadata = opts.metadata;
45735
45946
  }
45947
+ if (opts.budget) {
45948
+ payload.max_budget = opts.budget.maxBudget;
45949
+ payload.budget_duration = opts.budget.budgetDuration;
45950
+ if (opts.budget.softBudget != null)
45951
+ payload.soft_budget = opts.budget.softBudget;
45952
+ }
45736
45953
  let resp;
45737
45954
  try {
45738
45955
  resp = await fetchFn(url, {
@@ -45918,6 +46135,27 @@ async function safeText(resp) {
45918
46135
  return "";
45919
46136
  }
45920
46137
  }
46138
+ async function updateKeyBudget(opts, fetchFn = fetch) {
46139
+ const url = `${normalizeBase(opts.baseUrl)}/key/update`;
46140
+ let resp;
46141
+ try {
46142
+ resp = await fetchFn(url, {
46143
+ method: "POST",
46144
+ headers: authHeaders(opts.masterKey),
46145
+ body: JSON.stringify({
46146
+ key: opts.key,
46147
+ max_budget: opts.budget.maxBudget,
46148
+ budget_duration: opts.budget.budgetDuration
46149
+ })
46150
+ });
46151
+ } catch (err) {
46152
+ return { kind: "error", detail: err.message };
46153
+ }
46154
+ if (resp.ok)
46155
+ return { kind: "ok" };
46156
+ const body = await safeText(resp);
46157
+ return { kind: "error", detail: `HTTP ${resp.status}: ${body.slice(0, 200)}` };
46158
+ }
45921
46159
  var LiteLLMProvisionError;
45922
46160
  var init_provision = __esm(() => {
45923
46161
  LiteLLMProvisionError = class LiteLLMProvisionError extends Error {
@@ -47312,6 +47550,176 @@ var init_doctor_webkite = __esm(() => {
47312
47550
  init_loader();
47313
47551
  });
47314
47552
 
47553
+ // src/openrouter/credit.ts
47554
+ function num(v) {
47555
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
47556
+ }
47557
+ function parseKeySnapshot(body) {
47558
+ if (body == null || typeof body !== "object")
47559
+ return null;
47560
+ const data = body.data;
47561
+ if (data == null || typeof data !== "object")
47562
+ return null;
47563
+ const d = data;
47564
+ if (num(d.usage) == null)
47565
+ return null;
47566
+ return {
47567
+ label: typeof d.label === "string" ? d.label : null,
47568
+ limit: num(d.limit),
47569
+ limitRemaining: num(d.limit_remaining),
47570
+ limitReset: typeof d.limit_reset === "string" ? d.limit_reset : null,
47571
+ usage: num(d.usage),
47572
+ isFreeTier: typeof d.is_free_tier === "boolean" ? d.is_free_tier : null
47573
+ };
47574
+ }
47575
+ function evaluateCredit(snapshot) {
47576
+ const usage = snapshot.usage ?? 0;
47577
+ if (snapshot.limit == null || snapshot.limitRemaining == null) {
47578
+ return {
47579
+ status: "warn",
47580
+ detail: `OpenRouter key has NO per-key credit limit set, so its remaining credit cannot be measured. ` + `GET /api/v1/key reports limit/limit_remaining as null when a key is uncapped, and the response ` + `carries no account-balance field \u2014 so there is nothing to threshold and this check CANNOT warn ` + `you before the account runs dry. (Key has spent $${usage.toFixed(2)} all-time.)`,
47581
+ fix: `Set a per-key credit limit at ${OPENROUTER_KEYS_URL} (vault key: \`${OPENROUTER_VAULT_KEY}\`). ` + `That is what makes proactive monitoring possible at all, and it bounds the damage if the proxy ` + `key ever leaks. This is a manual console action \u2014 OpenRouter exposes no API to set it.`,
47582
+ actionable: false
47583
+ };
47584
+ }
47585
+ const { limit, limitRemaining } = snapshot;
47586
+ const fraction = limit > 0 ? limitRemaining / limit : 0;
47587
+ const reset = snapshot.limitReset ? ` (resets: ${snapshot.limitReset})` : " (never resets)";
47588
+ const where = `$${limitRemaining.toFixed(2)} of $${limit.toFixed(2)} remaining${reset}`;
47589
+ if (limitRemaining <= 0) {
47590
+ return {
47591
+ status: "fail",
47592
+ detail: `OpenRouter key credit is EXHAUSTED \u2014 ${where}. Every request through it is answering HTTP 402.`,
47593
+ fix: `Top up at ${OPENROUTER_CREDITS_URL} or raise the key's limit at ${OPENROUTER_KEYS_URL} (vault key: \`${OPENROUTER_VAULT_KEY}\`).`,
47594
+ actionable: true
47595
+ };
47596
+ }
47597
+ if (fraction < FAIL_REMAINING_FRACTION || limitRemaining < FAIL_REMAINING_USD) {
47598
+ return {
47599
+ status: "fail",
47600
+ detail: `OpenRouter key credit is CRITICALLY low \u2014 ${where}. A single busy session can exhaust it.`,
47601
+ fix: `Top up at ${OPENROUTER_CREDITS_URL} or raise the key's limit at ${OPENROUTER_KEYS_URL} (vault key: \`${OPENROUTER_VAULT_KEY}\`).`,
47602
+ actionable: true
47603
+ };
47604
+ }
47605
+ if (fraction < WARN_REMAINING_FRACTION || limitRemaining < WARN_REMAINING_USD) {
47606
+ return {
47607
+ status: "warn",
47608
+ detail: `OpenRouter key credit is running low \u2014 ${where}.`,
47609
+ fix: `Top up at ${OPENROUTER_CREDITS_URL} before it reaches zero (vault key: \`${OPENROUTER_VAULT_KEY}\`).`,
47610
+ actionable: true
47611
+ };
47612
+ }
47613
+ return {
47614
+ status: "ok",
47615
+ detail: `OpenRouter key credit healthy \u2014 ${where}.`,
47616
+ actionable: false
47617
+ };
47618
+ }
47619
+ async function fetchKeySnapshot(apiKey, fetchFn, signal) {
47620
+ let resp;
47621
+ try {
47622
+ resp = await fetchFn(OPENROUTER_KEY_ENDPOINT, {
47623
+ headers: { authorization: `Bearer ${apiKey}` },
47624
+ signal
47625
+ });
47626
+ } catch (err) {
47627
+ return { kind: "unreachable", detail: err.message };
47628
+ }
47629
+ if (resp.status === 401 || resp.status === 403) {
47630
+ return { kind: "unauthorized", status: resp.status };
47631
+ }
47632
+ if (!resp.ok)
47633
+ return { kind: "unreachable", detail: `HTTP ${resp.status}` };
47634
+ let body;
47635
+ try {
47636
+ body = await resp.json();
47637
+ } catch {
47638
+ return { kind: "unexpected-shape" };
47639
+ }
47640
+ const snapshot = parseKeySnapshot(body);
47641
+ if (snapshot == null)
47642
+ return { kind: "unexpected-shape" };
47643
+ return { kind: "ok", snapshot };
47644
+ }
47645
+ function assessFetchFailure(result) {
47646
+ switch (result.kind) {
47647
+ case "unauthorized":
47648
+ return {
47649
+ status: "fail",
47650
+ detail: `OpenRouter rejected the credit query with HTTP ${result.status} \u2014 the key is invalid, revoked or disabled.`,
47651
+ fix: `Re-issue the key at ${OPENROUTER_KEYS_URL} and update the vault entry \`${OPENROUTER_VAULT_KEY}\`.`,
47652
+ actionable: true
47653
+ };
47654
+ case "unexpected-shape":
47655
+ return {
47656
+ status: "warn",
47657
+ detail: `OpenRouter's GET /api/v1/key returned an unrecognised body, so credit could not be read. ` + `Treated as UNKNOWN, not as healthy \u2014 the API shape may have changed.`,
47658
+ fix: `Re-check https://openrouter.ai/docs/api-reference/limits against src/openrouter/credit.ts.`,
47659
+ actionable: false
47660
+ };
47661
+ case "unreachable":
47662
+ return {
47663
+ status: "skip",
47664
+ detail: `Could not reach OpenRouter to check credit (${result.detail}).`,
47665
+ actionable: false
47666
+ };
47667
+ }
47668
+ }
47669
+ var WARN_REMAINING_FRACTION = 0.25, WARN_REMAINING_USD = 10, FAIL_REMAINING_FRACTION = 0.1, FAIL_REMAINING_USD = 3, OPENROUTER_VAULT_KEY = "openrouter/api-key", OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/keys", OPENROUTER_CREDITS_URL = "https://openrouter.ai/credits", OPENROUTER_KEY_ENDPOINT = "https://openrouter.ai/api/v1/key";
47670
+
47671
+ // src/cli/doctor-openrouter-credit.ts
47672
+ async function runOpenRouterCreditChecks(deps) {
47673
+ if (!deps.enabled) {
47674
+ return [
47675
+ {
47676
+ name: CHECK_NAME2,
47677
+ status: "skip",
47678
+ detail: "No OpenRouter-backed model is configured in the LiteLLM proxy config."
47679
+ }
47680
+ ];
47681
+ }
47682
+ let apiKey;
47683
+ try {
47684
+ apiKey = await deps.readSecret(OPENROUTER_VAULT_KEY);
47685
+ } catch (err) {
47686
+ return [
47687
+ {
47688
+ name: CHECK_NAME2,
47689
+ status: "skip",
47690
+ detail: `Could not read vault key \`${OPENROUTER_VAULT_KEY}\` (${err.message}).`
47691
+ }
47692
+ ];
47693
+ }
47694
+ if (!apiKey) {
47695
+ return [
47696
+ {
47697
+ name: CHECK_NAME2,
47698
+ status: "warn",
47699
+ detail: `The proxy routes through OpenRouter but vault key \`${OPENROUTER_VAULT_KEY}\` is not ` + `readable here, so credit cannot be monitored.`,
47700
+ fix: `Grant read access to \`${OPENROUTER_VAULT_KEY}\`, or store it if it is missing.`
47701
+ }
47702
+ ];
47703
+ }
47704
+ const fetched = await fetchKeySnapshot(apiKey, deps.fetchFn);
47705
+ const assessment = fetched.kind === "ok" ? evaluateCredit(fetched.snapshot) : assessFetchFailure(fetched);
47706
+ return [
47707
+ {
47708
+ name: CHECK_NAME2,
47709
+ status: assessment.status,
47710
+ detail: assessment.detail,
47711
+ ...assessment.fix ? { fix: assessment.fix } : {}
47712
+ }
47713
+ ];
47714
+ }
47715
+ function usesOpenRouter(litellmConfigText) {
47716
+ if (!litellmConfigText)
47717
+ return false;
47718
+ return litellmConfigText.includes("openrouter/") || litellmConfigText.includes("OPENROUTER_API_KEY");
47719
+ }
47720
+ var CHECK_NAME2 = "OpenRouter credit";
47721
+ var init_doctor_openrouter_credit = () => {};
47722
+
47315
47723
  // src/cli/doctor-cron-session.ts
47316
47724
  import { statSync as realStatSync } from "node:fs";
47317
47725
  import { resolve as resolve38 } from "node:path";
@@ -47388,18 +47796,18 @@ function parseEpisode(raw) {
47388
47796
  if (typeof raw !== "object" || raw === null)
47389
47797
  return null;
47390
47798
  const r = raw;
47391
- const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
47392
- const firstTs = num(r.firstTs);
47393
- const untilTs = num(r.untilTs);
47394
- const peak = num(r.peakRetryAfterSec);
47799
+ const num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
47800
+ const firstTs = num2(r.firstTs);
47801
+ const untilTs = num2(r.untilTs);
47802
+ const peak = num2(r.peakRetryAfterSec);
47395
47803
  if (firstTs === null || untilTs === null || peak === null)
47396
47804
  return null;
47397
47805
  return {
47398
47806
  firstTs,
47399
- lastTs: num(r.lastTs) ?? firstTs,
47807
+ lastTs: num2(r.lastTs) ?? firstTs,
47400
47808
  peakRetryAfterSec: peak,
47401
47809
  untilTs,
47402
- count: num(r.count) ?? 1
47810
+ count: num2(r.count) ?? 1
47403
47811
  };
47404
47812
  }
47405
47813
  function readFlood429LedgerResult(path6, readFile2 = (p) => readFileSync63(p, "utf-8")) {
@@ -47571,6 +47979,25 @@ var init_doctor_flood_pressure = __esm(() => {
47571
47979
  FIX2 = "A flood ban is server-side and cannot be cleared early \u2014 cut the outbound " + "rate that earns it. Check the gateway's `send-gate stats` and " + "`edit-flood-fuse` lines for the class doing the sending (progress cards and " + "the worker feed are the usual culprits), and see " + "`telegram-plugin/edit-flood-fuse.ts` for the per-class ceilings.";
47572
47980
  });
47573
47981
 
47982
+ // src/litellm/budget.ts
47983
+ function resolveKeyBudget(config) {
47984
+ const raw = config?.max_budget;
47985
+ const maxBudget = raw === undefined ? DEFAULT_KEY_MAX_BUDGET_USD : raw;
47986
+ if (!Number.isFinite(maxBudget) || maxBudget <= 0)
47987
+ return null;
47988
+ const budgetDuration = config?.budget_duration && config.budget_duration.length > 0 ? config.budget_duration : DEFAULT_KEY_BUDGET_DURATION;
47989
+ const rawSoft = config?.soft_budget ?? DEFAULT_KEY_SOFT_BUDGET_USD;
47990
+ const softBudget = Number.isFinite(rawSoft) && rawSoft > 0 && rawSoft < maxBudget ? rawSoft : undefined;
47991
+ return { maxBudget, softBudget, budgetDuration };
47992
+ }
47993
+ function describeKeyBudget(budget) {
47994
+ if (budget == null)
47995
+ return "no spend cap (uncapped key)";
47996
+ const soft = budget.softBudget != null ? `, alert at $${budget.softBudget}` : "";
47997
+ return `cap $${budget.maxBudget}/${budget.budgetDuration}${soft}`;
47998
+ }
47999
+ var DEFAULT_KEY_MAX_BUDGET_USD = 25, DEFAULT_KEY_SOFT_BUDGET_USD = 15, DEFAULT_KEY_BUDGET_DURATION = "30d";
48000
+
47574
48001
  // examples/switchroom.yaml
47575
48002
  var switchroom_default = `# switchroom.yaml \u2014 Full example configuration
47576
48003
  #
@@ -48725,7 +49152,7 @@ async function provisionLiteLLMKeys(config, agentNames, switchroomConfigPath, ct
48725
49152
  let brokerKeyMaterialized = false;
48726
49153
  const [
48727
49154
  { getViaBrokerStructured: getViaBrokerStructured2, putViaBroker: putViaBroker2 },
48728
- { ensureTeam: ensureTeam2, ensureKey: ensureKey2, validateKey: validateKey2, bindKeyToTeam: bindKeyToTeam2, updateKeyModels: updateKeyModels2 },
49155
+ { ensureTeam: ensureTeam2, ensureKey: ensureKey2, validateKey: validateKey2, bindKeyToTeam: bindKeyToTeam2, updateKeyModels: updateKeyModels2, updateKeyBudget: updateKeyBudget2 },
48729
49156
  { addAgentSecret: addAgentSecret2 }
48730
49157
  ] = await Promise.all([
48731
49158
  Promise.resolve().then(() => (init_client(), exports_client)),
@@ -48806,6 +49233,11 @@ async function provisionLiteLLMKeys(config, agentNames, switchroomConfigPath, ct
48806
49233
  const baseUrl = litellm.base_url ?? topLevel?.base_url;
48807
49234
  const team = litellm.team ?? topLevel?.team ?? "switchroom";
48808
49235
  const adminKeyRef = litellm.admin_key ?? topLevel?.admin_key;
49236
+ const budget = resolveKeyBudget({
49237
+ max_budget: litellm.max_budget ?? topLevel?.max_budget,
49238
+ soft_budget: litellm.soft_budget ?? topLevel?.soft_budget,
49239
+ budget_duration: litellm.budget_duration ?? topLevel?.budget_duration
49240
+ });
48809
49241
  if (!baseUrl || !adminKeyRef) {
48810
49242
  failures.push({
48811
49243
  agent: name,
@@ -48887,6 +49319,16 @@ async function provisionLiteLLMKeys(config, agentNames, switchroomConfigPath, ct
48887
49319
  writeOut(source_default.gray(` ~ litellm/${name}: key already provisioned (skipped)
48888
49320
  `));
48889
49321
  }
49322
+ if (!driftReprovision && storedKey && masterKey && budget) {
49323
+ const cap = await updateKeyBudget2({ baseUrl, masterKey, key: storedKey, budget });
49324
+ if (cap.kind === "ok") {
49325
+ writeOut(source_default.gray(` ~ litellm/${name}: spend ${describeKeyBudget(budget)}${budget.softBudget != null ? " (soft budget applies to newly generated keys only)" : ""}
49326
+ `));
49327
+ } else {
49328
+ ctx.writeErr(source_default.yellow(` ! litellm/${name}: could NOT apply the spend cap (${cap.detail}) \u2014 ` + `this key remains UNCAPPED and can spend the whole upstream account balance. Manual: POST ${baseUrl}/key/update {"key":"<key>","max_budget":${budget.maxBudget},"budget_duration":"${budget.budgetDuration}"}.
49329
+ `));
49330
+ }
49331
+ }
48890
49332
  if (!driftReprovision) {
48891
49333
  if (configText !== null) {
48892
49334
  const after = addAgentSecret2(configText, name, vaultKey);
@@ -48923,6 +49365,7 @@ async function provisionLiteLLMKeys(config, agentNames, switchroomConfigPath, ct
48923
49365
  alias,
48924
49366
  teamId,
48925
49367
  metadata,
49368
+ ...budget ? { budget } : {},
48926
49369
  log: (m) => ctx.writeErr(source_default.gray(` ~ litellm/${name}: ${m}
48927
49370
  `))
48928
49371
  });
@@ -52352,6 +52795,7 @@ __export(exports_doctor, {
52352
52795
  checkDepsCacheWritable: () => checkDepsCacheWritable,
52353
52796
  checkDeployMounts: () => checkDeployMounts,
52354
52797
  checkConfig: () => checkConfig,
52798
+ checkBankObservationsMissions: () => checkBankObservationsMissions,
52355
52799
  checkBankIngestHealth: () => checkBankIngestHealth,
52356
52800
  checkAgents: () => checkAgents,
52357
52801
  buildPendingRetainsProbeScript: () => buildPendingRetainsProbeScript,
@@ -52372,6 +52816,20 @@ import {
52372
52816
  } from "node:fs";
52373
52817
  import { dirname as dirname31, join as join81, resolve as resolve44 } from "node:path";
52374
52818
  import { createPublicKey, createPrivateKey } from "node:crypto";
52819
+ function readLitellmConfigText() {
52820
+ const candidates = [
52821
+ process.env.LITELLM_CONFIG_PATH,
52822
+ "/data/coolify/services/litellm/litellm-config.yaml",
52823
+ join81(process.cwd(), "docker", "litellm-proxy", "litellm-config.yaml")
52824
+ ].filter((p) => typeof p === "string" && p.length > 0);
52825
+ for (const p of candidates) {
52826
+ try {
52827
+ if (existsSync77(p))
52828
+ return readFileSync71(p, "utf-8");
52829
+ } catch {}
52830
+ }
52831
+ return null;
52832
+ }
52375
52833
  function findInNvm(bin) {
52376
52834
  const nvmRoot = join81(process.env.HOME ?? "", ".nvm", "versions", "node");
52377
52835
  if (!existsSync77(nvmRoot))
@@ -53041,6 +53499,58 @@ function probeAuthBrokerSocket(consumerName) {
53041
53499
  return "unreachable";
53042
53500
  return "missing";
53043
53501
  }
53502
+ async function checkBankObservationsMissions(config, url, opts) {
53503
+ const banks = new Map;
53504
+ for (const [agentName, agentConfig] of Object.entries(config.agents)) {
53505
+ const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
53506
+ const bankId = resolved.memory?.collection ?? agentName;
53507
+ const existing = banks.get(bankId);
53508
+ if (existing)
53509
+ existing.agents.push(agentName);
53510
+ else
53511
+ banks.set(bankId, { agents: [agentName], config: resolved });
53512
+ }
53513
+ const inspected = await Promise.all([...banks].map(async ([bankId, entry]) => [
53514
+ bankId,
53515
+ entry,
53516
+ await fetchBankObservationsMission(url, bankId, { fetchImpl: opts?.fetchImpl })
53517
+ ]));
53518
+ return inspected.map(([bankId, entry, read]) => {
53519
+ const label = `bank ${bankId} observations_mission` + (entry.agents[0] !== bankId ? ` (${entry.agents.join(", ")})` : "");
53520
+ const firstAgent = entry.agents[0];
53521
+ if (!read.ok) {
53522
+ return {
53523
+ name: label,
53524
+ status: "warn",
53525
+ detail: `could not read observations_mission: ${read.reason}`
53526
+ };
53527
+ }
53528
+ const profileDefault = PROFILE_MEMORY_DEFAULTS[entry.config.extends ?? DEFAULT_PROFILE]?.observations_mission;
53529
+ const decision = decideObservationsMissionUpgrade(entry.config.memory?.observations_mission, profileDefault, read.mission);
53530
+ if (decision.action === "upgrade") {
53531
+ return {
53532
+ name: label,
53533
+ status: "warn",
53534
+ detail: read.mission == null || read.mission.trim() === "" ? "unset \u2014 this bank consolidates under Hindsight's stock mission, not switchroom's" : "carrying a superseded switchroom default",
53535
+ fix: `Run: switchroom agent reconcile ${firstAgent}`
53536
+ };
53537
+ }
53538
+ if (decision.action === "config") {
53539
+ return read.mission === decision.mission ? { name: label, status: "ok", detail: "set from switchroom.yaml" } : {
53540
+ name: label,
53541
+ status: "warn",
53542
+ detail: "switchroom.yaml sets a different observations_mission than the bank carries",
53543
+ fix: `Run: switchroom agent reconcile ${firstAgent}`
53544
+ };
53545
+ }
53546
+ const isSwitchroomDefault = read.mission === (profileDefault ?? DEFAULT_OBSERVATIONS_MISSION);
53547
+ return {
53548
+ name: label,
53549
+ status: "ok",
53550
+ detail: isSwitchroomDefault ? profileDefault != null ? `switchroom ${entry.config.extends ?? DEFAULT_PROFILE}-profile default` : "switchroom fleet default" : "operator-authored (left untouched by the never-clobber rule)"
53551
+ };
53552
+ });
53553
+ }
53044
53554
  async function checkBankIngestHealth(config, url, opts) {
53045
53555
  const results = [];
53046
53556
  const now = opts?.now ?? new Date;
@@ -53200,6 +53710,7 @@ async function checkHindsight(config) {
53200
53710
  results.push(checkHnswPartialIndexes());
53201
53711
  results.push(await checkHindsightHealthEndpoint(url));
53202
53712
  results.push(...await checkBankIngestHealth(config, url, { includeConsolidationBacklog: true }));
53713
+ results.push(...await checkBankObservationsMissions(config, url));
53203
53714
  const memoryAgentsDir = resolveAgentsDir(config);
53204
53715
  for (const agentName of Object.keys(config.agents)) {
53205
53716
  results.push(checkAgentRecallHealth(agentName, resolve44(memoryAgentsDir, agentName)));
@@ -53314,7 +53825,7 @@ function checkPendingRetainsQueues(config, opts) {
53314
53825
  }
53315
53826
  }
53316
53827
  const skippedNote = unreachable.length > 0 ? `; skipped (unreachable): ${unreachable.join(", ")}` : "";
53317
- const backlogFix = `Clear a backlog explicitly (the SessionStart drain cannot \u2014 its per-entry ` + `timeout is clamped to the hook budget, far below a real retain): ` + `\`docker exec switchroom-<agent> python3 ` + `/state/agent/.claude/plugins/hindsight-memory/scripts/drain_pending.py --backlog\`. ` + `Run \`--phase reconcile\` first \u2014 it costs nothing and typically clears most of ` + `the queue by confirming those documents already exist. PACE THE REST: the retain ` + `model pool is small and shared with live traffic, so drain ONE agent at a time at ` + `the default HINDSIGHT_DRAIN_CONCURRENCY=1, and keep HINDSIGHT_DRAIN_SLEEP_S set. ` + `Point HINDSIGHT_DRAIN_P95_CMD at the same figure the latency watchdog alarms on so ` + `the replay backs off instead of causing the alarm \u2014 on a LiteLLM deployment that is ` + `\`docker exec -i <postgres> psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c ` + `"select coalesce((select round(percentile_cont(0.95) within group (order by ` + `request_duration_ms)) from \\"LiteLLM_SpendLogs\\" where \\"startTime\\" > now() ` + `- interval '10 minutes' and metadata->>'user_api_key_alias' like 'hindsight-%' and ` + `status='success' and request_duration_ms is not null), -1)"\` (must print a ` + `millisecond integer on stdout; unset = no backoff). Fix the upstream first (bank ` + `health rows above), or every retry just ages entries toward .dead. Entries the ` + `drain retires are MOVED to \`.hindsight/pending-reconciled/\` (bounded, so they ` + `expire), never deleted \u2014 every retire rests on an HTTP 200, which is an ack and ` + `not proof, so the payload stays recoverable. If that archive cannot be written ` + `(disk full, permissions) the entry STAYS QUEUED and the drain says so on stderr; ` + `a failure to archive is never a reason to delete. FIX THE DISK ANYWAY \u2014 "stays ` + `queued" is bounded: entries pile up, the queue hits ` + `HINDSIGHT_PENDING_MAX_ENTRIES/MAX_BYTES, and enqueue then sheds the OLDEST ` + `entries to keep accepting the newest. While the disk is full their archive move ` + `fails too, so those oldest turns are removed outright (the ledger line reads ` + `\`reason=...+archive-failed\`). Under sustained ENOSPC "keep it queued" degrades ` + `to "keep the newest, drop the oldest".`;
53828
+ const backlogFix = `Clear a backlog explicitly (the SessionStart drain cannot \u2014 its per-entry ` + `timeout is clamped to the hook budget, far below a real retain): ` + `\`docker exec switchroom-<agent> python3 ` + `/state/agent/.claude/plugins/hindsight-memory/scripts/drain_pending.py --backlog\`. ` + `Run \`--phase reconcile\` first \u2014 it costs nothing and typically clears most of ` + `the queue by confirming those documents already exist. PACE THE REST: the retain ` + `model pool is small and shared with live traffic, so drain ONE agent at a time at ` + `the default HINDSIGHT_DRAIN_CONCURRENCY=1, and keep HINDSIGHT_DRAIN_SLEEP_S set. ` + `Point HINDSIGHT_DRAIN_P95_CMD at the same figure the latency watchdog alarms on so ` + `the replay backs off instead of causing the alarm \u2014 on a LiteLLM deployment that is ` + `\`docker exec -i <postgres> psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c ` + `"select coalesce((select round(percentile_cont(0.95) within group (order by ` + `request_duration_ms)) from \\"LiteLLM_SpendLogs\\" where \\"startTime\\" > now() ` + `- interval '10 minutes' and metadata->>'user_api_key_alias' like 'hindsight-%' and ` + `status='success' and request_duration_ms is not null), -1)"\` (must print a ` + `millisecond integer on stdout; unset = no backoff). ` + `SAFE ALONGSIDE THE \`hindsight-drain\` SIDECAR and a session booting underneath ` + `you: drain_pending.py takes an exclusive lock on ` + `\`$HOME/.hindsight/drain-pending.lock\` for the whole run, so whichever drain ` + `starts second prints "another drain holds ..." and exits without touching the ` + `queue. Do NOT wrap this in \`flock\` on that path yourself \u2014 you would hold the ` + `lock the script then asks for, and it would skip every time. Entries past the ` + `attempt ceiling (HINDSIGHT_DRAIN_ATTEMPT_CEILING, default 20) are PARKED and ` + `reported rather than retried forever; add \`--force\` once the upstream is fixed ` + `to replay them. ` + `Fix the upstream first (bank ` + `health rows above), or every retry just ages entries toward .dead. Entries the ` + `drain retires are MOVED to \`.hindsight/pending-reconciled/\` (bounded, so they ` + `expire), never deleted \u2014 every retire rests on an HTTP 200, which is an ack and ` + `not proof, so the payload stays recoverable. If that archive cannot be written ` + `(disk full, permissions) the entry STAYS QUEUED and the drain says so on stderr; ` + `a failure to archive is never a reason to delete. FIX THE DISK ANYWAY \u2014 "stays ` + `queued" is bounded: entries pile up, the queue hits ` + `HINDSIGHT_PENDING_MAX_ENTRIES/MAX_BYTES, and enqueue then sheds the OLDEST ` + `entries to keep accepting the newest. While the disk is full their archive move ` + `fails too, so those oldest turns are removed outright (the ledger line reads ` + `\`reason=...+archive-failed\`). Under sustained ENOSPC "keep it queued" degrades ` + `to "keep the newest, drop the oldest".`;
53318
53829
  if (dead.length > 0 || dropped.length > 0 || evicted.length > 0) {
53319
53830
  const parts = [];
53320
53831
  if (dead.length > 0)
@@ -54506,6 +55017,25 @@ function registerDoctorCommand(program3) {
54506
55017
  },
54507
55018
  { title: "MFF Skill", results: await checkMff(passphrase, vaultPath, config) },
54508
55019
  { title: "Webkite", results: runWebkiteChecks(config) },
55020
+ {
55021
+ title: "OpenRouter Credit",
55022
+ results: await runOpenRouterCreditChecks({
55023
+ enabled: usesOpenRouter(readLitellmConfigText()),
55024
+ readSecret: async (key) => {
55025
+ try {
55026
+ const { getViaBrokerStructured: getViaBrokerStructured2 } = await Promise.resolve().then(() => (init_client(), exports_client));
55027
+ const result = await getViaBrokerStructured2(key);
55028
+ if (result.kind === "ok" && result.entry.kind === "string") {
55029
+ return result.entry.value;
55030
+ }
55031
+ return null;
55032
+ } catch {
55033
+ return null;
55034
+ }
55035
+ },
55036
+ fetchFn: (url, init) => fetch(url, { headers: init?.headers, signal: init?.signal ?? AbortSignal.timeout(1e4) })
55037
+ })
55038
+ },
54509
55039
  { title: "Cron Session", results: runCronSessionChecks(config) },
54510
55040
  {
54511
55041
  title: "Generated-surface drift (KEN-130)",
@@ -54573,6 +55103,7 @@ var init_doctor = __esm(() => {
54573
55103
  init_scaffold();
54574
55104
  init_manager();
54575
55105
  init_accounts();
55106
+ init_schema();
54576
55107
  init_manifest();
54577
55108
  init_hindsight2();
54578
55109
  init_hindsight();
@@ -54589,6 +55120,7 @@ var init_doctor = __esm(() => {
54589
55120
  init_doctor_hostd();
54590
55121
  init_doctor_drive();
54591
55122
  init_doctor_webkite();
55123
+ init_doctor_openrouter_credit();
54592
55124
  init_doctor_cron_session();
54593
55125
  init_doctor_flood_pressure();
54594
55126
  init_doctor_drift();
@@ -69707,8 +70239,10 @@ import { join as join19 } from "node:path";
69707
70239
  import { execFileSync as execFileSync11 } from "node:child_process";
69708
70240
 
69709
70241
  // src/agents/handoff-summarizer.ts
70242
+ init_observation_scopes();
69710
70243
  import { readFileSync as readFileSync19, writeFileSync as writeFileSync9, renameSync as renameSync7, mkdirSync as mkdirSync16, existsSync as existsSync23, statSync as statSync11, readdirSync as readdirSync10 } from "node:fs";
69711
70244
  import { join as join18 } from "node:path";
70245
+ var HANDOFF_STATUS_MIRROR_SKIPPED = "mirror-skipped-invalid-scope";
69712
70246
  var DEFAULT_MAX_TURNS = 50;
69713
70247
  var TOPIC_MAX_CHARS = 117;
69714
70248
  var TURN_TEXT_MAX_CHARS = 1200;
@@ -69867,8 +70401,8 @@ async function buildHandoff(opts) {
69867
70401
  `);
69868
70402
  return "write-error";
69869
70403
  }
69870
- await mirrorToHindsight(briefing, opts).catch(() => {});
69871
- return "ok";
70404
+ const mirrored = await mirrorToHindsight(briefing, opts).catch(() => true);
70405
+ return mirrored ? "ok" : HANDOFF_STATUS_MIRROR_SKIPPED;
69872
70406
  }
69873
70407
  function errMsg(err) {
69874
70408
  if (err && typeof err === "object" && "message" in err) {
@@ -69880,15 +70414,22 @@ async function mirrorToHindsight(briefing, opts) {
69880
70414
  const url = opts.hindsightUrl ?? process.env.HINDSIGHT_API_URL;
69881
70415
  const bankId = opts.hindsightBankId ?? process.env.HINDSIGHT_BANK_ID ?? "default";
69882
70416
  if (!url)
69883
- return;
70417
+ return true;
69884
70418
  const fetchFn = opts.fetch ?? fetch;
69885
70419
  const endpoint = `${url.replace(/\/$/, "")}/v1/default/banks/${encodeURIComponent(bankId)}/memories`;
70420
+ const scope = resolveObservationScope(opts.observationScopes, opts.env ?? process.env);
70421
+ if (scope.kind === "invalid") {
70422
+ process.stderr.write(`handoff: hindsight mirror SKIPPED \u2014 observation scope ` + `${JSON.stringify(scope.raw)} is not valid ` + `(accepted: ${observationScopesHint()}). Fix ` + `memory.observation_scopes in switchroom.yaml (or the ` + `${OBSERVATION_SCOPES_ENV} export), then \`switchroom apply\` and ` + `restart the agent. The on-disk handoff sidecars were written ` + `normally; only the recallable Hindsight copy was skipped.
70423
+ `);
70424
+ return false;
70425
+ }
69886
70426
  const body = {
69887
70427
  items: [
69888
70428
  {
69889
70429
  content: briefing,
69890
70430
  document_id: "session_handoff",
69891
- tags: ["session_handoff", opts.agentName]
70431
+ tags: ["session_handoff", opts.agentName],
70432
+ ...scope.kind === "set" ? { observation_scopes: scope.scope } : {}
69892
70433
  }
69893
70434
  ],
69894
70435
  async: true
@@ -69903,6 +70444,7 @@ async function mirrorToHindsight(briefing, opts) {
69903
70444
  process.stderr.write(`handoff: hindsight mirror failed \u2014 ${errMsg(err)}
69904
70445
  `);
69905
70446
  }
70447
+ return true;
69906
70448
  }
69907
70449
  function findLatestSessionJsonl(claudeConfigDir) {
69908
70450
  const projects = join18(claudeConfigDir, "projects");
@@ -91975,6 +92517,7 @@ Dependency manifest`));
91975
92517
  init_helpers();
91976
92518
  init_loader();
91977
92519
  import { resolve as resolve48 } from "node:path";
92520
+ init_merge();
91978
92521
 
91979
92522
  // src/agents/session-retention.ts
91980
92523
  import {
@@ -92066,12 +92609,13 @@ function registerHandoffCommand(program3) {
92066
92609
  let agentDir;
92067
92610
  try {
92068
92611
  const config = getConfig(program3);
92069
- agentConfig = config.agents[agentName];
92070
- if (!agentConfig) {
92612
+ const rawAgent = config.agents[agentName];
92613
+ if (!rawAgent) {
92071
92614
  process.stderr.write(`handoff: agent "${agentName}" not defined in switchroom.yaml
92072
92615
  `);
92073
92616
  return;
92074
92617
  }
92618
+ agentConfig = resolveAgentConfig(config.defaults, config.profiles, rawAgent);
92075
92619
  const agentsDir = resolveAgentsDir(config);
92076
92620
  agentDir = resolve48(agentsDir, agentName);
92077
92621
  } catch (err) {
@@ -92101,10 +92645,14 @@ function registerHandoffCommand(program3) {
92101
92645
  jsonlPath: jsonl,
92102
92646
  agentDir,
92103
92647
  agentName,
92104
- maxTurns: cappedMaxTurns
92648
+ maxTurns: cappedMaxTurns,
92649
+ observationScopes: agentConfig?.memory?.observation_scopes
92105
92650
  });
92106
92651
  process.stderr.write(`handoff: ${status}
92107
92652
  `);
92653
+ if (status === HANDOFF_STATUS_MIRROR_SKIPPED) {
92654
+ process.exitCode = 1;
92655
+ }
92108
92656
  const retention = pruneSessionJsonl(claudeConfigDir, {
92109
92657
  maxCount: continuity?.session_retention_max_count ?? DEFAULT_SESSION_RETENTION_MAX_COUNT,
92110
92658
  maxAgeDays: continuity?.session_retention_max_age_days ?? DEFAULT_SESSION_RETENTION_MAX_AGE_DAYS,
@@ -101885,25 +102433,25 @@ function syncIssue(deps, repo, job_spec, iss) {
101885
102433
  }
101886
102434
  return;
101887
102435
  }
101888
- const num = String(iss.gh_issue);
101889
- const upd = deps.run(["issue", "edit", num, "-R", repo, "--body", body]);
102436
+ const num2 = String(iss.gh_issue);
102437
+ const upd = deps.run(["issue", "edit", num2, "-R", repo, "--body", body]);
101890
102438
  if (!upd.ok) {
101891
- deps.log(`fleet-health: gh issue edit #${num} failed: ${upd.stderr}`);
102439
+ deps.log(`fleet-health: gh issue edit #${num2} failed: ${upd.stderr}`);
101892
102440
  }
101893
102441
  if (iss.status === "closed") {
101894
102442
  const c = deps.run([
101895
102443
  "issue",
101896
102444
  "close",
101897
- num,
102445
+ num2,
101898
102446
  "-R",
101899
102447
  repo,
101900
102448
  "--comment",
101901
102449
  `Verified count-drop (frequency ${iss.frequency} \u2264 resolved threshold). Closed by the Fleet Health sensor.`
101902
102450
  ]);
101903
102451
  if (c.ok)
101904
- deps.log(`fleet-health: closed GH #${num} (count-drop) for ${iss.dedup_key}`);
102452
+ deps.log(`fleet-health: closed GH #${num2} (count-drop) for ${iss.dedup_key}`);
101905
102453
  else
101906
- deps.log(`fleet-health: gh issue close #${num} failed: ${c.stderr}`);
102454
+ deps.log(`fleet-health: gh issue close #${num2} failed: ${c.stderr}`);
101907
102455
  }
101908
102456
  }
101909
102457
  function syncLedgerIssues(ledger, repo, deps) {
@@ -103275,6 +103823,278 @@ async function notifyOperator(agentsDir, text, log) {
103275
103823
  return agent !== null;
103276
103824
  }
103277
103825
 
103826
+ // src/cli/openrouter-watch.ts
103827
+ init_source();
103828
+ import { existsSync as existsSync113, readdirSync as readdirSync46 } from "node:fs";
103829
+ import { homedir as homedir64 } from "node:os";
103830
+ import { join as join112, resolve as resolve65 } from "node:path";
103831
+
103832
+ // src/openrouter/install-cron.ts
103833
+ import { existsSync as existsSync112, mkdirSync as mkdirSync63, readFileSync as readFileSync99, renameSync as renameSync28, writeFileSync as writeFileSync47 } from "node:fs";
103834
+ import { dirname as dirname42 } from "node:path";
103835
+ var CRON_PATH2 = "/etc/cron.d/openrouter-watch";
103836
+ var CRON_SCHEDULE2 = "17 * * * *";
103837
+ var CRON_LOG_PATH2 = "/var/log/openrouter-watch.log";
103838
+ var CRON_LOCK_PATH2 = "/run/lock/openrouter-watch.lock";
103839
+ function renderCron2(opts) {
103840
+ return `# switchroom openrouter-watch \u2014 model-free OpenRouter credit watchdog.
103841
+ ` + `# Managed by \`switchroom openrouter-watch --install-cron\`; edits are overwritten.
103842
+ ` + `# Exit 0 = clean, 10 = credit signal firing (DM sent), 1 = the check could not complete.
103843
+ ` + `SHELL=/bin/sh
103844
+ ` + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
103845
+ ` + `${CRON_SCHEDULE2} ${opts.user} /usr/bin/flock -n ${CRON_LOCK_PATH2} ` + `${opts.binary} openrouter-watch >> ${CRON_LOG_PATH2} 2>&1
103846
+ `;
103847
+ }
103848
+ function installCron2(opts) {
103849
+ const path10 = opts.path ?? CRON_PATH2;
103850
+ const content = renderCron2(opts);
103851
+ if (existsSync112(path10)) {
103852
+ try {
103853
+ if (readFileSync99(path10, "utf8") === content) {
103854
+ return { status: "unchanged", path: path10, content };
103855
+ }
103856
+ } catch {}
103857
+ }
103858
+ mkdirSync63(dirname42(path10), { recursive: true });
103859
+ const tmp = `${path10}.${process.pid}.tmp`;
103860
+ writeFileSync47(tmp, content, { mode: 420 });
103861
+ renameSync28(tmp, path10);
103862
+ return { status: "installed", path: path10, content };
103863
+ }
103864
+ // src/openrouter/state.ts
103865
+ import { mkdirSync as mkdirSync64, readFileSync as readFileSync100, renameSync as renameSync29, writeFileSync as writeFileSync48 } from "node:fs";
103866
+ import { homedir as homedir63 } from "node:os";
103867
+ import { dirname as dirname43, resolve as resolve64 } from "node:path";
103868
+ function defaultStatePath3(home2 = process.env.SWITCHROOM_HOME ?? process.env.HOME ?? homedir63()) {
103869
+ return resolve64(home2, ".switchroom", "openrouter-watch", "state.json");
103870
+ }
103871
+ function loadState2(path10) {
103872
+ let parsed;
103873
+ try {
103874
+ parsed = JSON.parse(readFileSync100(path10, "utf8"));
103875
+ } catch {
103876
+ return {};
103877
+ }
103878
+ if (typeof parsed !== "object" || parsed === null)
103879
+ return {};
103880
+ const s = parsed;
103881
+ if (s.v !== 1)
103882
+ return {};
103883
+ const out = {};
103884
+ if (typeof s.lastNotifiedAt === "number" && Number.isFinite(s.lastNotifiedAt)) {
103885
+ out.lastNotifiedAt = s.lastNotifiedAt;
103886
+ }
103887
+ if (typeof s.lastNotifiedStatus === "string")
103888
+ out.lastNotifiedStatus = s.lastNotifiedStatus;
103889
+ if (out.lastNotifiedAt === undefined)
103890
+ delete out.lastNotifiedStatus;
103891
+ return out;
103892
+ }
103893
+ function saveState2(path10, state) {
103894
+ mkdirSync64(dirname43(path10), { recursive: true, mode: 448 });
103895
+ const tmp = `${path10}.${process.pid}.tmp`;
103896
+ const payload = { v: 1, ...state };
103897
+ writeFileSync48(tmp, JSON.stringify(payload, null, 2) + `
103898
+ `, { mode: 384 });
103899
+ renameSync29(tmp, path10);
103900
+ }
103901
+
103902
+ // src/openrouter/watch.ts
103903
+ var RENOTIFY_MS2 = 6 * 60 * 60 * 1000;
103904
+ function decideNotification(assessment, state, now) {
103905
+ if (!assessment.actionable) {
103906
+ return { notice: null, nextState: {}, firing: false };
103907
+ }
103908
+ const escalated = state.lastNotifiedStatus != null && state.lastNotifiedStatus !== "fail" && assessment.status === "fail";
103909
+ const cooled = state.lastNotifiedAt == null || now - state.lastNotifiedAt >= RENOTIFY_MS2;
103910
+ if (!escalated && !cooled) {
103911
+ return { notice: null, nextState: state, firing: true };
103912
+ }
103913
+ const repeat = state.lastNotifiedAt != null && !escalated;
103914
+ return {
103915
+ notice: renderNotice(assessment, { repeat, escalated }),
103916
+ nextState: { lastNotifiedAt: now, lastNotifiedStatus: assessment.status },
103917
+ firing: true
103918
+ };
103919
+ }
103920
+ function renderNotice(assessment, opts) {
103921
+ const head = assessment.status === "fail" ? opts.escalated ? "\uD83D\uDEA8 OpenRouter credit \u2014 ESCALATED" : "\uD83D\uDEA8 OpenRouter credit" : "\u26a0\ufe0f OpenRouter credit";
103922
+ const suffix = opts.repeat && !opts.escalated ? " (still unresolved)" : "";
103923
+ return [
103924
+ `${head}${suffix}`,
103925
+ assessment.detail,
103926
+ assessment.fix ? `\u2192 ${assessment.fix}` : ""
103927
+ ].filter((l) => l.length > 0).join(`
103928
+ `);
103929
+ }
103930
+
103931
+ // src/openrouter/run.ts
103932
+ async function tick2(deps) {
103933
+ const now = deps.now?.() ?? Date.now();
103934
+ let apiKey = null;
103935
+ try {
103936
+ apiKey = await deps.readSecret(OPENROUTER_VAULT_KEY);
103937
+ } catch (err2) {
103938
+ deps.log(`openrouter-watch: vault read failed for \`${OPENROUTER_VAULT_KEY}\`: ${err2.message}`);
103939
+ }
103940
+ if (!apiKey) {
103941
+ deps.log(`openrouter-watch: vault key \`${OPENROUTER_VAULT_KEY}\` is not readable \u2014 credit cannot be checked.`);
103942
+ return {
103943
+ assessment: {
103944
+ status: "skip",
103945
+ detail: `Vault key \`${OPENROUTER_VAULT_KEY}\` unreadable.`,
103946
+ actionable: false
103947
+ },
103948
+ notice: null,
103949
+ firing: false,
103950
+ delivered: false,
103951
+ exitCode: 1
103952
+ };
103953
+ }
103954
+ const fetched = await fetchKeySnapshot(apiKey, deps.fetchFn);
103955
+ const assessment = fetched.kind === "ok" ? evaluateCredit(fetched.snapshot) : assessFetchFailure(fetched);
103956
+ const prior = loadState2(deps.statePath);
103957
+ const decision = decideNotification(assessment, prior, now);
103958
+ let delivered = false;
103959
+ if (decision.notice && !deps.dryRun) {
103960
+ delivered = await deps.notify(decision.notice);
103961
+ }
103962
+ if (!deps.dryRun && (decision.notice === null || delivered)) {
103963
+ try {
103964
+ saveState2(deps.statePath, decision.nextState);
103965
+ } catch (err2) {
103966
+ deps.log(`openrouter-watch: could not persist state: ${err2.message}`);
103967
+ return { assessment, notice: decision.notice, firing: decision.firing, delivered, exitCode: 1 };
103968
+ }
103969
+ }
103970
+ if (decision.notice && !deps.dryRun && !delivered) {
103971
+ deps.log("openrouter-watch: alert reached no gateway \u2014 undelivered, will retry next tick");
103972
+ return { assessment, notice: decision.notice, firing: decision.firing, delivered, exitCode: 1 };
103973
+ }
103974
+ const cannotComplete = fetched.kind === "unreachable";
103975
+ const exitCode = cannotComplete ? 1 : decision.firing ? 10 : 0;
103976
+ return { assessment, notice: decision.notice, firing: decision.firing, delivered, exitCode };
103977
+ }
103978
+ // src/cli/openrouter-watch.ts
103979
+ function registerOpenRouterWatchCommand(program3) {
103980
+ program3.command("openrouter-watch").description("Model-free OpenRouter credit watchdog: warns before the key's credit runs out. " + "Alerts at most once per 6h, and escalates immediately.").option("--install-cron", `arm the watchdog: write ${CRON_PATH2} (${CRON_SCHEDULE2}, flock-guarded) and exit`, false).option("--cron-user <user>", "unix user the cron tick runs as (default: current user)").option("--dry-run", "evaluate and print; send no DM and write no state", false).option("--json", "emit machine-readable JSON", false).option("--state <path>", "state file path (default ~/.switchroom/openrouter-watch/state.json)").option("--agents-dir <path>", "agents scaffold dir (default ~/.switchroom/agents)").action(async (opts) => {
103981
+ if (opts.installCron) {
103982
+ process.exitCode = runInstallCron2(opts.cronUser);
103983
+ return;
103984
+ }
103985
+ const agentsDir = opts.agentsDir ?? process.env.SWITCHROOM_AGENTS_DIR ?? join112(process.env.SWITCHROOM_HOME ?? process.env.HOME ?? homedir64(), ".switchroom", "agents");
103986
+ const statePath = opts.state ?? defaultStatePath3();
103987
+ const log = (m) => {
103988
+ process.stderr.write(`${m}
103989
+ `);
103990
+ };
103991
+ const result = await tick2({
103992
+ statePath,
103993
+ dryRun: Boolean(opts.dryRun),
103994
+ log,
103995
+ readSecret: async (key) => {
103996
+ const { getViaBrokerStructured: getViaBrokerStructured2 } = await Promise.resolve().then(() => (init_client(), exports_client));
103997
+ const res = await getViaBrokerStructured2(key);
103998
+ if (res.kind === "ok" && res.entry.kind === "string")
103999
+ return res.entry.value;
104000
+ return null;
104001
+ },
104002
+ fetchFn: (url, init) => fetch(url, {
104003
+ headers: init?.headers,
104004
+ signal: init?.signal ?? AbortSignal.timeout(1e4)
104005
+ }),
104006
+ notify: (text) => notifyOperator2(agentsDir, text, log)
104007
+ });
104008
+ if (opts.json) {
104009
+ process.stdout.write(JSON.stringify({
104010
+ status: result.assessment.status,
104011
+ detail: result.assessment.detail,
104012
+ fix: result.assessment.fix ?? null,
104013
+ actionable: result.assessment.actionable,
104014
+ firing: result.firing,
104015
+ notified: result.notice,
104016
+ delivered: result.delivered,
104017
+ exitCode: result.exitCode,
104018
+ vaultKey: OPENROUTER_VAULT_KEY,
104019
+ thresholds: {
104020
+ warnRemainingFraction: WARN_REMAINING_FRACTION,
104021
+ warnRemainingUsd: WARN_REMAINING_USD,
104022
+ failRemainingFraction: FAIL_REMAINING_FRACTION,
104023
+ failRemainingUsd: FAIL_REMAINING_USD,
104024
+ renotifyMs: RENOTIFY_MS2
104025
+ }
104026
+ }, null, 2) + `
104027
+ `);
104028
+ } else {
104029
+ const mark = result.assessment.status === "fail" ? source_default.red("FAIL") : result.assessment.status === "warn" ? source_default.yellow("WARN") : result.assessment.status === "skip" ? source_default.gray("skip") : source_default.green("ok ");
104030
+ process.stdout.write(`${mark} ${result.assessment.detail}
104031
+ `);
104032
+ if (result.assessment.fix)
104033
+ process.stdout.write(` \u2192 ${result.assessment.fix}
104034
+ `);
104035
+ if (result.notice) {
104036
+ const header = opts.dryRun ? "would send" : result.delivered ? "sent" : "UNDELIVERED";
104037
+ process.stdout.write(`
104038
+ ${source_default.bold(`operator DM ${header}:`)}
104039
+ `);
104040
+ process.stdout.write(result.notice.split(`
104041
+ `).map((l) => ` | ${l}`).join(`
104042
+ `) + `
104043
+ `);
104044
+ } else if (result.firing) {
104045
+ process.stdout.write(source_default.gray(` (firing, but already reported within the ${RENOTIFY_MS2 / 3600000}h window)
104046
+ `));
104047
+ }
104048
+ }
104049
+ process.exitCode = result.exitCode;
104050
+ });
104051
+ }
104052
+ function runInstallCron2(cronUser) {
104053
+ const user = cronUser ?? process.env.SUDO_USER ?? process.env.USER ?? process.env.LOGNAME;
104054
+ if (!user || user === "root") {
104055
+ process.stderr.write(source_default.red("openrouter-watch: refusing to install a cron for `root`.\n") + " The state file and the agents scaffold live under the OPERATOR's " + "~/.switchroom, so a root tick would keep its re-notify ledger somewhere " + `nothing else reads.
104056
+ ` + " Re-run with `--cron-user <operator>`.\n");
104057
+ return 1;
104058
+ }
104059
+ const binary = process.env.SWITCHROOM_BINARY ?? "/usr/local/bin/switchroom";
104060
+ if (!binary.startsWith("/")) {
104061
+ process.stderr.write(source_default.red(`openrouter-watch: SWITCHROOM_BINARY must be an absolute path (got ${binary})
104062
+ `));
104063
+ return 1;
104064
+ }
104065
+ let res;
104066
+ try {
104067
+ res = installCron2({ user, binary });
104068
+ } catch (e) {
104069
+ process.stderr.write(source_default.red(`openrouter-watch: could not write ${CRON_PATH2}: ${e.message}
104070
+ `) + " This path needs root; re-run under sudo with `--cron-user <operator>`.\n");
104071
+ return 1;
104072
+ }
104073
+ const verb = res.status === "installed" ? "installed" : "already up to date";
104074
+ process.stdout.write(`${source_default.green("\u2713")} openrouter-watch cron ${verb} at ${res.path} ` + `(${CRON_SCHEDULE2}, as ${user})
104075
+ ` + ` Verify with: switchroom openrouter-watch --dry-run
104076
+ `);
104077
+ return 0;
104078
+ }
104079
+ async function notifyOperator2(agentsDir, text, log) {
104080
+ const candidates = [];
104081
+ try {
104082
+ for (const name of readdirSync46(agentsDir).sort()) {
104083
+ const sock = resolve65(agentsDir, name, "telegram", "gateway.sock");
104084
+ if (existsSync113(sock))
104085
+ candidates.push({ agent: name, sock });
104086
+ }
104087
+ } catch (e) {
104088
+ log(`openrouter-watch: agents dir unreadable (${e.message})`);
104089
+ }
104090
+ if (candidates.length === 0) {
104091
+ log("openrouter-watch: no gateway socket found \u2014 alert undelivered, will retry next tick");
104092
+ return false;
104093
+ }
104094
+ const agent = await postOperatorNoticeViaGateways(candidates, text, log, 5000, "openrouter-watch", "");
104095
+ return agent !== null;
104096
+ }
104097
+
103278
104098
  // src/cli/index.ts
103279
104099
  init_posthog();
103280
104100
  installGlobalErrorHandlers();
@@ -103334,6 +104154,7 @@ registerWebdCommand(program3);
103334
104154
  registerHostCommand(program3);
103335
104155
  registerFleetHealthCommand(program3);
103336
104156
  registerHindsightWatchCommand(program3);
104157
+ registerOpenRouterWatchCommand(program3);
103337
104158
 
103338
104159
  // bin/switchroom.ts
103339
104160
  program3.parse();