agent-nuvira 3.3.11 → 3.3.13

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 (220) hide show
  1. package/README.md +32 -1
  2. package/dist/agents/agents/reasoner.d.ts.map +1 -1
  3. package/dist/agents/agents/reasoner.js +13 -0
  4. package/dist/agents/agents/reasoner.js.map +1 -1
  5. package/dist/agents/orchestrator.d.ts.map +1 -1
  6. package/dist/agents/orchestrator.js +56 -20
  7. package/dist/agents/orchestrator.js.map +1 -1
  8. package/dist/cli/chat.d.ts +36 -0
  9. package/dist/cli/chat.d.ts.map +1 -1
  10. package/dist/cli/chat.js +456 -43
  11. package/dist/cli/chat.js.map +1 -1
  12. package/dist/cli/cli-program.d.ts.map +1 -1
  13. package/dist/cli/cli-program.js +8 -0
  14. package/dist/cli/cli-program.js.map +1 -1
  15. package/dist/cli/config.d.ts.map +1 -1
  16. package/dist/cli/config.js +35 -4
  17. package/dist/cli/config.js.map +1 -1
  18. package/dist/cli/execute.d.ts.map +1 -1
  19. package/dist/cli/execute.js +14 -0
  20. package/dist/cli/execute.js.map +1 -1
  21. package/dist/cli/knowledge.d.ts +23 -0
  22. package/dist/cli/knowledge.d.ts.map +1 -0
  23. package/dist/cli/knowledge.js +132 -0
  24. package/dist/cli/knowledge.js.map +1 -0
  25. package/dist/cli/loop-executor.d.ts.map +1 -1
  26. package/dist/cli/loop-executor.js +57 -13
  27. package/dist/cli/loop-executor.js.map +1 -1
  28. package/dist/cli/omniroute-control.d.ts +88 -0
  29. package/dist/cli/omniroute-control.d.ts.map +1 -0
  30. package/dist/cli/omniroute-control.js +179 -0
  31. package/dist/cli/omniroute-control.js.map +1 -0
  32. package/dist/cli/omniroute.d.ts +21 -0
  33. package/dist/cli/omniroute.d.ts.map +1 -0
  34. package/dist/cli/omniroute.js +67 -0
  35. package/dist/cli/omniroute.js.map +1 -0
  36. package/dist/cli/tool-install-prompt.d.ts +58 -0
  37. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  38. package/dist/cli/tool-install-prompt.js +137 -0
  39. package/dist/cli/tool-install-prompt.js.map +1 -1
  40. package/dist/cli/weak-model-prompt.d.ts +16 -7
  41. package/dist/cli/weak-model-prompt.d.ts.map +1 -1
  42. package/dist/cli/weak-model-prompt.js +24 -8
  43. package/dist/cli/weak-model-prompt.js.map +1 -1
  44. package/dist/config/types.d.ts +44 -0
  45. package/dist/config/types.d.ts.map +1 -1
  46. package/dist/context/cache.d.ts +35 -1
  47. package/dist/context/cache.d.ts.map +1 -1
  48. package/dist/context/cache.js +31 -2
  49. package/dist/context/cache.js.map +1 -1
  50. package/dist/context/history.d.ts +21 -0
  51. package/dist/context/history.d.ts.map +1 -1
  52. package/dist/context/history.js +50 -0
  53. package/dist/context/history.js.map +1 -1
  54. package/dist/gateway/hook-contract.d.ts +173 -0
  55. package/dist/gateway/hook-contract.d.ts.map +1 -0
  56. package/dist/gateway/hook-contract.js +352 -0
  57. package/dist/gateway/hook-contract.js.map +1 -0
  58. package/dist/gateway/hooks.d.ts.map +1 -1
  59. package/dist/gateway/hooks.js +14 -0
  60. package/dist/gateway/hooks.js.map +1 -1
  61. package/dist/inference/interface.d.ts +17 -0
  62. package/dist/inference/interface.d.ts.map +1 -1
  63. package/dist/inference/model-catalog.d.ts +17 -0
  64. package/dist/inference/model-catalog.d.ts.map +1 -1
  65. package/dist/inference/model-catalog.js +42 -7
  66. package/dist/inference/model-catalog.js.map +1 -1
  67. package/dist/inference/model-id-validation.d.ts +57 -0
  68. package/dist/inference/model-id-validation.d.ts.map +1 -0
  69. package/dist/inference/model-id-validation.js +127 -0
  70. package/dist/inference/model-id-validation.js.map +1 -0
  71. package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
  72. package/dist/inference/openai-compat-adapter.js +12 -1
  73. package/dist/inference/openai-compat-adapter.js.map +1 -1
  74. package/dist/inference/provider-catalog.d.ts +11 -0
  75. package/dist/inference/provider-catalog.d.ts.map +1 -1
  76. package/dist/inference/provider-catalog.js +80 -0
  77. package/dist/inference/provider-catalog.js.map +1 -1
  78. package/dist/inference/route-resolver.d.ts +2 -8
  79. package/dist/inference/route-resolver.d.ts.map +1 -1
  80. package/dist/inference/route-resolver.js +17 -0
  81. package/dist/inference/route-resolver.js.map +1 -1
  82. package/dist/inference/tool-call-utils.d.ts +4 -3
  83. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  84. package/dist/inference/tool-call-utils.js +26 -12
  85. package/dist/inference/tool-call-utils.js.map +1 -1
  86. package/dist/inference/tools.d.ts +1 -0
  87. package/dist/inference/tools.d.ts.map +1 -1
  88. package/dist/inference/tools.js +20 -3
  89. package/dist/inference/tools.js.map +1 -1
  90. package/dist/learning/agentic-route-gate.d.ts +104 -0
  91. package/dist/learning/agentic-route-gate.d.ts.map +1 -0
  92. package/dist/learning/agentic-route-gate.js +125 -0
  93. package/dist/learning/agentic-route-gate.js.map +1 -0
  94. package/dist/learning/auto-router.d.ts +89 -0
  95. package/dist/learning/auto-router.d.ts.map +1 -1
  96. package/dist/learning/auto-router.js +188 -8
  97. package/dist/learning/auto-router.js.map +1 -1
  98. package/dist/learning/build-prerequisites.d.ts +68 -0
  99. package/dist/learning/build-prerequisites.d.ts.map +1 -0
  100. package/dist/learning/build-prerequisites.js +267 -0
  101. package/dist/learning/build-prerequisites.js.map +1 -0
  102. package/dist/learning/continuation-intent.d.ts +43 -0
  103. package/dist/learning/continuation-intent.d.ts.map +1 -0
  104. package/dist/learning/continuation-intent.js +82 -0
  105. package/dist/learning/continuation-intent.js.map +1 -0
  106. package/dist/learning/error-repair.d.ts +23 -1
  107. package/dist/learning/error-repair.d.ts.map +1 -1
  108. package/dist/learning/error-repair.js +58 -0
  109. package/dist/learning/error-repair.js.map +1 -1
  110. package/dist/learning/knowledge-base.d.ts +124 -0
  111. package/dist/learning/knowledge-base.d.ts.map +1 -0
  112. package/dist/learning/knowledge-base.js +337 -0
  113. package/dist/learning/knowledge-base.js.map +1 -0
  114. package/dist/learning/model-harness.d.ts +19 -0
  115. package/dist/learning/model-harness.d.ts.map +1 -1
  116. package/dist/learning/model-harness.js +27 -0
  117. package/dist/learning/model-harness.js.map +1 -1
  118. package/dist/learning/model-selection.d.ts +2 -1
  119. package/dist/learning/model-selection.d.ts.map +1 -1
  120. package/dist/learning/model-selection.js +35 -2
  121. package/dist/learning/model-selection.js.map +1 -1
  122. package/dist/learning/project-docs.d.ts +83 -0
  123. package/dist/learning/project-docs.d.ts.map +1 -0
  124. package/dist/learning/project-docs.js +132 -0
  125. package/dist/learning/project-docs.js.map +1 -0
  126. package/dist/learning/prompt-budget.d.ts +91 -0
  127. package/dist/learning/prompt-budget.d.ts.map +1 -0
  128. package/dist/learning/prompt-budget.js +99 -0
  129. package/dist/learning/prompt-budget.js.map +1 -0
  130. package/dist/learning/quota-ledger.d.ts +13 -0
  131. package/dist/learning/quota-ledger.d.ts.map +1 -1
  132. package/dist/learning/quota-ledger.js +28 -0
  133. package/dist/learning/quota-ledger.js.map +1 -1
  134. package/dist/learning/reasoning-trace.d.ts +55 -2
  135. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  136. package/dist/learning/reasoning-trace.js +48 -0
  137. package/dist/learning/reasoning-trace.js.map +1 -1
  138. package/dist/learning/resilient-call.d.ts.map +1 -1
  139. package/dist/learning/resilient-call.js +16 -3
  140. package/dist/learning/resilient-call.js.map +1 -1
  141. package/dist/learning/routing-history.d.ts +13 -0
  142. package/dist/learning/routing-history.d.ts.map +1 -1
  143. package/dist/learning/routing-history.js.map +1 -1
  144. package/dist/learning/seeded-benchmark.d.ts +14 -0
  145. package/dist/learning/seeded-benchmark.d.ts.map +1 -1
  146. package/dist/learning/seeded-benchmark.js +43 -4
  147. package/dist/learning/seeded-benchmark.js.map +1 -1
  148. package/dist/learning/turn-report.d.ts +78 -0
  149. package/dist/learning/turn-report.d.ts.map +1 -0
  150. package/dist/learning/turn-report.js +140 -0
  151. package/dist/learning/turn-report.js.map +1 -0
  152. package/dist/security/secret-scan.d.ts +156 -0
  153. package/dist/security/secret-scan.d.ts.map +1 -0
  154. package/dist/security/secret-scan.js +525 -0
  155. package/dist/security/secret-scan.js.map +1 -0
  156. package/dist/tools/edit-verification.d.ts +2 -11
  157. package/dist/tools/edit-verification.d.ts.map +1 -1
  158. package/dist/tools/edit-verification.js +33 -1
  159. package/dist/tools/edit-verification.js.map +1 -1
  160. package/dist/tools/loop-skill-hint.d.ts +51 -0
  161. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  162. package/dist/tools/loop-skill-hint.js +87 -2
  163. package/dist/tools/loop-skill-hint.js.map +1 -1
  164. package/dist/tools/plan-store.d.ts +126 -3
  165. package/dist/tools/plan-store.d.ts.map +1 -1
  166. package/dist/tools/plan-store.js +290 -6
  167. package/dist/tools/plan-store.js.map +1 -1
  168. package/dist/tools/registry.d.ts +20 -1
  169. package/dist/tools/registry.d.ts.map +1 -1
  170. package/dist/tools/registry.js +165 -4
  171. package/dist/tools/registry.js.map +1 -1
  172. package/dist/tools/remediation-ladder.d.ts +91 -0
  173. package/dist/tools/remediation-ladder.d.ts.map +1 -0
  174. package/dist/tools/remediation-ladder.js +308 -0
  175. package/dist/tools/remediation-ladder.js.map +1 -0
  176. package/dist/tools/run-terminal.d.ts.map +1 -1
  177. package/dist/tools/run-terminal.js +60 -2
  178. package/dist/tools/run-terminal.js.map +1 -1
  179. package/dist/tools/skills-hub.d.ts +7 -0
  180. package/dist/tools/skills-hub.d.ts.map +1 -1
  181. package/dist/tools/skills-hub.js +11 -2
  182. package/dist/tools/skills-hub.js.map +1 -1
  183. package/dist/tools/step-artifact.d.ts +35 -0
  184. package/dist/tools/step-artifact.d.ts.map +1 -0
  185. package/dist/tools/step-artifact.js +154 -0
  186. package/dist/tools/step-artifact.js.map +1 -0
  187. package/dist/tools/tool-loop.d.ts +35 -0
  188. package/dist/tools/tool-loop.d.ts.map +1 -1
  189. package/dist/tools/tool-loop.js +284 -9
  190. package/dist/tools/tool-loop.js.map +1 -1
  191. package/dist/tools/toolsets.js +4 -4
  192. package/dist/tools/toolsets.js.map +1 -1
  193. package/dist/tools/worktree.d.ts +11 -2
  194. package/dist/tools/worktree.d.ts.map +1 -1
  195. package/dist/tools/worktree.js +11 -2
  196. package/dist/tools/worktree.js.map +1 -1
  197. package/dist/utils/effect-verification.js +1 -1
  198. package/dist/utils/effect-verification.js.map +1 -1
  199. package/dist/web-dashboard/chat-console.d.ts +21 -1
  200. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  201. package/dist/web-dashboard/chat-console.js +33 -5
  202. package/dist/web-dashboard/chat-console.js.map +1 -1
  203. package/dist/web-dashboard/server.d.ts +29 -0
  204. package/dist/web-dashboard/server.d.ts.map +1 -1
  205. package/dist/web-dashboard/server.js +410 -5
  206. package/dist/web-dashboard/server.js.map +1 -1
  207. package/dist/web-dashboard/src/types.d.ts +137 -0
  208. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  209. package/dist/web-dashboard/workspace-guard.d.ts +7 -0
  210. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  211. package/dist/web-dashboard/workspace-guard.js +70 -0
  212. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  213. package/package.json +4 -1
  214. package/src/web-dashboard/public/assets/index-DhiuR4S2.js +210 -0
  215. package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +1 -0
  216. package/src/web-dashboard/public/assets/index-tK8Y2qHB.css +1 -0
  217. package/src/web-dashboard/public/index.html +2 -2
  218. package/src/web-dashboard/public/assets/index-BdKf5Xw2.js +0 -207
  219. package/src/web-dashboard/public/assets/index-BdKf5Xw2.js.map +0 -1
  220. package/src/web-dashboard/public/assets/index-C9eBskc9.css +0 -1
