@vellumai/assistant 0.8.11 → 0.8.12-staging.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.
Files changed (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -9,11 +9,13 @@
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
11
  import {
12
+ chmodSync,
12
13
  mkdirSync,
13
14
  mkdtempSync,
14
15
  readdirSync,
15
16
  readFileSync,
16
17
  rmSync,
18
+ statSync,
17
19
  writeFileSync,
18
20
  } from "node:fs";
19
21
  import { tmpdir } from "node:os";
@@ -36,9 +38,27 @@ mock.module("../util/secure-keys.js", () => ({
36
38
  getSecureKeyAsync: async () => undefined,
37
39
  }));
38
40
 
41
+ import { getDb } from "../memory/db-connection.js";
39
42
  import { initializeDb } from "../memory/db-init.js";
43
+ import {
44
+ conversations,
45
+ llmRequestLogs,
46
+ toolInvocations,
47
+ } from "../memory/schema.js";
40
48
  import { RouteError } from "../runtime/routes/errors.js";
41
- import { ROUTES } from "../runtime/routes/log-export-routes.js";
49
+ import {
50
+ MAX_EXPORT_LLM_REQUEST_LOG_ROWS,
51
+ ROUTES,
52
+ } from "../runtime/routes/log-export-routes.js";
53
+ import {
54
+ MAX_SWEEP_FILE_BYTES,
55
+ OVERSIZED_FILE_NOTE,
56
+ redactStagedExportFiles,
57
+ } from "../runtime/routes/redact-staged-export.js";
58
+ import {
59
+ OPENAI_PROJECT_KEY_REDACTION_MARKER,
60
+ SYNTHETIC_OPENAI_PROJECT_KEY,
61
+ } from "./secret-fixtures.js";
42
62
 
43
63
  initializeDb();
44
64
 
@@ -98,10 +118,29 @@ async function extractArchive(res: Response): Promise<string> {
98
118
  // Seed test data
99
119
  // ---------------------------------------------------------------------------
100
120
 
101
- // config.json at workspace root — needed for config-snapshot test
121
+ // config.json at workspace root — needed for config-snapshot tests. The
122
+ // acp.agents env value is a synthetic secret that must be redacted from the
123
+ // exported snapshot. Unlike the shared `SYNTHETIC_OPENAI_PROJECT_KEY`, it is
124
+ // deliberately below the secret scanner's 40-char minimum, so the export-time
125
+ // sweep cannot see it — only the config snapshot's structural env redaction
126
+ // can keep it out of the archive, meaning a sanitizer regression cannot be
127
+ // masked by the sweep.
128
+ const SWEEP_INVISIBLE_SYNTHETIC_KEY = "sk-proj-synthetic-test-key-000000";
102
129
  writeFileSync(
103
130
  join(testWorkspaceDir, "config.json"),
104
- JSON.stringify({ provider: "anthropic" }),
131
+ JSON.stringify({
132
+ provider: "anthropic",
133
+ acp: {
134
+ agents: {
135
+ codex: {
136
+ env: {
137
+ OPENAI_API_KEY: SWEEP_INVISIBLE_SYNTHETIC_KEY,
138
+ PATH: "/data/.bun/bin",
139
+ },
140
+ },
141
+ },
142
+ },
143
+ }),
105
144
  );
106
145
 
107
146
  // Conversation directories — used for workspace allowlist tests
@@ -201,6 +240,26 @@ describe("POST /v1/export — tar.gz archive", () => {
201
240
  rmSync(dir, { recursive: true, force: true });
202
241
  }
203
242
  });
243
+
244
+ test("config-snapshot.json redacts acp.agents env values", async () => {
245
+ const res = await callExport();
246
+ const dir = await extractArchive(res);
247
+ try {
248
+ const configContent = readFileSync(
249
+ join(dir, "config-snapshot.json"),
250
+ "utf-8",
251
+ );
252
+ const parsed = JSON.parse(configContent);
253
+ const env = parsed.acp.agents.codex.env as Record<string, string>;
254
+ expect(Object.keys(env).sort()).toEqual(["OPENAI_API_KEY", "PATH"]);
255
+ for (const value of Object.values(env)) {
256
+ expect(value).toBe("(set)");
257
+ }
258
+ expect(configContent).not.toContain(SWEEP_INVISIBLE_SYNTHETIC_KEY);
259
+ } finally {
260
+ rmSync(dir, { recursive: true, force: true });
261
+ }
262
+ });
204
263
  });
