@vellumai/assistant 0.11.3-dev.202608110527.eba8e13 → 0.11.3-dev.202608111429.ed4a99b

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  3. package/src/__tests__/edit-propagation.test.ts +0 -7
  4. package/src/__tests__/mcp-list-plugin-servers.test.ts +2 -2
  5. package/src/__tests__/run-conversation-turn-persistence.test.ts +222 -0
  6. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  7. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  8. package/src/cli/commands/memory/index.help.ts +43 -1
  9. package/src/cli/commands/memory/memory-v3.ts +64 -0
  10. package/src/cli/lib/bundled-marketplace.json +13 -0
  11. package/src/config/schemas/mcp.ts +23 -0
  12. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  13. package/src/daemon/mcp-reload-service.ts +36 -6
  14. package/src/daemon/providers-setup.ts +6 -3
  15. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  16. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  17. package/src/mcp/client.ts +12 -4
  18. package/src/mcp/effective-config.ts +113 -0
  19. package/src/mcp/manager.ts +11 -6
  20. package/src/persistence/delivery-crud.ts +31 -29
  21. package/src/persistence/external-conversation-store.ts +32 -4
  22. package/src/plugin-api/conversation-turn.ts +168 -5
  23. package/src/plugin-api/index.ts +1 -0
  24. package/src/plugins/__tests__/mcp-servers.test.ts +5 -4
  25. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  26. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  27. package/src/plugins/mcp-servers.ts +26 -8
  28. package/src/plugins/mtime-cache.ts +12 -0
  29. package/src/runtime/routes/inbound-message-handler.ts +12 -17
  30. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  31. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +0 -1
  32. package/src/runtime/routes/mcp-auth-routes.ts +16 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608110527.eba8e13",
3
+ "version": "0.11.3-dev.202608111429.ed4a99b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -504,23 +504,23 @@ describe("channel-delivery-store", () => {
504
504
  expect(r1.conversationId).not.toBe(r2.conversationId);
505
505
  });
506
506
 
507
- test("same chat/channel but different assistantId uses different conversations", () => {
508
- const r1 = recordInbound("telegram", "chat-1", "msg-1", {
509
- assistantId: "asst-A",
510
- });
511
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
512
- assistantId: "asst-B",
513
- });
507
+ test("the same chat keeps the same conversation", () => {
508
+ const r1 = recordInbound("telegram", "chat-1", "msg-1");
509
+ const r2 = recordInbound("telegram", "chat-1", "msg-2");
514
510
 
515
- expect(r1.conversationId).not.toBe(r2.conversationId);
511
+ expect(r1.conversationId).toBe(r2.conversationId);
516
512
  });
517
513
 
518
- test("no assistantId defaults to self-scoped key", () => {
519
- const r1 = recordInbound("telegram", "chat-1", "msg-1");
520
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
521
- assistantId: "self",
522
- });
523
- expect(r1.conversationId).toBe(r2.conversationId);
514
+ test("binds under the self-scoped key", () => {
515
+ // The prefix is a stored format, not an internal detail. It reads like a
516
+ // leftover of the assistant id that used to be interpolated into it, so
517
+ // pin it: rows already written under `asst:self:` stop resolving if a
518
+ // later tidy-up drops it.
519
+ const { conversationId } = recordInbound("telegram", "chat-1", "msg-1");
520
+
521
+ expect(
522
+ getConversationByKey("asst:self:telegram:chat-1")?.conversationId,
523
+ ).toBe(conversationId);
524
524
  });
525
525
 