@@ -25,11 +25,13 @@ import { loadEnv } from '../utils/env.js';
25
25
  import { ConfigManager } from '../config/manager.js';
26
26
  import { runAllChecks } from '../cli/doctor.js';
27
27
  import { getAutoRouter } from '../learning/auto-router.js';
28
+ import { withStrictModel } from '../inference/route-resolver.js';
28
29
  import { readRecallHits } from '../context/session-recall.js';
29
30
  import { getRouterPromotion } from '../learning/router-promotion.js';
30
31
  import { AUTH_CLEAR_THRESHOLD } from '../learning/key-hygiene.js';
31
32
  import { ACTION_LOG_FILENAME, DEFAULT_STALE_MS, aggregateActionTelemetry, readActionTelemetryFile, getModelRegistry, } from '../learning/model-registry.js';
32
33
  import { classifyModelEntitlement } from '../inference/model-entitlement.js';
34
+ import { capabilityBand, estimateModelCapability } from '../learning/model-capability.js';
33
35
  import { listUnattendedJobs } from '../learning/unattended-job.js';
34
36
  import { AdminSessions, countAdminRoleUsers, isAdminConfigured, listAdminUsers, mustChangePasswordFor, removeAdminUser, roleForUser, verifyAdmin, writeAdminUser, MIN_ADMIN_PASSWORD_LENGTH, } from './src/admin-auth.js';