205
264
 
206
265
  describe("POST /v1/export — daemon log date filtering", () => {
@@ -403,6 +462,13 @@ describe("POST /v1/export — workspace allowlist", () => {
403
462
  );
404
463
  const content = readFileSync(messagesPath, "utf-8");
405
464
  expect(content).toBe('{"role":"user","content":"jan 15"}\n');
465
+ // Clean daemon log files must also ship byte-identical — the secret
466
+ // sweep does no gratuitous rewrites.
467
+ for (const logFile of ["assistant-2025-01-10.log", "vellum.log"]) {
468
+ expect(readFileSync(join(dir, "daemon-logs", logFile), "utf-8")).toBe(
469
+ readFileSync(join(logsDir, logFile), "utf-8"),
470
+ );
471
+ }
406
472
  } finally {
407
473
  rmSync(dir, { recursive: true, force: true });
408
474
  }
@@ -442,3 +508,402 @@ describe("POST /v1/export — workspace allowlist", () => {
442
508
  }
443
509
  });
444
510
  });
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Export manifest row-cap truncation
514
+ // ---------------------------------------------------------------------------
515
+
516
+ describe("POST /v1/export — manifest truncatedSections", () => {
517
+ async function readManifest(
518
+ body: Record<string, unknown>,
519
+ ): Promise<Record<string, unknown>> {
520
+ const res = await callExport(body);
521
+ const dir = await extractArchive(res);
522
+ try {
523
+ return JSON.parse(
524
+ readFileSync(join(dir, "export-manifest.json"), "utf-8"),
525
+ ) as Record<string, unknown>;
526
+ } finally {
527
+ rmSync(dir, { recursive: true, force: true });
528
+ }
529
+ }
530
+
531
+ test("omits truncatedSections when no section hits its row cap", async () => {
532
+ const manifest = await readManifest({ full: true });
533
+ expect(manifest.type).toBe("full-export");
534
+ expect(manifest).not.toHaveProperty("truncatedSections");
535
+ });
536
+
537
+ test("surfaces truncatedSections when a full-export dump exceeds its row cap", async () => {
538
+ // Seed limit + 1 rows for the cheapest capped section (llm-request-logs)
539
+ // so capRows detects truncation without a COUNT query.
540
+ const db = getDb();
541
+ const now = Date.now();
542
+ db.insert(conversations)
543
+ .values({ id: "conv-cap-test", createdAt: now, updatedAt: now })
544
+ .run();
545
+ const rows = Array.from(
546
+ { length: MAX_EXPORT_LLM_REQUEST_LOG_ROWS + 1 },
547
+ (_, i) => ({
548
+ id: `llm-log-cap-${i}`,
549
+ conversationId: "conv-cap-test",
550
+ requestPayload: "{}",
551
+ responsePayload: "{}",
552
+ createdAt: now + i,
553
+ }),
554
+ );
555
+ db.insert(llmRequestLogs).values(rows).run();
556
+
557
+ const manifest = await readManifest({ full: true });
558
+ expect(manifest.truncatedSections).toEqual(["llm-request-logs"]);
559
+ });
560
+ });
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Export-time secret sweep
564
+ // ---------------------------------------------------------------------------
565
+
566
+ // NOTE: these tests intentionally run after every other describe block in
567
+ // this file — they seed a workspace conversation and an audit DB row that
568
+ // contain a raw secret, which would otherwise leak into the exports made by
569
+ // the earlier (clean-state) tests.
570
+ describe("POST /v1/export — staged-file secret sweep", () => {
571
+ test("clean staged files are left byte-identical and report filesRedacted: 0", () => {
572
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-clean-"));
573
+ try {
574
+ mkdirSync(join(staging, "daemon-logs"), { recursive: true });
575
+ const seeded: Record<string, string> = {
576
+ "audit-data.json": JSON.stringify(
577
+ [{ id: "ti-1", toolName: "bash", input: "{}" }],
578
+ null,
579
+ 2,
580
+ ),
581
+ "daemon-logs/assistant-2025-01-10.log": "log entry from Jan 10\n",
582
+ "notes.md": "# clean notes\n",
583
+ };
584
+ for (const [rel, content] of Object.entries(seeded)) {
585
+ writeFileSync(join(staging, rel), content, "utf-8");
586
+ }
587
+
588
+ const result = redactStagedExportFiles(staging);
589
+
590
+ expect(result).toEqual({
591
+ filesScanned: 3,
592
+ filesRedacted: 0,
593
+ filesOmitted: 0,
594
+ });
595
+ for (const [rel, content] of Object.entries(seeded)) {
596
+ expect(readFileSync(join(staging, rel), "utf-8")).toBe(content);
597
+ }
598
+ } finally {
599
+ rmSync(staging, { recursive: true, force: true });
600
+ }
601
+ });
602
+
603
+ test("redacts .jsonl files line-wise, keeping each valid line parseable", () => {
604
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-jsonl-"));
605
+ try {
606
+ const filePath = join(staging, "conversation-filtered.jsonl");
607
+ const validLine = JSON.stringify({
608
+ msg: `token ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
609
+ });
610
+ const malformedLine = `not json but carries ${SYNTHETIC_OPENAI_PROJECT_KEY}`;
611
+ const cleanLine = JSON.stringify({ msg: "clean" });
612
+ writeFileSync(
613
+ filePath,
614
+ `${validLine}\n${malformedLine}\n${cleanLine}\n`,
615
+ "utf-8",
616
+ );
617
+
618
+ const result = redactStagedExportFiles(staging);
619
+ expect(result).toEqual({
620
+ filesScanned: 1,
621
+ filesRedacted: 1,
622
+ filesOmitted: 0,
623
+ });
624
+
625
+ const lines = readFileSync(filePath, "utf-8").split("\n");
626
+ expect(lines[0]).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
627
+ // The valid line stays valid JSON, marker stored as a string value.
628
+ const parsed = JSON.parse(lines[0]) as { msg: string };
629
+ expect(parsed.msg).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
630
+ // The unparseable line falls back to plain-text redaction.
631
+ expect(lines[1]).toBe(
632
+ `not json but carries ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`,
633
+ );
634
+ // The clean line is untouched.
635
+ expect(lines[2]).toBe(cleanLine);
636
+ } finally {
637
+ rmSync(staging, { recursive: true, force: true });
638
+ }
639
+ });
640
+
641
+ test("sweeps arbitrary-extension staged files that sniff as text; leaves binary files untouched", () => {
642
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-sniff-"));
643
+ try {
644
+ // Conversation attachments are staged wholesale with arbitrary user
645
+ // extensions — sweep eligibility is decided by content sniffing, not
646
+ // file extension, so they must still be swept when they sniff as text.
647
+ const attachmentsDir = join(
648
+ staging,
649
+ "workspace",
650
+ "conversations",
651
+ "2025-01-10T00-00-00.000Z_conv-jan10",
652
+ "attachments",
653
+ );
654
+ mkdirSync(attachmentsDir, { recursive: true });
655
+ writeFileSync(
656
+ join(attachmentsDir, "creds.env"),
657
+ `OPENAI_API_KEY=${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
658
+ "utf-8",
659
+ );
660
+ writeFileSync(
661
+ join(attachmentsDir, "no-extension"),
662
+ `key: ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
663
+ "utf-8",
664
+ );
665
+ // Binary sniff: a NUL byte in the head exempts the file from the
666
+ // sweep even though scanner-matching bytes appear later in it.
667
+ const binary = Buffer.concat([
668
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]),
669
+ Buffer.from(SYNTHETIC_OPENAI_PROJECT_KEY, "utf-8"),
670
+ ]);
671
+ writeFileSync(join(attachmentsDir, "image.png"), binary);
672
+
673
+ const result = redactStagedExportFiles(staging);
674
+
675
+ expect(result).toEqual({
676
+ filesScanned: 2,
677
+ filesRedacted: 2,
678
+ filesOmitted: 0,
679
+ });
680
+ for (const file of ["creds.env", "no-extension"]) {
681
+ const content = readFileSync(join(attachmentsDir, file), "utf-8");
682
+ expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
683
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
684
+ }
685
+ expect(readFileSync(join(attachmentsDir, "image.png"))).toEqual(binary);
686
+ } finally {
687
+ rmSync(staging, { recursive: true, force: true });
688
+ }
689
+ });
690
+
691
+ test("replaces oversized sweep-eligible files with an omission note (fail closed)", () => {
692
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-oversize-"));
693
+ try {
694
+ // One byte over the cap. The content must never ship unswept — it is
695
+ // replaced wholesale with the omission note. An omitted file was never
696
+ // actually scanned or redacted, so it only counts as omitted.
697
+ writeFileSync(
698
+ join(staging, "messages.json"),
699
+ Buffer.alloc(MAX_SWEEP_FILE_BYTES + 1, 0x61),
700
+ );
701
+
702
+ const result = redactStagedExportFiles(staging);
703
+
704
+ expect(result).toEqual({
705
+ filesScanned: 0,
706
+ filesRedacted: 0,
707
+ filesOmitted: 1,
708
+ });
709
+ expect(readFileSync(join(staging, "messages.json"), "utf-8")).toBe(
710
+ OVERSIZED_FILE_NOTE,
711
+ );
712
+ } finally {
713
+ rmSync(staging, { recursive: true, force: true });
714
+ }
715
+ });
716
+
717
+ test("redacts BOM-prefixed .json files via the JSON-aware path, preserving the BOM", () => {
718
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-bom-"));
719
+ try {
720
+ // A UTF-8 BOM (common in user attachments) makes JSON.parse throw, so
721
+ // without BOM handling the file would take the plain-text fallback and
722
+ // the quoted marker would corrupt the JSON string it lands in.
723
+ const filePath = join(staging, "attachment.json");
724
+ writeFileSync(
725
+ filePath,
726
+ "\uFEFF" +
727
+ JSON.stringify({ key: SYNTHETIC_OPENAI_PROJECT_KEY }, null, 2),
728
+ "utf-8",
729
+ );
730
+
731
+ const result = redactStagedExportFiles(staging);
732
+ expect(result).toEqual({
733
+ filesScanned: 1,
734
+ filesRedacted: 1,
735
+ filesOmitted: 0,
736
+ });
737
+
738
+ const content = readFileSync(filePath, "utf-8");
739
+ expect(content.startsWith("\uFEFF")).toBe(true);
740
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
741
+ // Stripping the preserved BOM yields valid JSON with the marker stored
742
+ // as a proper string value.
743
+ const parsed = JSON.parse(content.slice(1)) as { key: string };
744
+ expect(parsed.key).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
745
+ } finally {
746
+ rmSync(staging, { recursive: true, force: true });
747
+ }
748
+ });
749
+
750
+ // Root bypasses file permission checks, so chmod 0o444 cannot make the
751
+ // file read-only — skip rather than assert a fail-closed path that cannot
752
+ // be exercised.
753
+ test.skipIf(process.getuid?.() === 0)(
754
+ "redacts a read-only staged file in place instead of shipping the raw secret",
755
+ () => {
756
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-readonly-"));
757
+ try {
758
+ // A read-only workspace attachment is staged with its mode bits
759
+ // preserved (cpSync). The sweep must still rewrite it — the chmod
760
+ // path — so the raw secret never reaches the tar step.
761
+ const readonlyPath = join(staging, "creds.env");
762
+ writeFileSync(
763
+ readonlyPath,
764
+ `OPENAI_API_KEY=${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
765
+ "utf-8",
766
+ );
767
+ chmodSync(readonlyPath, 0o444);
768
+ // A clean read-only file needs no rewrite, so it must keep its
769
+ // content AND its mode bits (no gratuitous chmod).
770
+ const cleanPath = join(staging, "clean.txt");
771
+ writeFileSync(cleanPath, "nothing secret here\n", "utf-8");
772
+ chmodSync(cleanPath, 0o444);
773
+
774
+ const result = redactStagedExportFiles(staging);
775
+
776
+ expect(result).toEqual({
777
+ filesScanned: 2,
778
+ filesRedacted: 1,
779
+ filesOmitted: 0,
780
+ });
781
+ const content = readFileSync(readonlyPath, "utf-8");
782
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
783
+ expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
784
+ expect(readFileSync(cleanPath, "utf-8")).toBe("nothing secret here\n");
785
+ expect(statSync(cleanPath).mode & 0o777).toBe(0o444);
786
+ } finally {
787
+ rmSync(staging, { recursive: true, force: true });
788
+ }
789
+ },
790
+ );
791
+
792
+ // Root bypasses file permission checks, so chmod 0o000 cannot make the
793
+ // file unreadable — skip rather than assert a degraded path that cannot
794
+ // be exercised.
795
+ test.skipIf(process.getuid?.() === 0)(
796
+ "sweep continues past an unreadable staged file (degraded, not failed)",
797
+ () => {
798
+ const staging = mkdtempSync(join(tmpdir(), "redact-staged-unreadable-"));
799
+ const unreadablePath = join(staging, "unreadable.log");
800
+ try {
801
+ writeFileSync(
802
+ join(staging, "readable.log"),
803
+ `key ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
804
+ "utf-8",
805
+ );
806
+ writeFileSync(unreadablePath, "any content\n", "utf-8");
807
+ chmodSync(unreadablePath, 0o000);
808
+
809
+ // Must return normally: the unreadable file is logged and skipped,
810
+ // and every other staged file is still swept.
811
+ const result = redactStagedExportFiles(staging);
812
+
813
+ expect(result).toEqual({
814
+ filesScanned: 1,
815
+ filesRedacted: 1,
816
+ filesOmitted: 0,
817
+ });
818
+ const content = readFileSync(join(staging, "readable.log"), "utf-8");
819
+ expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
820
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
821
+ } finally {
822
+ chmodSync(unreadablePath, 0o600);
823
+ rmSync(staging, { recursive: true, force: true });
824
+ }
825
+ },
826
+ );
827
+
828
+ test("redacts raw keys from workspace conversation files in the archive", async () => {
829
+ seedConversation(
830
+ "2025-01-30T00-00-00.000Z_conv-secret",
831
+ JSON.stringify({
832
+ role: "user",
833
+ content: `export OPENAI_API_KEY="${SYNTHETIC_OPENAI_PROJECT_KEY}"`,
834
+ }) + "\n",
835
+ );
836
+
837
+ const res = await callExport();
838
+ const dir = await extractArchive(res);
839
+ try {
840
+ const content = readFileSync(
841
+ join(
842
+ dir,
843
+ "workspace",
844
+ "conversations",
845
+ "2025-01-30T00-00-00.000Z_conv-secret",
846
+ "messages.jsonl",
847
+ ),
848
+ "utf-8",
849
+ );
850
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
851
+ // Line-wise JSON-aware redaction: every redacted line must still
852
+ // JSON.parse, with the marker stored as a proper JSON string value
853
+ // (its quotes are escaped on serialization).
854
+ const lines = content.split("\n").filter((line) => line.trim() !== "");
855
+ expect(lines.length).toBeGreaterThan(0);
856
+ const parsedLines = lines.map(
857
+ (line) => JSON.parse(line) as { content?: string },
858
+ );
859
+ expect(
860
+ parsedLines.some((record) =>
861
+ record.content?.includes(OPENAI_PROJECT_KEY_REDACTION_MARKER),
862
+ ),
863
+ ).toBe(true);
864
+ } finally {
865
+ rmSync(dir, { recursive: true, force: true });
866
+ }
867
+ });
868
+
869
+ test("redacts legacy audit rows and keeps audit-data.json valid JSON", async () => {
870
+ // Simulate a row written before write-time input redaction shipped: the
871
+ // raw key sits in the persisted `input` column, where no structural
872
+ // sanitizer can retroactively fix it.
873
+ const db = getDb();
874
+ const now = Date.now();
875
+ db.insert(conversations)
876
+ .values({ id: "conv-legacy-audit", createdAt: now, updatedAt: now })
877
+ .run();
878
+ db.insert(toolInvocations)
879
+ .values({
880
+ id: "ti-legacy-audit",
881
+ conversationId: "conv-legacy-audit",
882
+ toolName: "bash",
883
+ input: JSON.stringify({
884
+ command: `export OPENAI_API_KEY="${SYNTHETIC_OPENAI_PROJECT_KEY}"`,
885
+ }),
886
+ result: "{}",
887
+ decision: "allow",
888
+ riskLevel: "low",
889
+ durationMs: 5,
890
+ createdAt: now,
891
+ })
892
+ .run();
893
+
894
+ const res = await callExport();
895
+ const dir = await extractArchive(res);
896
+ try {
897
+ const content = readFileSync(join(dir, "audit-data.json"), "utf-8");
898
+ expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
899
+ // The sweep must keep the file parseable — redaction goes through a
900
+ // JSON-aware path rather than splicing quoted markers into raw JSON.
901
+ const rows = JSON.parse(content) as Array<{ id: string; input: string }>;
902
+ const row = rows.find((r) => r.id === "ti-legacy-audit");
903
+ expect(row).toBeDefined();
904
+ expect(row!.input).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
905
+ } finally {
906
+ rmSync(dir, { recursive: true, force: true });
907
+ }
908
+ });
909
+ });
@@ -74,6 +74,12 @@ function seedThreads(body: string): void {
74
74
  writeFileSync(path, body, "utf-8");
75
75
  }
76
76
 
77
+ function seedBuffer(body: string): void {
78
+ const path = getWorkspacePromptPath("memory/buffer.md");
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ writeFileSync(path, body, "utf-8");
81
+ }
82
+
77
83
  function clearV2StaticFiles(): void {
78
84
  for (const file of [
79
85
  "memory/essentials.md",
@@ -116,6 +122,22 @@ describe("memory-v2-static injector", () => {
116
122
  );
117
123
  });
118
124
 
125
+ test("suppresses the Buffer section on memoryV2Consolidation turns", async () => {
126
+ seedEssentials("Alice prefers VS Code.");
127
+ seedBuffer("- [Apr 27, 9:00 AM] backlog entry");
128
+
129
+ const consolidationBlock = await memoryV2StaticInjector.produce(
130
+ makeContext({ callSite: "memoryV2Consolidation" }),
131
+ );
132
+ expect(consolidationBlock).not.toBeNull();
133
+ expect(consolidationBlock!.text).toContain("## Essentials");
134
+ expect(consolidationBlock!.text).not.toContain("## Buffer");
135
+
136
+ // Other call sites (and untagged turns) keep the buffer section.
137
+ const mainBlock = await memoryV2StaticInjector.produce(makeContext());
138
+ expect(mainBlock!.text).toContain("## Buffer");
139
+ });
140
+
119
141
  test("escapes inner </info> closing tags so the wrapper cannot be broken out of", async () => {
120
142
  seedEssentials("Text with </info> embedded.");
121
143
  const ctx = makeContext();
@@ -20,7 +20,7 @@ describe("model intents", () => {
20
20
  "claude-haiku-4-5-20251001",
21
21
  );
22
22
  expect(resolveModelIntent("anthropic", "quality-optimized")).toBe(
23
- "claude-opus-4-8",
23
+ "claude-fable-5",
24
24
  );
25
25
  expect(resolveModelIntent("anthropic", "vision-optimized")).toBe(
26
26
  "claude-opus-4-6",
@@ -25,8 +25,10 @@ import { describe, expect, mock, test } from "bun:test";
25
25
 
26
26
  import { Command } from "commander";
27
27
 
28
- let mockPlatformClientCreate: () => Promise<Record<string, unknown> | null> =
29
- async () => null;
28
+ let mockPlatformClientCreate: () => Promise<Record<
29
+ string,
30
+ unknown
31
+ > | null> = async () => null;
30
32
 
31
33
  mock.module("../platform/client.js", () => ({
32
34
  VellumPlatformClient: {
@@ -49,18 +51,27 @@ mock.module("../config/loader.js", () => ({
49
51
  mergeDefaultWorkspaceConfig: () => {},
50
52
  getNestedValue: () => undefined,
51
53
  setNestedValue: () => {},
52
- _appendQuarantineBulletin: () => {},
54
+ _writeQuarantineNotice: () => {},
53
55
  API_KEY_PROVIDERS: ["anthropic", "openai", "gemini"],
54
56
  }));
55
57
 
56
58
  mock.module("../util/logger.js", () => ({
57
- getLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }),
58
- getCliLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }),
59
+ getLogger: () => ({
60
+ info: () => {},
61
+ warn: () => {},
62
+ error: () => {},
63
+ debug: () => {},
64
+ }),
65
+ getCliLogger: () => ({
66
+ info: () => {},
67
+ warn: () => {},
68
+ error: () => {},
69
+ debug: () => {},
70
+ }),
59
71
  }));
60
72
 
61
- const { requirePlatformConnection } = await import(
62
- "../cli/commands/oauth/shared.js"
63
- );
73
+ const { requirePlatformConnection } =
74
+ await import("../cli/commands/oauth/shared.js");
64
75
 
65
76
  // ---------------------------------------------------------------------------
66
77
  // requirePlatformConnection
@@ -1364,6 +1364,40 @@ describe("custom baseURL initialization", () => {
1364
1364
  timeout: DEFAULT_SDK_TIMEOUT_MS,
1365
1365
  });
1366
1366
  });
1367
+
1368
+ test("MinimaxProvider sends reasoning_split so reasoning never leaks into content as <think> tags", async () => {
1369
+ fakeChunks = [textChunk("hello", "stop"), usageChunk(10, 5)];
1370
+ const provider = new MinimaxProvider("mm-user-key", "MiniMax-M3");
1371
+
1372
+ await provider.sendMessage([userMsg("hi")]);
1373
+
1374
+ expect(lastCreateParams!.reasoning_split).toBe(true);
1375
+ });
1376
+
1377
+ test("MinimaxProvider replays prior assistant thinking via reasoning_content", async () => {
1378
+ fakeChunks = [textChunk("done", "stop"), usageChunk(10, 5)];
1379
+ const provider = new MinimaxProvider("mm-user-key", "MiniMax-M3");
1380
+
1381
+ await provider.sendMessage([
1382
+ userMsg("question"),
1383
+ {
1384
+ role: "assistant",
1385
+ content: [
1386
+ { type: "thinking", thinking: "prior reasoning", signature: "" },
1387
+ { type: "text", text: "prior answer" },
1388
+ ],
1389
+ },
1390
+ userMsg("follow-up"),
1391
+ ]);
1392
+
1393
+ const sent = lastCreateParams!.messages as Array<Record<string, unknown>>;
1394
+ const assistantTurn = sent.find((m) => m.role === "assistant");
1395
+ expect(assistantTurn).toEqual({
1396
+ role: "assistant",
1397
+ content: "prior answer",
1398
+ reasoning_content: "prior reasoning",
1399
+ });
1400
+ });
1367
1401
  });
1368
1402
 
1369
1403
  // ---------------------------------------------------------------------------
@@ -107,7 +107,6 @@ describe("pre-chat onboarding contract", () => {
107
107
  "USER.md",
108
108
  "BOOTSTRAP.md",
109
109
  "BOOTSTRAP-REFERENCE.md",
110
- "UPDATES.md",
111
110
  "users",
112
111
  "channels",
113
112
  ]) {