switchroom 0.15.1 → 0.15.2

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.
@@ -11348,6 +11348,7 @@ var AgentSchema = exports_external.object({
11348
11348
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
11349
11349
  network_isolation: NetworkIsolationSchema,
11350
11350
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently — " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
11351
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet — read any agent's logs, " + "docker exec into peers, edit host files — instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent — it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
11351
11352
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only — prefer the typed fields when they exist."),
11352
11353
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
11353
11354
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -11348,6 +11348,7 @@ var AgentSchema = exports_external.object({
11348
11348
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
11349
11349
  network_isolation: NetworkIsolationSchema,
11350
11350
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently — " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
11351
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet — read any agent's logs, " + "docker exec into peers, edit host files — instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent — it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
11351
11352
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only — prefer the typed fields when they exist."),
11352
11353
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
11353
11354
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -13558,7 +13559,10 @@ function enrichMirrorContent(sourceJson) {
13558
13559
  function configToShape(cfg) {
13559
13560
  const auth = cfg.auth ?? {};
13560
13561
  const agentsMap = cfg.agents ?? {};
13561
- const adminAgents = Object.entries(agentsMap).filter(([, a]) => a.admin === true).map(([name]) => name);
13562
+ const adminAgents = Object.entries(agentsMap).filter(([, a]) => {
13563
+ const cfg2 = a;
13564
+ return cfg2.admin === true || cfg2.root === true;
13565
+ }).map(([name]) => name);
13562
13566
  return {
13563
13567
  agents: Object.keys(agentsMap),
13564
13568
  consumers: (auth.consumers ?? []).map((c) => c.name),
@@ -13774,7 +13778,8 @@ class AuthBroker {
13774
13778
  }
13775
13779
  const sockPath = this.agentSocketPath(agentName);
13776
13780
  const uid = allocateAgentUid(agentName);
13777
- const adminFlag = this.config.agents?.[agentName]?.admin === true;
13781
+ const agentCfg = this.config.agents?.[agentName];
13782
+ const adminFlag = agentCfg?.admin === true || agentCfg?.root === true;
13778
13783
  await this.bindListener(sockPath, uid, 432, {
13779
13784
  kind: "agent",
13780
13785
  name: agentName,
@@ -12096,6 +12096,7 @@ var AgentSchema = exports_external.object({
12096
12096
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
12097
12097
  network_isolation: NetworkIsolationSchema,
12098
12098
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently \u2014 " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
12099
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet \u2014 read any agent's logs, " + "docker exec into peers, edit host files \u2014 instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent \u2014 it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
12099
12100
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only \u2014 prefer the typed fields when they exist."),
12100
12101
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
12101
12102
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -13912,6 +13912,7 @@ var init_schema = __esm(() => {
13912
13912
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
13913
13913
  network_isolation: NetworkIsolationSchema,
13914
13914
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently \u2014 " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
13915
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet \u2014 read any agent's logs, " + "docker exec into peers, edit host files \u2014 instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent \u2014 it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
13915
13916
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only \u2014 prefer the typed fields when they exist."),
13916
13917
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
13917
13918
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -23247,7 +23248,8 @@ function describeAgents(config) {
23247
23248
  resources,
23248
23249
  strippedCaps,
23249
23250
  networkIsolation: resolved.network_isolation === "strict" ? "strict" : "host",
23250
- admin: agent.admin === true,
23251
+ admin: agent.admin === true || agent.root === true,
23252
+ root: agent.root === true,
23251
23253
  bindMounts: agent.bind_mounts ? [...agent.bind_mounts] : [],
23252
23254
  userEnv: { ...resolved.env ?? {} },
23253
23255
  timezone: resolveTimezone(config, resolved)
@@ -23559,7 +23561,11 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23559
23561
  lines.push(` tty: true`);
23560
23562
  lines.push(` stdin_open: true`);
23561
23563
  lines.push(` stop_grace_period: 45s`);
23562
- lines.push(` user: "${a.uid}:${a.uid}"`);
23564
+ if (a.root) {
23565
+ lines.push(` user: "0:0"`);
23566
+ } else {
23567
+ lines.push(` user: "${a.uid}:${a.uid}"`);
23568
+ }
23563
23569
  lines.push(` mem_limit: ${a.resources.memLimit}`);
23564
23570
  if (a.resources.memReservation !== undefined) {
23565
23571
  lines.push(` mem_reservation: ${a.resources.memReservation}`);
@@ -23568,11 +23574,13 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23568
23574
  lines.push(` pids_limit: ${a.resources.pidsLimit}`);
23569
23575
  }
23570
23576
  lines.push(` cpus: ${a.resources.cpus.toFixed(1)}`);
23571
- lines.push(` security_opt:`);
23572
- lines.push(` - "no-new-privileges:true"`);
23573
- lines.push(` cap_drop:`);
23574
- lines.push(` - "ALL"`);
23575
- lines.push(` read_only: true`);
23577
+ if (!a.root) {
23578
+ lines.push(` security_opt:`);
23579
+ lines.push(` - "no-new-privileges:true"`);
23580
+ lines.push(` cap_drop:`);
23581
+ lines.push(` - "ALL"`);
23582
+ lines.push(` read_only: true`);
23583
+ }
23576
23584
  lines.push(` tmpfs:`);
23577
23585
  lines.push(` - /tmp:size=1g,mode=1777`);
23578
23586
  lines.push(` depends_on:`);
@@ -23615,6 +23623,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23615
23623
  if (switchroomConfigPath) {
23616
23624
  env2.SWITCHROOM_CONFIG = "/state/config/switchroom.yaml";
23617
23625
  }
23626
+ if (a.root === true) {
23627
+ env2.SWITCHROOM_AGENT_ROOT = "true";
23628
+ }
23618
23629
  if (a.admin === true) {
23619
23630
  env2.SWITCHROOM_AGENT_ADMIN = "true";
23620
23631
  }
@@ -23632,6 +23643,11 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23632
23643
  lines.push(` - broker-${a.name}-sock:/run/switchroom/broker`);
23633
23644
  lines.push(` - kernel-${a.name}-sock:/run/switchroom/kernel`);
23634
23645
  lines.push(` - auth-broker-${a.name}-sock:/run/switchroom/auth-broker`);
23646
+ if (a.root === true) {
23647
+ lines.push(` - /var/run/docker.sock:/var/run/docker.sock:rw`);
23648
+ lines.push(` - ${homePrefix}/.switchroom:/host-home/.switchroom:rw`);
23649
+ lines.push(` - /:/host:rw`);
23650
+ }
23635
23651
  if (a.admin === true) {
23636
23652
  if (existsSync14(`${hostHomeForChecks}/.switchroom/vault-audit.log`)) {
23637
23653
  lines.push(` - ${homePrefix}/.switchroom/vault-audit.log:/state/agent/home/.switchroom/vault-audit.log:ro`);
@@ -28704,6 +28720,118 @@ var init_protocol3 = __esm(() => {
28704
28720
  ResponseSchema3 = exports_external.object(ResponseEnvelope);
28705
28721
  });
28706
28722
 
28723
+ // src/memory/bank-health.ts
28724
+ function hindsightRestBase(mcpUrl) {
28725
+ return mcpUrl.replace(/\/mcp\/?$/, "").replace(/\/$/, "");
28726
+ }
28727
+ async function getJson(url, opts) {
28728
+ const fetchImpl = opts?.fetchImpl ?? fetch;
28729
+ const timeoutMs = opts?.timeoutMs ?? 15000;
28730
+ const controller = new AbortController;
28731
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
28732
+ try {
28733
+ const resp = await fetchImpl(url, { signal: controller.signal });
28734
+ clearTimeout(timeout);
28735
+ if (!resp.ok)
28736
+ return { ok: false, reason: `HTTP ${resp.status}` };
28737
+ return { ok: true, data: await resp.json() };
28738
+ } catch (err) {
28739
+ clearTimeout(timeout);
28740
+ if (err.name === "AbortError")
28741
+ return { ok: false, reason: "Timeout" };
28742
+ return { ok: false, reason: String(err.message ?? err) };
28743
+ }
28744
+ }
28745
+ async function inspectBankHealth(mcpUrl, bankId, opts) {
28746
+ const base = hindsightRestBase(mcpUrl);
28747
+ const bank = encodeURIComponent(bankId);
28748
+ const empty = {
28749
+ bankId,
28750
+ ok: false,
28751
+ totalDocuments: 0,
28752
+ totalFacts: 0,
28753
+ pendingOperations: 0,
28754
+ newestDocumentAt: null,
28755
+ unextractedDocuments: [],
28756
+ mentalModels: []
28757
+ };
28758
+ const stats = await getJson(`${base}/v1/default/banks/${bank}/stats`, opts);
28759
+ if (!stats.ok)
28760
+ return { ...empty, reason: stats.reason };
28761
+ const docs = await getJson(`${base}/v1/default/banks/${bank}/documents?limit=${DOCUMENTS_PAGE_LIMIT}`, opts);
28762
+ if (!docs.ok)
28763
+ return { ...empty, reason: docs.reason };
28764
+ const models = await getJson(`${base}/v1/default/banks/${bank}/mental-models`, opts);
28765
+ if (!models.ok)
28766
+ return { ...empty, reason: models.reason };
28767
+ const docItems = docs.data.items ?? [];
28768
+ let newestDocumentAt = null;
28769
+ const unextracted = [];
28770
+ for (const d of docItems) {
28771
+ const createdAt = d.created_at ?? "";
28772
+ if (createdAt && (!newestDocumentAt || createdAt > newestDocumentAt)) {
28773
+ newestDocumentAt = createdAt;
28774
+ }
28775
+ if ((d.memory_unit_count ?? 0) === 0 && d.id) {
28776
+ unextracted.push({
28777
+ id: d.id,
28778
+ createdAt,
28779
+ textLength: d.text_length ?? 0,
28780
+ memoryUnitCount: 0
28781
+ });
28782
+ }
28783
+ }
28784
+ unextracted.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
28785
+ return {
28786
+ bankId,
28787
+ ok: true,
28788
+ totalDocuments: stats.data.total_documents ?? docItems.length,
28789
+ totalFacts: stats.data.total_nodes ?? 0,
28790
+ pendingOperations: stats.data.pending_operations ?? 0,
28791
+ newestDocumentAt,
28792
+ unextractedDocuments: unextracted,
28793
+ mentalModels: (models.data.items ?? []).filter((m) => typeof m?.id === "string" && typeof m?.name === "string").map((m) => ({
28794
+ id: m.id,
28795
+ name: m.name,
28796
+ lastRefreshedAt: m.last_refreshed_at ?? null,
28797
+ createdAt: m.created_at ?? null,
28798
+ contentLength: (m.content ?? "").length,
28799
+ contentHead: (m.content ?? "").slice(0, 200)
28800
+ }))
28801
+ };
28802
+ }
28803
+ function ageDays(iso, now = new Date) {
28804
+ if (!iso)
28805
+ return null;
28806
+ const t = Date.parse(iso);
28807
+ if (Number.isNaN(t))
28808
+ return null;
28809
+ return Math.max(0, (now.getTime() - t) / 86400000);
28810
+ }
28811
+ function corruptedMentalModels(models) {
28812
+ const failurePhrase = /out of (extra )?usage|hit your (usage |session )?limit|resets \d|quota exceeded|rate.?limit/i;
28813
+ return models.filter((m) => {
28814
+ if (m.contentLength === 0)
28815
+ return m.lastRefreshedAt !== null;
28816
+ return m.contentLength < 300 && failurePhrase.test(m.contentHead);
28817
+ });
28818
+ }
28819
+ function staleMentalModels(models, staleDays = 7, now = new Date) {
28820
+ return models.filter((m) => {
28821
+ const age = ageDays(m.lastRefreshedAt ?? m.createdAt, now);
28822
+ return age !== null && age > staleDays;
28823
+ });
28824
+ }
28825
+ function recentUnextracted(docs, withinDays = 30, now = new Date, minTextLength = 1000) {
28826
+ return docs.filter((d) => {
28827
+ if (d.textLength < minTextLength)
28828
+ return false;
28829
+ const age = ageDays(d.createdAt, now);
28830
+ return age !== null && age <= withinDays;
28831
+ });
28832
+ }
28833
+ var DOCUMENTS_PAGE_LIMIT = 500;
28834
+
28707
28835
  // src/host-control/audit-reader.ts
28708
28836
  import { homedir as homedir20 } from "node:os";
28709
28837
  import { join as join38 } from "node:path";
@@ -31694,6 +31822,7 @@ __export(exports_doctor, {
31694
31822
  checkHindsightConsumer: () => checkHindsightConsumer,
31695
31823
  checkDepsCacheWritable: () => checkDepsCacheWritable,
31696
31824
  checkConfig: () => checkConfig,
31825
+ checkBankIngestHealth: () => checkBankIngestHealth,
31697
31826
  checkAgents: () => checkAgents,
31698
31827
  MFF_VAULT_KEY: () => MFF_VAULT_KEY
31699
31828
  });
@@ -32253,6 +32382,82 @@ function probeAuthBrokerSocket(consumerName) {
32253
32382
  return "unreachable";
32254
32383
  return "missing";
32255
32384
  }
32385
+ async function checkBankIngestHealth(config, url, opts) {
32386
+ const results = [];
32387
+ const now = opts?.now ?? new Date;
32388
+ const banks = new Map;
32389
+ for (const [agentName, agentConfig] of Object.entries(config.agents)) {
32390
+ const bankId = agentConfig.memory?.collection ?? agentName;
32391
+ banks.set(bankId, [...banks.get(bankId) ?? [], agentName]);
32392
+ }
32393
+ const inspected = await Promise.all([...banks].map(async ([bankId, agents]) => [bankId, agents, await inspectBankHealth(url, bankId, { fetchImpl: opts?.fetchImpl })]));
32394
+ for (const [bankId, agents, h] of inspected) {
32395
+ const label = `bank ${bankId}` + (agents[0] !== bankId ? ` (${agents.join(", ")})` : "");
32396
+ if (!h.ok) {
32397
+ results.push({
32398
+ name: label,
32399
+ status: "warn",
32400
+ detail: `inspection failed: ${h.reason}`
32401
+ });
32402
+ continue;
32403
+ }
32404
+ if (h.totalDocuments === 0) {
32405
+ results.push({
32406
+ name: label,
32407
+ status: "warn",
32408
+ detail: "bank is empty (no documents retained yet)"
32409
+ });
32410
+ continue;
32411
+ }
32412
+ const gaps = recentUnextracted(h.unextractedDocuments, 30, now);
32413
+ const stale = staleMentalModels(h.mentalModels, 7, now);
32414
+ const corrupted = corruptedMentalModels(h.mentalModels);
32415
+ const newestAge = ageDays(h.newestDocumentAt, now);
32416
+ const summary = `${h.totalDocuments} docs \u00b7 ${h.totalFacts} facts \u00b7 ` + `newest ${h.newestDocumentAt?.slice(0, 10) ?? "?"} \u00b7 ` + `${h.mentalModels.length} mental models`;
32417
+ if (corrupted.length > 0) {
32418
+ results.push({
32419
+ name: label,
32420
+ status: "fail",
32421
+ detail: `${corrupted.length} mental model(s) hold a persisted LLM-failure message instead of real content (${corrupted.map((m) => m.name).join(", ")}) \u2014 that garbage is injected into every agent turn`,
32422
+ fix: "A refresh ran while the LLM was quota-walled and the error string was stored as content. " + "Once quota recovers: POST /v1/default/banks/<bank>/mental-models/<id>/refresh and verify the content regenerated."
32423
+ });
32424
+ continue;
32425
+ }
32426
+ if (gaps.length > 0) {
32427
+ const oldest = gaps[0];
32428
+ results.push({
32429
+ name: label,
32430
+ status: "fail",
32431
+ detail: `${gaps.length} document(s) in the last 30d retained with ZERO extracted facts (oldest ${oldest.createdAt.slice(0, 10)}) \u2014 conversations from those turns are invisible to recall`,
32432
+ fix: "Extraction silently failed (check hindsight logs for quota/429/shm). Re-extract each: " + `curl -X POST '${hindsightDocReprocessUrl(url, bankId, oldest.id)}' (repeat per doc id)`
32433
+ });
32434
+ continue;
32435
+ }
32436
+ if (h.pendingOperations > 0 && newestAge !== null && newestAge > 1) {
32437
+ results.push({
32438
+ name: label,
32439
+ status: "warn",
32440
+ detail: `${h.pendingOperations} pending operation(s) with no new documents for ${Math.round(newestAge)}d \u2014 pipeline may be stuck`
32441
+ });
32442
+ continue;
32443
+ }
32444
+ if (stale.length > 0) {
32445
+ results.push({
32446
+ name: label,
32447
+ status: "warn",
32448
+ detail: `${summary} \u00b7 ${stale.length} stale (>7d): ${stale.map((m) => m.name).join(", ")}`,
32449
+ fix: "POST /v1/default/banks/<bank>/mental-models/<id>/refresh to regenerate"
32450
+ });
32451
+ continue;
32452
+ }
32453
+ results.push({ name: label, status: "ok", detail: summary });
32454
+ }
32455
+ return results;
32456
+ }
32457
+ function hindsightDocReprocessUrl(mcpUrl, bankId, docId) {
32458
+ const base = mcpUrl.replace(/\/mcp\/?$/, "").replace(/\/$/, "");
32459
+ return `${base}/v1/default/banks/${encodeURIComponent(bankId)}/documents/${encodeURIComponent(docId)}/reprocess`;
32460
+ }
32256
32461
  async function checkHindsight(config) {
32257
32462
  if (!isHindsightEnabled(config)) {
32258
32463
  return [];
@@ -32303,6 +32508,7 @@ async function checkHindsight(config) {
32303
32508
  }
32304
32509
  results.push(checkHindsightConsumer(config));
32305
32510
  results.push(...checkHindsightContainerHealth());
32511
+ results.push(...await checkBankIngestHealth(config, url));
32306
32512
  for (const [agentName, agentConfig] of Object.entries(config.agents)) {
32307
32513
  const bankId = agentConfig.memory?.collection ?? agentName;
32308
32514
  const hasBankMission = !!agentConfig.memory?.bank_mission;
@@ -49958,8 +50164,8 @@ var {
49958
50164
  } = import__.default;
49959
50165
 
49960
50166
  // src/build-info.ts
49961
- var VERSION = "0.15.1";
49962
- var COMMIT_SHA = "a93177c8";
50167
+ var VERSION = "0.15.2";
50168
+ var COMMIT_SHA = "95461524";
49963
50169
 
49964
50170
  // src/cli/agent.ts
49965
50171
  init_source();
@@ -51772,7 +51978,8 @@ function buildWorkspaceContext(args) {
51772
51978
  botToken: resolvedBotToken ?? rawBotToken,
51773
51979
  forumChatId: telegramConfig.forum_chat_id,
51774
51980
  dangerousMode: agentConfig.dangerous_mode === true,
51775
- admin: agentConfig.admin === true,
51981
+ admin: agentConfig.admin === true || agentConfig.root === true,
51982
+ root: agentConfig.root === true,
51776
51983
  useSwitchroomPlugin: usesSwitchroomTelegramPlugin(agentConfig),
51777
51984
  useHotReloadStable: agentConfig.channels?.telegram?.hotReloadStable === true,
51778
51985
  telegramEnabledFlag: agentConfig.channels?.telegram?.enabled === false ? "false" : "true",
@@ -52124,7 +52331,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
52124
52331
  alwaysLoad: true
52125
52332
  }
52126
52333
  };
52127
- if (agentConfig.admin === true) {
52334
+ if (agentConfig.admin === true || agentConfig.root === true) {
52128
52335
  mcpServers["hostd"] = {
52129
52336
  command: switchroomCliPath,
52130
52337
  args: ["mcp", "hostd"],
@@ -52945,7 +53152,8 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
52945
53152
  model: agentConfig.model,
52946
53153
  schedule: agentConfig.schedule,
52947
53154
  useSwitchroomPlugin: usesSwitchroomTelegramPlugin(agentConfig),
52948
- admin: agentConfig.admin === true
53155
+ admin: agentConfig.admin === true || agentConfig.root === true,
53156
+ root: agentConfig.root === true
52949
53157
  };
52950
53158
  let rendered = renderTemplate(claudeMdSrc, claudeContext);
52951
53159
  const vaultProtocol = renderVaultProtocolFragment(claudeContext);
@@ -53174,7 +53382,7 @@ ${body}
53174
53382
  alwaysLoad: true
53175
53383
  }
53176
53384
  };
53177
- if (agentConfig.admin === true) {
53385
+ if (agentConfig.admin === true || agentConfig.root === true) {
53178
53386
  mcpServers["hostd"] = {
53179
53387
  command: switchroomCliPath,
53180
53388
  args: ["mcp", "hostd"],
@@ -56220,10 +56428,12 @@ function registerAgentCommand(program3) {
56220
56428
  const agentConfig = config.agents[name];
56221
56429
  const status = statuses[name];
56222
56430
  const sched = schedulerStates[name];
56431
+ const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
56223
56432
  return {
56224
56433
  name,
56225
56434
  status: status?.active ?? "unknown",
56226
56435
  uptime: formatUptime2(status?.uptime ?? null),
56436
+ model: resolved.model ?? SWITCHROOM_DEFAULT_MAIN_MODEL,
56227
56437
  extends: agentConfig.extends ?? "default",
56228
56438
  topic_name: agentConfig.topic_name,
56229
56439
  topic_emoji: agentConfig.topic_emoji,
@@ -63747,7 +63957,7 @@ class VaultBroker {
63747
63957
  const isGrantMgmtOp = req.op === "mint_grant" || req.op === "list_grants" || req.op === "revoke_grant";
63748
63958
  let mintPassphraseAttested = false;
63749
63959
  if (isGrantMgmtOp) {
63750
- const isAdminAgent = agentName !== null && this.config?.agents?.[agentName]?.admin === true;
63960
+ const isAdminAgent = agentName !== null && (this.config?.agents?.[agentName]?.admin === true || this.config?.agents?.[agentName]?.root === true);
63751
63961
  if ((req.op === "mint_grant" || req.op === "list_grants") && req.passphrase !== undefined && req.passphrase !== "") {
63752
63962
  if (req.attest_via_posture === true) {
63753
63963
  writeAudit({
@@ -73178,6 +73388,66 @@ async function handleGetApprovals() {
73178
73388
  const sorted = [...decisions].sort((a, b) => b.granted_at - a.granted_at);
73179
73389
  return { reachable: true, decisions: sorted };
73180
73390
  }
73391
+ async function handleGetMemoryHealth(config, opts) {
73392
+ const url = config.memory?.config?.url ?? "http://127.0.0.1:18888/mcp/";
73393
+ const now = opts?.now ?? new Date;
73394
+ let reachable = false;
73395
+ try {
73396
+ reachable = (await probeHindsight(url, { fetchImpl: opts?.fetchImpl })).ok;
73397
+ } catch {
73398
+ reachable = false;
73399
+ }
73400
+ if (!reachable)
73401
+ return { reachable, url, banks: [] };
73402
+ const banks = new Map;
73403
+ for (const agentName of Object.keys(config.agents)) {
73404
+ const bank = getCollectionForAgent(agentName, config);
73405
+ banks.set(bank, [...banks.get(bank) ?? [], agentName]);
73406
+ }
73407
+ const rows = await Promise.all([...banks].map(async ([bank, agents]) => {
73408
+ const h = await inspectBankHealth(url, bank, { fetchImpl: opts?.fetchImpl });
73409
+ const gaps = recentUnextracted(h.unextractedDocuments, 30, now);
73410
+ const stale = staleMentalModels(h.mentalModels, 7, now);
73411
+ const corrupted = corruptedMentalModels(h.mentalModels);
73412
+ let status = "ok";
73413
+ let statusDetail = "facts flowing";
73414
+ if (!h.ok) {
73415
+ status = "warn";
73416
+ statusDetail = `inspection failed: ${h.reason ?? "unknown"}`;
73417
+ } else if (corrupted.length > 0) {
73418
+ status = "fail";
73419
+ statusDetail = `${corrupted.length} mental model(s) corrupted by an LLM failure message \u2014 injected into every turn until refreshed`;
73420
+ } else if (gaps.length > 0) {
73421
+ status = "fail";
73422
+ statusDetail = `${gaps.length} recent conversation(s) stored with zero extracted facts \u2014 invisible to recall`;
73423
+ } else if (stale.length > 0) {
73424
+ status = "warn";
73425
+ statusDetail = `${stale.length} mental model(s) not refreshed in >7d`;
73426
+ } else if (h.totalDocuments === 0) {
73427
+ status = "warn";
73428
+ statusDetail = "bank is empty";
73429
+ }
73430
+ return {
73431
+ bank,
73432
+ agents,
73433
+ ok: h.ok,
73434
+ reason: h.reason,
73435
+ totalDocuments: h.totalDocuments,
73436
+ totalFacts: h.totalFacts,
73437
+ pendingOperations: h.pendingOperations,
73438
+ newestDocumentAt: h.newestDocumentAt,
73439
+ recentUnextractedCount: gaps.length,
73440
+ oldestUnextractedAt: gaps[0]?.createdAt ?? null,
73441
+ mentalModels: h.mentalModels,
73442
+ staleMentalModelCount: stale.length,
73443
+ corruptedMentalModelNames: corrupted.map((m) => m.name),
73444
+ status,
73445
+ statusDetail
73446
+ };
73447
+ }));
73448
+ rows.sort((a, b) => a.bank.localeCompare(b.bank));
73449
+ return { reachable, url, banks: rows };
73450
+ }
73181
73451
 
73182
73452
  // src/web/webhook-handler.ts
73183
73453
  import { appendFileSync as appendFileSync3, existsSync as existsSync48, mkdirSync as mkdirSync27, readFileSync as readFileSync43, writeFileSync as writeFileSync25 } from "fs";
@@ -73820,6 +74090,9 @@ function parseRoute(pathname, method) {
73820
74090
  if (method === "GET" && pathname === "/api/system-health") {
73821
74091
  return { handler: "getSystemHealth", params: {} };
73822
74092
  }
74093
+ if (method === "GET" && pathname === "/api/memory-health") {
74094
+ return { handler: "getMemoryHealth", params: {} };
74095
+ }
73823
74096
  if (method === "GET" && pathname === "/api/google-accounts") {
73824
74097
  return { handler: "getGoogleAccounts", params: {} };
73825
74098
  }
@@ -73981,6 +74254,8 @@ function startWebServer(config, port, hostname = "127.0.0.1", configPath) {
73981
74254
  }
73982
74255
  case "getSystemHealth":
73983
74256
  return (async () => jsonResponse(await handleGetSystemHealth(config)))();
74257
+ case "getMemoryHealth":
74258
+ return (async () => jsonResponse(await handleGetMemoryHealth(freshConfig())))();
73984
74259
  case "getGoogleAccounts":
73985
74260
  return (async () => jsonResponse(await handleGetGoogleAccounts(freshConfig())))();
73986
74261
  case "getMicrosoftAccounts":
@@ -79590,7 +79865,14 @@ async function ensureSwitchroomFolder2(deps, agentName) {
79590
79865
  const top = await ensureFolder2(deps, "Switchroom", "root");
79591
79866
  return ensureFolder2(deps, agentName, top.id);
79592
79867
  }
79868
+ var GDRIVE_MULTIPART_MAX_BYTES = 5 * 1024 * 1024;
79593
79869
  async function uploadFile2(deps, parentId, filename, bytes, mimeType = "application/octet-stream") {
79870
+ if (bytes.byteLength <= GDRIVE_MULTIPART_MAX_BYTES) {
79871
+ return uploadMultipart(deps, parentId, filename, bytes, mimeType);
79872
+ }
79873
+ return uploadResumable(deps, parentId, filename, bytes, mimeType);
79874
+ }
79875
+ async function uploadMultipart(deps, parentId, filename, bytes, mimeType = "application/octet-stream") {
79594
79876
  const f = deps.fetchImpl ?? fetch;
79595
79877
  const boundary = "switchroom-deliver-boundary";
79596
79878
  const metadata = JSON.stringify({ name: filename, parents: [parentId] });
@@ -79622,6 +79904,48 @@ Content-Type: ${mimeType}\r
79622
79904
  }
79623
79905
  return await resp.json();
79624
79906
  }
79907
+ async function uploadResumable(deps, parentId, filename, bytes, mimeType = "application/octet-stream") {
79908
+ const f = deps.fetchImpl ?? fetch;
79909
+ const init = await f(`${UPLOAD}/files?uploadType=resumable&fields=id,name,webViewLink`, {
79910
+ method: "POST",
79911
+ headers: {
79912
+ Authorization: `Bearer ${deps.accessToken}`,
79913
+ "Content-Type": "application/json; charset=UTF-8",
79914
+ "X-Upload-Content-Type": mimeType
79915
+ },
79916
+ body: JSON.stringify({ name: filename, parents: [parentId] })
79917
+ });
79918
+ if (!init.ok) {
79919
+ throw new Error(`Drive resumable init failed: HTTP ${init.status} \u2014 ${await readBody2(init)}`);
79920
+ }
79921
+ const sessionUrl = init.headers.get("location") ?? init.headers.get("Location");
79922
+ if (!sessionUrl) {
79923
+ throw new Error("Drive resumable init returned no session URL (Location header)");
79924
+ }
79925
+ const CHUNK = 8 * 1024 * 1024;
79926
+ const total = bytes.byteLength;
79927
+ let lastFile = null;
79928
+ for (let start = 0;start < total; start += CHUNK) {
79929
+ const end = Math.min(start + CHUNK, total);
79930
+ const chunk2 = bytes.subarray(start, end);
79931
+ const put = await f(sessionUrl, {
79932
+ method: "PUT",
79933
+ headers: {
79934
+ "Content-Length": String(chunk2.byteLength),
79935
+ "Content-Range": `bytes ${start}-${end - 1}/${total}`
79936
+ },
79937
+ body: chunk2
79938
+ });
79939
+ if (put.status === 200 || put.status === 201) {
79940
+ lastFile = await put.json();
79941
+ } else if (put.status !== 308) {
79942
+ throw new Error(`Drive resumable chunk failed: HTTP ${put.status} \u2014 ${await readBody2(put)}`);
79943
+ }
79944
+ }
79945
+ if (!lastFile)
79946
+ throw new Error("Drive resumable upload completed without a final file resource");
79947
+ return lastFile;
79948
+ }
79625
79949
  async function createShareLink2(deps, file, scopes = ["anyone"]) {
79626
79950
  const f = deps.fetchImpl ?? fetch;
79627
79951
  for (const type of scopes) {