35
37
  import { TaskRunner } from './task-runner.js';
@@ -2641,6 +2643,7 @@ function summarizeProvider(type, configManager) {
2641
2643
  : maskKey(key),
2642
2644
  model: c?.model,
2643
2645
  baseUrl: c?.baseUrl,
2646
+ enabled: c?.enabled !== false,
2644
2647
  };
2645
2648
  }
2646
2649
  /**
@@ -2867,6 +2870,26 @@ export function setGatewayShutdownForTest(action) {
2867
2870
  function runDashboardShutdown() {
2868
2871
  dashboardShutdownAction();
2869
2872
  }
2873
+ /**
2874
+ * The OmniRoute lifecycle actions behind /api/admin/omniroute. `null` (default)
2875
+ * → the REAL probe/spawn/stop. Swappable for the same reason as the gateway-stop
2876
+ * action: the real probe hits the network and the real start/stop spawns or
2877
+ * signals a machine-wide process, so a ROUTE test must not do either.
2878
+ */
2879
+ let omniRouteActions = null;
2880
+ /** Test hook: stub the OmniRoute lifecycle actions (null restores the real ones). */
2881
+ export function setOmniRouteActionsForTest(actions) {
2882
+ omniRouteActions = actions;
2883
+ }
2884
+ /** The real actions, lazily imported so dashboard boot does not pull the CLI in. */
2885
+ async function realOmniRouteActions() {
2886
+ const mod = await import('../cli/omniroute-control.js');
2887
+ return {
2888
+ status: () => mod.omnirouteStatus(),
2889
+ start: () => mod.startOmniRoute(),
2890
+ stop: () => mod.stopOmniRoute(),
2891
+ };
2892
+ }
2870
2893
  /**
2871
2894
  * Test hook: swap the pairing manager (e.g. for a fake-bridge manager) so
2872
2895
  * /api/whatsapp integration tests never open a real WhatsApp connection.
@@ -3039,6 +3062,102 @@ function handleRequest(req, res) {
3039
3062
  res.end(JSON.stringify(readHistoryData()));
3040
3063
  return;
3041
3064
  }
3065
+ // POST /api/history/clear — delete stored conversations. Body:
3066
+ // { target: 'session', id } — delete ONE session (the History tab
3067
+ // used to be read-only: only clear-all or an age band existed)
3068
+ // { target: 'older', olderThanMs } — time-based bulk sweep
3069
+ // { target: 'all' } — clear everything
3070
+ // Same gate as the continuity store: this is local memory, so an authenticated
3071
+ // admin/operator may change it. Reads (/api/history) stay open as before.
3072
+ if (pathname === '/api/history/clear' && req.method === 'POST') {
3073
+ const session = adminSessions.validate(bearerToken(req));
3074
+ if (!session) {
3075
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3076
+ return;
3077
+ }
3078
+ if (!roleCan(session.role, 'routing.operate')) {
3079
+ writeJson(res, 403, { ok: false, error: `Access denied — role '${session.role}' cannot change conversation history.` });
3080
+ return;
3081
+ }
3082
+ void (async () => {
3083
+ const body = await readJsonBody(req);
3084
+ const target = typeof body?.target === 'string' ? body.target : '';
3085
+ const id = typeof body?.id === 'string' ? body.id : undefined;
3086
+ const olderThanMs = typeof body?.olderThanMs === 'number' && body.olderThanMs > 0 ? body.olderThanMs : 0;
3087
+ const { getChatHistory } = await import('../context/history.js');
3088
+ const history = getChatHistory();
3089
+ let removed = 0;
3090
+ if (target === 'session' && id) {
3091
+ if (await history.deleteSession(id))
3092
+ removed = 1;
3093
+ }
3094
+ else if (target === 'older' && olderThanMs > 0) {
3095
+ removed = history.pruneOlderThan(olderThanMs);
3096
+ }
3097
+ else if (target === 'all') {
3098
+ removed = history.count();
3099
+ history.clear();
3100
+ }
3101
+ else {
3102
+ writeJson(res, 400, { ok: false, error: "Expected { target: 'session', id } | { target: 'older', olderThanMs } | { target: 'all' }." });
3103
+ return;
3104
+ }
3105
+ writeJson(res, 200, { ok: true, removed });
3106
+ })();
3107
+ return;
3108
+ }
3109
+ // ── Hook contract (declarative lifecycle rules) ──────────────────────────
3110
+ // GET /api/hooks — the declared hooks plus the contract vocabulary the page
3111
+ // needs to render an editor (events, action kinds and their descriptions).
3112
+ // Reads are open like /api/history.
3113
+ // PUT /api/hooks — replace the whole set. Gated on routing.operate (admin or
3114
+ // operator), the same capability every other config write uses. The server
3115
+ // VALIDATES before persisting, so a bad declaration is a 400 and the
3116
+ // previously-saved set stays in force.
3117
+ if (pathname === '/api/hooks' && req.method === 'GET') {
3118
+ void (async () => {
3119
+ const m = await import('../gateway/hook-contract.js');
3120
+ writeJson(res, 200, {
3121
+ ok: true,
3122
+ file: m.hookDeclarationsFile(),
3123
+ hooks: m.getHookDeclarations(),
3124
+ events: m.HOOK_EVENTS,
3125
+ eventDescriptions: m.HOOK_EVENT_DESCRIPTIONS,
3126
+ actionKinds: m.HOOK_ACTION_KINDS,
3127
+ actionDescriptions: m.HOOK_ACTION_DESCRIPTIONS,
3128
+ });
3129
+ })();
3130
+ return;
3131
+ }
3132
+ if (pathname === '/api/hooks' && req.method === 'PUT') {
3133
+ void (async () => {
3134
+ const session = adminSessions.validate(bearerToken(req));
3135
+ if (!session) {
3136
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3137
+ return;
3138
+ }
3139
+ if (!roleCan(session.role, 'routing.operate')) {
3140
+ writeJson(res, 403, {
3141
+ ok: false,
3142
+ error: `Access denied — role '${session.role}' cannot change hooks (requires admin or operator).`,
3143
+ });
3144
+ return;
3145
+ }
3146
+ const body = await readJsonBody(req);
3147
+ if (!body || !Array.isArray(body.hooks)) {
3148
+ writeJson(res, 400, { ok: false, error: 'Invalid JSON body — expected { hooks: [...] }.' });
3149
+ return;
3150
+ }
3151
+ const m = await import('../gateway/hook-contract.js');
3152
+ const saved = m.setHookDeclarations(body.hooks);
3153
+ if (!saved.ok) {
3154
+ writeJson(res, 400, { ok: false, error: saved.error });
3155
+ return;
3156
+ }
3157
+ writeJson(res, 200, { ok: true, hooks: saved.declarations });
3158
+ })();
3159
+ return;
3160
+ }
3042
3161
  if (pathname === '/api/benchmarks') {
3043
3162
  res.writeHead(200, { 'Content-Type': 'application/json' });
3044
3163
  res.end(JSON.stringify(readBenchmarkData()));
@@ -3211,6 +3330,10 @@ function handleRequest(req, res) {
3211
3330
  icon: entry?.icon,
3212
3331
  envVar: entry?.envVar || null,
3213
3332
  keyless: entry?.keyless === true,
3333
+ // What it is / how to set it up — the dashboard renders these beside
3334
+ // the key fields. Optional: most providers need no explanation.
3335
+ ...(entry?.description ? { description: entry.description } : {}),
3336
+ ...(entry?.setup ? { setup: entry.setup } : {}),
3214
3337
  };
3215
3338
  });
3216
3339
  writeJson(res, 200, { providers });
@@ -3324,6 +3447,67 @@ function handleRequest(req, res) {
3324
3447
  })();
3325
3448
  return;
3326
3449
  }
3450
+ // ── OmniRoute gateway lifecycle (the EXTERNAL AI gateway on port 20128) ──
3451
+ // OmniRoute is a separate long-lived process nuvira does not embed; the Admin
3452
+ // page could configure it as a provider but not start/stop it or say whether
3453
+ // it was up. GET reports reachability (a real HTTP probe — a 401 counts as UP),
3454
+ // POST { action: 'start' | 'stop' } drives it. Reads need a session; start/stop
3455
+ // need gateway.manage (the same gate as `nuvira omniroute start|stop` and
3456
+ // `nuvira gateway stop`).
3457
+ if (pathname === '/api/admin/omniroute' && (req.method === 'GET' || req.method === 'POST')) {
3458
+ void (async () => {
3459
+ const session = adminSessions.validate(bearerToken(req));
3460
+ if (!session) {
3461
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3462
+ return;
3463
+ }
3464
+ const actions = omniRouteActions ?? (await realOmniRouteActions());
3465
+ try {
3466
+ if (req.method === 'GET') {
3467
+ const status = await actions.status();
3468
+ writeJson(res, 200, { ok: true, status });
3469
+ return;
3470
+ }
3471
+ if (!roleCan(session.role, 'gateway.manage')) {
3472
+ writeJson(res, 403, {
3473
+ ok: false,
3474
+ error: `Access denied — role '${session.role}' cannot start or stop the OmniRoute gateway (requires admin or operator).`,
3475
+ });
3476
+ return;
3477
+ }
3478
+ const body = await readJsonBody(req);
3479
+ const action = body?.action;
3480
+ if (action === 'start') {
3481
+ const result = await actions.start();
3482
+ writeJson(res, 200, {
3483
+ ok: result.ok,
3484
+ action,
3485
+ started: result.started,
3486
+ detail: result.detail,
3487
+ status: result.status,
3488
+ });
3489
+ return;
3490
+ }
3491
+ if (action === 'stop') {
3492
+ const result = await actions.stop();
3493
+ writeJson(res, 200, {
3494
+ ok: result.stopped,
3495
+ action,
3496
+ stopped: result.stopped,
3497
+ reason: result.reason,
3498
+ pid: result.pid,
3499
+ status: await actions.status(),
3500
+ });
3501
+ return;
3502
+ }
3503
+ writeJson(res, 400, { ok: false, error: 'Invalid action — expected "start" or "stop".' });
3504
+ }
3505
+ catch (err) {
3506
+ writeJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
3507
+ }
3508
+ })();
3509
+ return;
3510
+ }
3327
3511
  // ── Agent Hub toggles (I5) — admin-gated writes honored by the I1 runtime
3328
3512
  // gate (tool-loop schema + execution gating), so the dashboard toggle is
3329
3513
  // NEVER cosmetic. Same config the CLI writes (`nuvira tools toolsets`);
@@ -3622,6 +3806,121 @@ function handleRequest(req, res) {
3622
3806
  })();
3623
3807
  return;
3624
3808
  }
3809
+ // ── Knowledge base (tag-scoped retrieval over the operator's own documents) ──
3810
+ // Thin endpoints over learning/knowledge-base.ts: list tags, ingest a file or
3811
+ // folder under a tag, query a tag, and forget a tag. Documents are extracted,
3812
+ // chunked and embedded ONCE on ingest, so a query only embeds the question.
3813
+ // Reads list a tag's metadata; writes (ingest/forget) need routing.operate.
3814
+ if (pathname === '/api/knowledge' && req.method === 'GET') {
3815
+ void (async () => {
3816
+ const session = adminSessions.validate(bearerToken(req));
3817
+ if (!session) {
3818
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3819
+ return;
3820
+ }
3821
+ try {
3822
+ const { listKnowledgeTags } = await import('../learning/knowledge-base.js');
3823
+ writeJson(res, 200, { ok: true, tags: listKnowledgeTags() });
3824
+ }
3825
+ catch (err) {
3826
+ writeJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
3827
+ }
3828
+ })();
3829
+ return;
3830
+ }
3831
+ if (pathname === '/api/knowledge/ingest' && req.method === 'POST') {
3832
+ void (async () => {
3833
+ const session = adminSessions.validate(bearerToken(req));
3834
+ if (!session) {
3835
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3836
+ return;
3837
+ }
3838
+ if (!roleCan(session.role, 'routing.operate')) {
3839
+ writeJson(res, 403, {
3840
+ ok: false,
3841
+ error: `Access denied — role '${session.role}' cannot ingest documents (requires admin or operator).`,
3842
+ });
3843
+ return;
3844
+ }
3845
+ const body = await readJsonBody(req);
3846
+ const tag = typeof body?.tag === 'string' ? body.tag.trim() : '';
3847
+ const paths = Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === 'string' && p.trim().length > 0) : [];
3848
+ if (!tag) {
3849
+ writeJson(res, 400, { ok: false, error: 'A tag is required.' });
3850
+ return;
3851
+ }
3852
+ if (paths.length === 0) {
3853
+ writeJson(res, 400, { ok: false, error: 'At least one file or folder path is required.' });
3854
+ return;
3855
+ }
3856
+ try {
3857
+ const { ingestKnowledge } = await import('../learning/knowledge-base.js');
3858
+ const result = await ingestKnowledge(tag, paths);
3859
+ writeJson(res, 200, { ok: true, ...result });
3860
+ }
3861
+ catch (err) {
3862
+ writeJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
3863
+ }
3864
+ })();
3865
+ return;
3866
+ }
3867
+ if (pathname === '/api/knowledge/query' && req.method === 'POST') {
3868
+ void (async () => {
3869
+ const session = adminSessions.validate(bearerToken(req));
3870
+ if (!session) {
3871
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3872
+ return;
3873
+ }
3874
+ const body = await readJsonBody(req);
3875
+ const tag = typeof body?.tag === 'string' ? body.tag.trim() : '';
3876
+ const question = typeof body?.question === 'string' ? body.question.trim() : '';
3877
+ const topK = typeof body?.topK === 'number' ? body.topK : undefined;
3878
+ if (!tag || !question) {
3879
+ writeJson(res, 400, { ok: false, error: 'A tag and a question are required.' });
3880
+ return;
3881
+ }
3882
+ try {
3883
+ const { queryKnowledge } = await import('../learning/knowledge-base.js');
3884
+ const hits = await queryKnowledge(tag, question, { topK });
3885
+ writeJson(res, 200, { ok: true, tag, hits });
3886
+ }
3887
+ catch (err) {
3888
+ writeJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
3889
+ }
3890
+ })();
3891
+ return;
3892
+ }
3893
+ if (pathname === '/api/knowledge/forget' && req.method === 'POST') {
3894
+ void (async () => {
3895
+ const session = adminSessions.validate(bearerToken(req));
3896
+ if (!session) {
3897
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3898
+ return;
3899
+ }
3900
+ if (!roleCan(session.role, 'routing.operate')) {
3901
+ writeJson(res, 403, {
3902
+ ok: false,
3903
+ error: `Access denied — role '${session.role}' cannot remove knowledge tags (requires admin or operator).`,
3904
+ });
3905
+ return;
3906
+ }
3907
+ const body = await readJsonBody(req);
3908
+ const tag = typeof body?.tag === 'string' ? body.tag.trim() : '';
3909
+ if (!tag) {
3910
+ writeJson(res, 400, { ok: false, error: 'A tag is required.' });
3911
+ return;
3912
+ }
3913
+ try {
3914
+ const { forgetKnowledgeTag } = await import('../learning/knowledge-base.js');
3915
+ const removed = await forgetKnowledgeTag(tag);
3916
+ writeJson(res, 200, { ok: true, removed });
3917
+ }
3918
+ catch (err) {
3919
+ writeJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
3920
+ }
3921
+ })();
3922
+ return;
3923
+ }
3625
3924
  // ── PA4 — save skill env vars to ~/.nuvira/.env (dashboard secret capture) ──
3626
3925
  if (pathname === '/api/skills/secrets' && req.method === 'POST') {
3627
3926
  void (async () => {
@@ -4568,6 +4867,26 @@ function handleRequest(req, res) {
4568
4867
  }
4569
4868
  if (typeof body.runner === 'string' && body.runner.trim().length > 0)
4570
4869
  updates.runner = body.runner.trim();
4870
+ // The On/Off switch. `false` removes the provider from the auto-routing
4871
+ // pool WITHOUT deleting its credentials; `true` re-admits it.
4872
+ if (typeof body.enabled === 'boolean')
4873
+ updates.enabled = body.enabled;
4874
+ // A3 — a model id is validated against the provider's LIVE list before
4875
+ // it is written, so a typo cannot become a silent substitution at run
4876
+ // time. An unreachable provider is saved unverified (never blocked); a
4877
+ // reachable one that does not serve the id is refused with suggestions.
4878
+ if (typeof updates.model === 'string' && updates.model) {
4879
+ const { validateModelIdForProvider } = await import('../inference/model-id-validation.js');
4880
+ const verdict = await validateModelIdForProvider(type, updates.model, configManager);
4881
+ if (!verdict.ok) {
4882
+ writeJson(res, 400, {
4883
+ ok: false,
4884
+ error: verdict.message,
4885
+ suggestions: verdict.suggestions ?? [],
4886
+ });
4887
+ return;
4888
+ }
4889
+ }
4571
4890
  // Raw config (vault refs INTACT) — never save a resolved key back to
4572
4891
  // plaintext (the documented resolveVaultRefs footgun).
4573
4892
  const raw = configManager.getAll().providers[type] || {};
@@ -4583,7 +4902,7 @@ function handleRequest(req, res) {
4583
4902
  // explicit undefineds, so the fields vanish from the file.
4584
4903
  if (req.method === 'DELETE') {
4585
4904
  const cleared = configManager.clearProviderApiKey(type);
4586
- configManager.save({ providers: { [type]: { apiKey: undefined, baseUrl: undefined, model: undefined, runner: undefined } } });
4905
+ configManager.save({ providers: { [type]: { apiKey: undefined, baseUrl: undefined, model: undefined, runner: undefined, enabled: undefined } } });
4587
4906
  clearModelListCache();
4588
4907
  // An ENV-sourced key cannot be removed from the file (env re-injects on
4589
4908
  // every load) — surface that so the UI warns instead of hiding the row.
@@ -6276,6 +6595,11 @@ function handleRequest(req, res) {
6276
6595
  // POST /api/continuity/clear — forget stored continuity data. Body:
6277
6596
  // { target: 'sessions' | 'recall' } — clear all of one kind
6278
6597
  // { target: 'session' | 'recall-entry', id } — clear one entry
6598
+ // { target: 'sessions' | 'recall', olderThanMs } — TIME-BASED bulk: clear
6599
+ // only entries older than now − olderThanMs. Per-entry forgetting is fine
6600
+ // for a handful of records but tedious for a store that grows on every
6601
+ // run, so "forget anything older than a week" is one request instead of
6602
+ // N. Absent/<=0 → the existing clear-all behaviour, unchanged.
6279
6603
  if (pathname === '/api/continuity/clear' && req.method === 'POST') {
6280
6604
  const session = adminSessions.validate(bearerToken(req));
6281
6605
  if (!session) {
@@ -6290,8 +6614,23 @@ function handleRequest(req, res) {
6290
6614
  const body = await readJsonBody(req);
6291
6615
  const target = typeof body?.target === 'string' ? body.target : '';
6292
6616
  const id = typeof body?.id === 'string' ? body.id : undefined;
6617
+ const olderThanMs = typeof body?.olderThanMs === 'number' && body.olderThanMs > 0 ? body.olderThanMs : 0;
6618
+ const cutoff = olderThanMs > 0 ? Date.now() - olderThanMs : 0;
6293
6619
  let removed = 0;
6294
- if (target === 'sessions') {
6620
+ if (target === 'sessions' && cutoff > 0) {
6621
+ // Time-based bulk: only snapshots saved before the cutoff.
6622
+ const { listSessionSnapshots, clearSession } = await import('../learning/session-store.js');
6623
+ for (const s of listSessionSnapshots())
6624
+ if (s.savedAt < cutoff && clearSession(s.id))
6625
+ removed += 1;
6626
+ }
6627
+ else if (target === 'recall' && cutoff > 0) {
6628
+ const { listRecallEntries, removeRecallEntry } = await import('../learning/session-recall.js');
6629
+ for (const e of listRecallEntries())
6630
+ if (e.savedAt < cutoff && removeRecallEntry(e.id))
6631
+ removed += 1;
6632
+ }
6633
+ else if (target === 'sessions') {
6295
6634
  const { listSessionSnapshots, clearSession } = await import('../learning/session-store.js');
6296
6635
  for (const s of listSessionSnapshots())
6297
6636
  if (clearSession(s.id))
@@ -6313,7 +6652,7 @@ function handleRequest(req, res) {
6313
6652
  removed = 1;
6314
6653
  }
6315
6654
  else {
6316
- writeJson(res, 400, { ok: false, error: "Expected { target: 'sessions'|'recall'|'session'|'recall-entry', id? }." });
6655
+ writeJson(res, 400, { ok: false, error: "Expected { target: 'sessions'|'recall'|'session'|'recall-entry', id?, olderThanMs? }." });
6317
6656
  return;
6318
6657
  }
6319
6658
  writeJson(res, 200, { ok: true, removed });
@@ -6346,6 +6685,39 @@ function handleRequest(req, res) {
6346
6685
  })();
6347
6686
  return;
6348
6687
  }
6688
+ // GET /api/chat/routable-models — the short list a chat model PICKER may
6689
+ // offer: only pairs the router would actually use right now (the registry's
6690
+ // own `isUsable` predicate), each with a capability estimate and band.
6691
+ //
6692
+ // WHY THIS EXISTS (2026-10-04). The user's question was fair: the chat had no
6693
+ // way to choose a model, so every turn was Auto — and when Auto's floor had to
6694
+ // relax it silently landed on a 4-bit LOCAL model. Offering a handful of
6695
+ // ROUTABLE pairs (provider + model + capability) lets the user rule on Auto
6696
+ // without ever being shown the 500-id catalog. Cheap: a registry read, no
6697
+ // provider probe.
6698
+ if (pathname === '/api/chat/routable-models' && req.method === 'GET') {
6699
+ void (async () => {
6700
+ const session = adminSessions.validate(bearerToken(req));
6701
+ if (!session) {
6702
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
6703
+ return;
6704
+ }
6705
+ const registry = getModelRegistry();
6706
+ const now = Date.now();
6707
+ const models = [];
6708
+ for (const provider of registry.getTrackedProviders()) {
6709
+ for (const entry of registry.getAllModelsForProvider(provider)) {
6710
+ if (!registry.isUsable(provider, entry.model, now))
6711
+ continue;
6712
+ const capability = estimateModelCapability(entry.model);
6713
+ models.push({ provider, model: entry.model, capability, band: capabilityBand(capability) });
6714
+ }
6715
+ }
6716
+ models.sort((a, b) => b.capability - a.capability);
6717
+ writeJson(res, 200, { ok: true, models: models.slice(0, 80) });
6718
+ })();
6719
+ return;
6720
+ }
6349
6721
  // POST /api/chat — send one message { sessionId?, message, provider?, model? }.
6350
6722
  if (pathname === '/api/chat' && req.method === 'POST') {
6351
6723
  void (async () => {
@@ -6374,6 +6746,11 @@ function handleRequest(req, res) {
6374
6746
  : newChatSessionId();
6375
6747
  const provider = typeof body?.provider === 'string' ? body.provider : undefined;
6376
6748
  const model = typeof body?.model === 'string' ? body.model : undefined;
6749
+ // Strict-model pin for THIS turn (the dashboard chat's "pin this model
6750
+ // only" switch). Scoped to the turn with AsyncLocalStorage rather than a
6751
+ // process env write, because two sessions can run concurrently and a
6752
+ // global mutation would leak one user's pin into the other's turn.
6753
+ const strict = body?.strict === true;
6377
6754
  // WS5 (#27) — per-turn isolation and resume, from the GUI.
6378
6755
  //
6379
6756
  // Read as a strict TRI-state, because the three cases are genuinely
@@ -6482,7 +6859,7 @@ function handleRequest(req, res) {
6482
6859
  // Mark the failover log BEFORE the turn so a failure can report which
6483
6860
  // models were actually tried (same contract as the gateway).
6484
6861
  const attemptMark = markFailoverAttempts();
6485
- const result = await chatConsole.answer(sessionId, message, {
6862
+ const result = await withStrictModel(strict, () => chatConsole.answer(sessionId, message, {
6486
6863
  provider,
6487
6864
  model,
6488
6865
  projectContext,
@@ -6491,7 +6868,7 @@ function handleRequest(req, res) {
6491
6868
  ...(worktree === undefined ? {} : { worktree }),
6492
6869
  ...(keepWorktree === undefined ? {} : { keepWorktree }),
6493
6870
  ...(resume === undefined ? {} : { resume }),
6494
- });
6871
+ }));
6495
6872
  if (!result.ok) {
6496
6873
  writeJson(res, 400, {
6497
6874
  ok: false,
@@ -6504,6 +6881,29 @@ function handleRequest(req, res) {
6504
6881
  // enforced (see ChatRetryBroker). Without this the console was a dead end
6505
6882
  // — the reader had to wait and re-send the message themselves.
6506
6883
  let content = result.content ?? '';
6884
+ // When a model was pinned WITHOUT strict mode, say plainly what happened
6885
+ // to the pin: a turn that ran on another provider was auto-routed, and a
6886
+ // pinned model that failed may be handed off on the next attempt. Either
6887
+ // way the reader is told how to force the pinned model — enable strict
6888
+ // mode — instead of discovering the switch later.
6889
+ let routingNotice;
6890
+ if (provider) {
6891
+ const requested = `${provider}${model ? `/${model}` : ''}`;
6892
+ const served = result.provider ? `${result.provider}${result.model ? `/${result.model}` : ''}` : '';
6893
+ const substituted = Boolean(served) && result.provider !== provider;
6894
+ if (strict) {
6895
+ if (result.generationFailed === true) {
6896
+ routingNotice =
6897
+ 'Strict model mode is on, so the turn stopped rather than falling back to another model.';
6898
+ }
6899
+ }
6900
+ else if (substituted) {
6901
+ routingNotice = `Auto routing took over: this turn ran on ${served} because your pinned ${requested} was not available. To work with ${requested} only, enable strict model mode.`;
6902
+ }
6903
+ else if (result.generationFailed === true) {
6904
+ routingNotice = `Your pinned ${requested} could not complete this turn, so auto routing may hand the work to another model. To stay on this model only, enable strict model mode.`;
6905
+ }
6906
+ }
6507
6907
  // Did the offer we are about to send get a mechanism behind it? The client
6508
6908
  // uses this to stop offering "↻ Retry" for a turn the server is already
6509
6909
  // retrying on its own — a manual re-send on top of the queued one would
@@ -6535,6 +6935,7 @@ function handleRequest(req, res) {
6535
6935
  followups: result.followups ?? [],
6536
6936
  provider: result.provider ?? null,
6537
6937
  model: result.model ?? null,
6938
+ ...(routingNotice ? { routingNotice } : {}),
6538
6939
  // WS1 — the findings this turn recorded, with the gate's verdicts, so
6539
6940
  // a dashboard client reads the same wire form the CLI, the gateway and
6540
6941
  // the subagent report (the GUI card is a rendering decision; the FACT
@@ -6552,6 +6953,10 @@ function handleRequest(req, res) {
6552
6953
  // that sets `NUVIRA_RESUME` gets resumed turns, and a reader who is never
6553
6954
  // told cannot tell a replayed turn from a fresh one.
6554
6955
  ...(result.resume ? { resume: result.resume } : {}),
6956
+ // E — the derived plan → track → verify → report artifact for this turn,
6957
+ // so the GUI can render the trust verdict instead of inferring it from the
6958
+ // transcript. Absent when the turn produced no non-trivial report.
6959
+ ...(result.turnReport ? { turnReport: result.turnReport } : {}),
6555
6960
  // WS5 — whether the turn declined to run, so the client labels the failure
6556
6961
  // as the refusal it is instead of offering a Retry that cannot help.
6557
6962
  refused: result.refused === true,