526
526
  test("external bindings allow multiple Slack thread anchors per channel", () => {
@@ -145,7 +145,6 @@ describe("Slack edit propagation", () => {
145
145
  conversationExternalId: seeded.conversationExternalId,
146
146
  externalMessageId: nextEditEventId(),
147
147
  sourceMessageId: seeded.channelTs,
148
- canonicalAssistantId: "self",
149
148
  assistantId: "self",
150
149
  content: "new text",
151
150
  });
@@ -187,7 +186,6 @@ describe("Slack edit propagation", () => {
187
186
  externalMessageId: nextEditEventId(),
188
187
  sourceMessageId: seeded.channelTs,
189
188
  sourceThreadId: threadTs,
190
- canonicalAssistantId: "self",
191
189
  assistantId: "self",
192
190
  content: "new text",
193
191
  });
@@ -216,7 +214,6 @@ describe("Slack edit propagation", () => {
216
214
  conversationExternalId: seeded.conversationExternalId,
217
215
  externalMessageId: nextEditEventId(),
218
216
  sourceMessageId: seeded.channelTs,
219
- canonicalAssistantId: "self",
220
217
  assistantId: "self",
221
218
  content: "first edit",
222
219
  });
@@ -239,7 +236,6 @@ describe("Slack edit propagation", () => {
239
236
  conversationExternalId: seeded.conversationExternalId,
240
237
  externalMessageId: nextEditEventId(),
241
238
  sourceMessageId: seeded.channelTs,
242
- canonicalAssistantId: "self",
243
239
  assistantId: "self",
244
240
  content: "second edit",
245
241
  });
@@ -272,7 +268,6 @@ describe("Slack edit propagation", () => {
272
268
  conversationExternalId: seeded.conversationExternalId,
273
269
  externalMessageId: nextEditEventId(),
274
270
  sourceMessageId: seeded.channelTs,
275
- canonicalAssistantId: "self",
276
271
  assistantId: "self",
277
272
  // Same text as stored -- simulates a Slack unfurl `message_changed`
278
273
  // where only attachments changed.
@@ -313,7 +308,6 @@ describe("Slack edit propagation", () => {
313
308
  conversationExternalId: seeded.conversationExternalId,
314
309
  externalMessageId: nextEditEventId(),
315
310
  sourceMessageId: seeded.channelTs,
316
- canonicalAssistantId: "self",
317
311
  assistantId: "self",
318
312
  content: "edited searchable text",
319
313
  });
@@ -341,7 +335,6 @@ describe("Slack edit propagation", () => {
341
335
  // sourceMessageId points at a ts that was never stored.
342
336
  externalMessageId: nextEditEventId(),
343
337
  sourceMessageId: "9999.0000",
344
- canonicalAssistantId: "self",
345
338
  assistantId: "self",
346
339
  content: "new text",
347
340
  });
@@ -204,12 +204,12 @@ describe("internal_mcp_list, plugin-declared servers", () => {
204
204
  expect(workspace.hasStaticAuth).toBe(true);
205
205
  });
206
206
 
207
- test("plugin servers default to high risk", async () => {
207
+ test("plugin servers default to low risk", async () => {
208
208
  writePlugin("unabyss", unabyssManifest());
209
209
 
210
210
  const servers = await listServers();
211
211
  expect(servers.find((s) => s.id === "unabyss")!.defaultRiskLevel).toEqual(
212
- "high",
212
+ "low",
213
213
  );
214
214
  });
215
215
 
@@ -5,12 +5,15 @@ import {
5
5
  ensureConversationExists,
6
6
  getConversation,
7
7
  } from "../persistence/conversation-crud.js";
8
+ import { getConversationByKey } from "../persistence/conversation-key-store.js";
8
9
  import {
9
10
  isEchoSuppressedUserMessage,
10
11
  isReplyPushIneligibleUserMessage,
11
12
  } from "../persistence/conversation-types.js";
12
13
  import { getDb } from "../persistence/db-connection.js";
13
14
  import { initializeDb } from "../persistence/db-init.js";
15
+ import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
16
+ import { getBindingByConversation } from "../persistence/external-conversation-store.js";
14
17
 
15
18
  await initializeDb();
16
19
 
@@ -37,6 +40,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
37
40
  let lastProcessMessageConversationId: string | undefined;
38
41
  let lastProcessMessageOptions: Record<string, unknown> | undefined;
39
42
  let lastEnqueueOptions: Record<string, unknown> | undefined;
43
+ let lastTurnChannelContext: unknown;
40
44
  let conversationIsProcessing = false;
41
45
  mock.module("../daemon/conversation-store.js", () => ({
42
46
  getOrCreateConversation: async (
@@ -57,6 +61,9 @@ mock.module("../daemon/conversation-store.js", () => ({
57
61
  }
58
62
  return {
59
63
  abortController: undefined,
64
+ setTurnChannelContext: (ctx: unknown) => {
65
+ lastTurnChannelContext = ctx;
66
+ },
60
67
  isProcessing: () => conversationIsProcessing,
61
68
  async processMessage(processOptions: Record<string, unknown>) {
62
69
  // The row must already exist by the time the turn persists its user
@@ -95,6 +102,7 @@ describe("runConversationTurn persistence", () => {
95
102
  lastProcessMessageConversationId = undefined;
96
103
  lastProcessMessageOptions = undefined;
97
104
  lastEnqueueOptions = undefined;
105
+ lastTurnChannelContext = undefined;
98
106
  conversationIsProcessing = false;
99
107
  });
100
108
 
@@ -193,3 +201,217 @@ describe("runConversationTurn provenance", () => {
193
201
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
194
202
  });
195
203
  });
204
+
205
+ /**
206
+ * Addressing a turn by chat rather than by conversation id.
207
+ *
208
+ * The point of these is not that the turn runs (that is the same code path
209
+ * either way) but that the conversation it runs in is the one the rest of
210
+ * the assistant already addresses by those coordinates. A caller keeping its
211
+ * own chat-to-conversation map would pass every test above and still be
212
+ * invisible to conversation reset, to the deny lanes, and to the channel
213
+ * metadata the conversation list renders.
214
+ */
215
+ describe("runConversationTurn channel binding", () => {
216
+ const CHANNEL = {
217
+ sourceChannel: "plugin" as const,
218
+ externalChatId: "imessage:+12025550142",
219
+ displayName: "Ada",
220
+ };
221
+
222
+ beforeEach(() => {
223
+ const db = getDb();
224
+ db.run("DELETE FROM messages");
225
+ db.run("DELETE FROM external_conversation_bindings");
226
+ db.run("DELETE FROM conversation_keys");
227
+ db.run("DELETE FROM conversations");
228
+ listChangedCalls.length = 0;
229
+ lastProcessMessageOptions = undefined;
230
+ lastEnqueueOptions = undefined;
231
+ lastTurnChannelContext = undefined;
232
+ conversationIsProcessing = false;
233
+ });
234
+
235
+ test("resolves the chat to the conversation inbound would have used", async () => {
236
+ const result = await runConversationTurn({
237
+ channel: CHANNEL,
238
+ content: [{ type: "text", text: "hello" }],
239
+ });
240
+
241
+ // The public name for this conversation, which is what reset and the deny
242
+ // lanes address it by.
243
+ expect(
244
+ getConversationByKey(
245
+ buildScopedConversationKey(
246
+ CHANNEL.sourceChannel,
247
+ CHANNEL.externalChatId,
248
+ ),
249
+ )?.conversationId,
250
+ ).toBe(result.conversationId);
251
+ });
252
+
253
+ test("keeps the same chat in the same conversation across turns", async () => {
254
+ const first = await runConversationTurn({
255
+ channel: CHANNEL,
256
+ content: [{ type: "text", text: "hello" }],
257
+ });
258
+ const second = await runConversationTurn({
259
+ channel: CHANNEL,
260
+ content: [{ type: "text", text: "still me" }],
261
+ });
262
+
263
+ expect(second.conversationId).toBe(first.conversationId);
264
+ // Only the first turn minted a conversation.
265
+ expect(listChangedCalls).toEqual([
266
+ { kind: "created", conversationId: first.conversationId },
267
+ ]);
268
+ });
269
+
270
+ test("gives a different chat its own conversation", async () => {
271
+ const ada = await runConversationTurn({
272
+ channel: CHANNEL,
273
+ content: [{ type: "text", text: "hello" }],
274
+ });
275
+ const grace = await runConversationTurn({
276
+ channel: { ...CHANNEL, externalChatId: "imessage:+12025550188" },
277
+ content: [{ type: "text", text: "hello" }],
278
+ });
279
+
280
+ expect(grace.conversationId).not.toBe(ada.conversationId);
281
+ });
282
+
283
+ test("records the channel metadata the conversation list reads", async () => {
284
+ const result = await runConversationTurn({
285
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
286
+ content: [{ type: "text", text: "hello" }],
287
+ });
288
+
289
+ const binding = getBindingByConversation(result.conversationId);
290
+ expect(binding).toMatchObject({
291
+ sourceChannel: "plugin",
292
+ externalChatId: CHANNEL.externalChatId,
293
+ externalUserId: "+12025550142",
294
+ displayName: "Ada",
295
+ });
296
+ });
297
+
298
+ test("follows the vendor when a display name changes", async () => {
299
+ await runConversationTurn({
300
+ channel: CHANNEL,
301
+ content: [{ type: "text", text: "hello" }],
302
+ });
303
+ const result = await runConversationTurn({
304
+ channel: { ...CHANNEL, displayName: "Ada L." },
305
+ content: [{ type: "text", text: "renamed" }],
306
+ });
307
+
308
+ expect(getBindingByConversation(result.conversationId)?.displayName).toBe(
309
+ "Ada L.",
310
+ );
311
+ });
312
+
313
+ test("runs the turn on the channel it was addressed by", async () => {
314
+ // Runtime assembly reads the per-turn channel first and only then the
315
+ // conversation's origin, so a turn into a conversation whose origin was
316
+ // never recorded would otherwise run as `vellum`. That is the wrong
317
+ // channel for the message row and for the permission cascade the tools
318
+ // are approved against.
319
+ await runConversationTurn({
320
+ channel: CHANNEL,
321
+ content: [{ type: "text", text: "hello" }],
322
+ });
323
+
324
+ expect(lastTurnChannelContext).toEqual({
325
+ userMessageChannel: "plugin",
326
+ assistantMessageChannel: "plugin",
327
+ });
328
+ expect(lastProcessMessageOptions?.metadata).toMatchObject({
329
+ userMessageChannel: "plugin",
330
+ assistantMessageChannel: "plugin",
331
+ });
332
+ });
333
+
334
+ test("a queued turn carries its own channel rather than inheriting one", async () => {
335
+ // The drain happens after this call returns and reads the channel off the
336
+ // queued message, falling back to whichever turn was in flight. On a
337
+ // conversation shared with another channel that fallback is someone
338
+ // else's channel.
339
+ conversationIsProcessing = true;
340
+
341
+ const result = await runConversationTurn({
342
+ channel: CHANNEL,
343
+ content: [{ type: "text", text: "hello" }],
344
+ });
345
+
346
+ expect(result.queued).toBe(true);
347
+ expect(lastEnqueueOptions?.metadata).toMatchObject({
348
+ userMessageChannel: "plugin",
349
+ assistantMessageChannel: "plugin",
350
+ });
351
+ });
352
+
353
+ test("leaves the channel unset when the turn names none", async () => {
354
+ // Clearing rather than leaving whatever the last turn set: a stale
355
+ // context would report this turn on a channel it has nothing to do with.
356
+ await runConversationTurn({
357
+ content: [{ type: "text", text: "hello" }],
358
+ });
359
+
360
+ expect(lastTurnChannelContext).toBeNull();
361
+ expect(lastProcessMessageOptions?.metadata).toEqual({ automated: true });
362
+ });
363
+
364
+ test("keeps a known sender when a later turn omits it", async () => {
365
+ // A plugin that knows only the chat coordinates this time is silent about
366
+ // the sender, not asserting it has none. Erasing the stored name on that
367
+ // silence drops it from the conversation and session APIs.
368
+ await runConversationTurn({
369
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
370
+ content: [{ type: "text", text: "hello" }],
371
+ });
372
+
373
+ const result = await runConversationTurn({
374
+ channel: {
375
+ sourceChannel: CHANNEL.sourceChannel,
376
+ externalChatId: CHANNEL.externalChatId,
377
+ },
378
+ content: [{ type: "text", text: "still me" }],
379
+ });
380
+
381
+ expect(getBindingByConversation(result.conversationId)).toMatchObject({
382
+ externalUserId: "+12025550142",
383
+ displayName: "Ada",
384
+ });
385
+ });
386
+
387
+ test("clears a sender the caller explicitly says is gone", async () => {
388
+ await runConversationTurn({
389
+ channel: CHANNEL,
390
+ content: [{ type: "text", text: "hello" }],
391
+ });
392
+
393
+ const result = await runConversationTurn({
394
+ channel: { ...CHANNEL, displayName: null },
395
+ content: [{ type: "text", text: "anonymous now" }],
396
+ });
397
+
398
+ expect(
399
+ getBindingByConversation(result.conversationId)?.displayName,
400
+ ).toBeNull();
401
+ });
402
+
403
+ test("an explicit conversation id wins over the address", async () => {
404
+ // Two different conversations were named. Re-resolving would overrule the
405
+ // caller, so the explicit one is honoured and nothing is bound behind it.
406
+ const existing = createConversation({ title: "chosen" });
407
+
408
+ const result = await runConversationTurn({
409
+ conversationId: existing.id,
410
+ channel: CHANNEL,
411
+ content: [{ type: "text", text: "hello" }],
412
+ });
413
+
414
+ expect(result.conversationId).toBe(existing.id);
415
+ expect(getBindingByConversation(existing.id)).toBeNull();
416
+ });
417
+ });
@@ -59,7 +59,6 @@ async function applyEdit(eventId: string, content: string): Promise<void> {
59
59
  conversationExternalId: CHANNEL,
60
60
  externalMessageId: eventId,
61
61
  sourceMessageId: ORIGINAL_TS,
62
- canonicalAssistantId: "self",
63
62
  assistantId: "self",
64
63
  content,
65
64
  });
@@ -2,12 +2,13 @@
2
2
  * Tests for the `assistant memory v3` CLI subgroup (section-lane model).
3
3
  *
4
4
  * Validates:
5
- * - Subcommand registration (rebuild-index, backfill-sections) under
6
- * `memory v3`.
7
- * - Each subcommand maps to the right `cliIpcCall` method.
5
+ * - Subcommand registration (rebuild-index, backfill-sections, gate-stats)
6
+ * under `memory v3`.
7
+ * - `rebuild-index` and `backfill-sections` route through IPC to the daemon.
8
8
  * - `backfill-sections` passes a long IPC timeout (the one-time full-corpus
9
- * embed easily outlasts the default 60s), while `rebuild-index` does not.
10
- * - IPC error paths return a non-zero exit code without throwing.
9
+ * embed easily outlasts the default 60s).
10
+ * - `gate-stats` calls handleMemoryV3GateStats directly — no daemon required.
11
+ * - Error paths return a non-zero exit code without throwing.
11
12
  */
12
13
 
13
14
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -39,6 +40,47 @@ let mockIpcResult: {
39
40
  /** Captured log output for assertion. */
40
41
  let logOutput: string[] = [];
41
42
 
43
+ /** Shared fake gate-stats payload used across gate-stats tests. */
44
+ const fakeGateStats = {
45
+ lookbackDays: 30,
46
+ totalRuns: 10,
47
+ buckets: [
48
+ {
49
+ pageCountRange: "0–9",
50
+ total: 2,
51
+ scored: 1,
52
+ passed: 2,
53
+ scoredPassRate: 1,
54
+ reasons: { dense_pass: 1, dense_disabled: 1 },
55
+ },
56
+ {
57
+ pageCountRange: "10–49",
58
+ total: 5,
59
+ scored: 4,
60
+ passed: 3,
61
+ scoredPassRate: 0.75,
62
+ reasons: { dense_pass: 3, fail_no_signal: 1 },
63
+ },
64
+ {
65
+ pageCountRange: "50–199",
66
+ total: 2,
67
+ scored: 2,
68
+ passed: 1,
69
+ scoredPassRate: 0.5,
70
+ reasons: { dense_pass: 1, fail_no_signal: 1 },
71
+ },
72
+ {
73
+ pageCountRange: "200+",
74
+ total: 1,
75
+ scored: 1,
76
+ passed: 0,
77
+ scoredPassRate: 0,
78
+ reasons: { fail_no_signal: 1 },
79
+ },
80
+ ],
81
+ unknownPageCount: { total: 0, passed: 0, reasons: {} },
82
+ };
83
+
42
84
  // ---------------------------------------------------------------------------
43
85
  // Mocks
44
86
  // ---------------------------------------------------------------------------
@@ -68,6 +110,33 @@ mock.module("../../../../util/logger.js", () => ({
68
110
  getCliLogger: () => fakeLogger,
69
111
  }));
70
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // gate-stats direct-invocation mocks
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Args captured from the last handleMemoryV3GateStats call. */
118
+ let lastGateStatsArgs: { lookbackDays: number; db: unknown } | null = null;
119
+
120
+ /** What handleMemoryV3GateStats returns, or an error to throw. */
121
+ let gateStatsImpl: (() => unknown) | null = null;
122
+
123
+ mock.module(
124
+ "../../../../plugins/defaults/memory/src/memory-v3-routes.js",
125
+ () => ({
126
+ handleMemoryV3GateStats: (lookbackDays: number, db: unknown) => {
127
+ lastGateStatsArgs = { lookbackDays, db };
128
+ if (gateStatsImpl) {
129
+ return gateStatsImpl();
130
+ }
131
+ return fakeGateStats;
132
+ },
133
+ }),
134
+ );
135
+
136
+ mock.module("../../../../persistence/db-connection.js", () => ({
137
+ getTelemetrySqlite: () => ({}),
138
+ }));
139
+
71
140
  // ---------------------------------------------------------------------------
72
141
  // Import modules under test (after mocks)
73
142
  // ---------------------------------------------------------------------------
@@ -135,6 +204,8 @@ beforeEach(() => {
135
204
  ok: true,
136
205
  result: { articles: 3, sections: 12, failures: 0 },
137
206
  };
207
+ lastGateStatsArgs = null;
208
+ gateStatsImpl = null;
138
209
  logOutput = [];
139
210
  process.exitCode = 0;
140
211
  });
@@ -155,6 +226,7 @@ describe("subcommand registration", () => {
155
226
  "backfill-sections",
156
227
  "eval",
157
228
  "eval-tally",
229
+ "gate-stats",
158
230
  "rebuild-index",
159
231
  ]);
160
232
  });
@@ -236,3 +308,54 @@ describe("memory v3 backfill-sections", () => {
236
308
  expect(exitCode).toBe(1);
237
309
  });
238
310
  });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // gate-stats (direct invocation — no daemon required)
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe("memory v3 gate-stats", () => {
317
+ test("calls handleMemoryV3GateStats directly with default lookback (no IPC)", async () => {
318
+ const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
319
+
320
+ expect(exitCode).toBe(0);
321
+ // Must NOT have gone through IPC.
322
+ expect(lastIpcCall).toBeNull();
323
+ // Must have called the handler directly with the default lookback.
324
+ expect(lastGateStatsArgs!.lookbackDays).toBe(30);
325
+ });
326
+
327
+ test("forwards --lookback-days directly to handleMemoryV3GateStats", async () => {
328
+ await runCommand(["memory", "v3", "gate-stats", "--lookback-days", "7"]);
329
+
330
+ expect(lastIpcCall).toBeNull();
331
+ expect(lastGateStatsArgs!.lookbackDays).toBe(7);
332
+ });
333
+
334
+ test("prints formatted table on success (no --json)", async () => {
335
+ await runCommand(["memory", "v3", "gate-stats"]);
336
+
337
+ expect(logOutput.some((l) => l.includes("Gate-stats"))).toBe(true);
338
+ expect(logOutput.some((l) => l.includes("10–49"))).toBe(true);
339
+ expect(logOutput.some((l) => l.includes("75.0%"))).toBe(true);
340
+ });
341
+
342
+ test("emits raw JSON with --json", async () => {
343
+ await runCommand(["memory", "v3", "gate-stats", "--json"]);
344
+
345
+ const raw = logOutput.join("");
346
+ const parsed = JSON.parse(raw) as typeof fakeGateStats;
347
+ expect(parsed.totalRuns).toBe(10);
348
+ expect(parsed.buckets).toHaveLength(4);
349
+ });
350
+
351
+ test("exits with code 1 when handleMemoryV3GateStats throws", async () => {
352
+ gateStatsImpl = () => {
353
+ throw new Error("telemetry DB locked");
354
+ };
355
+
356
+ const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
357
+
358
+ expect(exitCode).toBe(1);
359
+ expect(logOutput.some((l) => l.includes("telemetry DB locked"))).toBe(true);
360
+ });
361
+ });
@@ -606,7 +606,9 @@ that live state safely.
606
606
 
607
607
  Examples:
608
608
  $ assistant memory v3 rebuild-index
609
- $ assistant memory v3 backfill-sections`,
609
+ $ assistant memory v3 backfill-sections
610
+ $ assistant memory v3 gate-stats
611
+ $ assistant memory v3 gate-stats --lookback-days 7 --json`,
610
612
  subcommands: [
611
613
  {
612
614
  name: "rebuild-index",
@@ -716,6 +718,46 @@ Examples:
716
718
  $ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval
717
719
  $ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --turns-file .mv3/eval/key.json
718
720
  $ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --exclude-conversation <migration-conv-id>`,
721
+ },
722
+ {
723
+ name: "gate-stats",
724
+ description:
725
+ "Show injection gate pass rates bucketed by corpus size (read-only)",
726
+ options: [
727
+ {
728
+ flags: "--lookback-days <n>",
729
+ description: "Days of telemetry to aggregate (1–90, default 30)",
730
+ defaultValue: "30",
731
+ },
732
+ {
733
+ flags: "--json",
734
+ description: "Emit raw JSON instead of a formatted table",
735
+ },
736
+ ],
737
+ helpText: `
738
+ Reads the memory v3 injection gate telemetry outbox and prints pass rates
739
+ and reason distributions grouped by concept page count bucket
740
+ (0–9 / 10–49 / 50–199 / 200+). Scored runs (dense lane was available and
741
+ actually weighed scores) are reported separately from pass-open shortcuts
742
+ (dense disabled / unavailable / gate threw) so scoredPassRate reflects
743
+ only contested gate decisions — the signal relevant for threshold calibration.
744
+
745
+ Coverage is limited to runs still pending platform flush. In a healthy system
746
+ the outbox holds only the last few minutes to hours of events, so
747
+ --lookback-days is an upper bound, not a guarantee; long-window aggregation
748
+ lives on the platform side on top of flushed watchdog events.
749
+
750
+ Reads the telemetry database directly — the assistant does not need to be
751
+ running.
752
+
753
+ Intended use: verify that gate firing rates shift as expected after a
754
+ config or threshold change, and check whether the bm25-auto-calibration
755
+ flag is worth enabling for large corpora.
756
+
757
+ Examples:
758
+ $ assistant memory v3 gate-stats
759
+ $ assistant memory v3 gate-stats --lookback-days 7
760
+ $ assistant memory v3 gate-stats --json | jq '.buckets[] | select(.pageCountRange == "200+")'`,
719
761
  },
720
762
  {
721
763
  name: "eval-tally",