@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
@@ -1,354 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Memory buffer file: the two writers of `memory/buffer.md`
3
- // ---------------------------------------------------------------------------
4
- //
5
- // `memory/buffer.md` has exactly two writers, and both live here so the
6
- // protocol between them has one home. `remember()` and the sweep job APPEND
7
- // entries through `appendBufferAndArchive`. The consolidation job CONSUMES
8
- // the entries it handed to a run through `consumeBufferEntries`, removing
9
- // exactly those entries and nothing else. Everything else in the plugin
10
- // reads the file. The entry format itself is owned by `buffer-format.ts`.
11
- //
12
- // The writers run in different processes: user turns append from the
13
- // daemon, while consolidation (and its consume) runs in the memory jobs
14
- // worker, and the sweep appends from the worker too. No in-process lock can
15
- // order them, and the buffer must never be rewritten from a stale read: an
16
- // entry appended between a reader's read and its write would be destroyed
17
- // without ever being filed. The consumer therefore never trusts a read for
18
- // longer than one synchronous stretch and never lets an append land where
19
- // it cannot see it:
20
- //
21
- // 1. Open the file and keep the descriptor: it pins the inode the path
22
- // names at that moment (call it O).
23
- // 2. Read O through that descriptor, remove the consumed entries from what
24
- // was read, write the remainder to a sibling temp file, and rename it
25
- // over the path. Steps 1 and 2 run synchronously, with no await, so
26
- // nothing in this process interleaves. From the rename on, the path
27
- // names a new inode (N) and every appender that opens the path lands
28
- // on N, where nothing is ever rewritten.
29
- // 3. Drain: an appender that opened O before the rename still writes to O,
30
- // which the held descriptor keeps alive. Compare O's size against the
31
- // bytes read; copy anything beyond them onto the path (an append to N,
32
- // the same operation the appenders use). Drain once immediately, then
33
- // once more after a short grace window, and only then close O.
34
- //
35
- // What can still be lost: an appender whose `open` preceded the rename and
36
- // whose `write` lands after the final drain. Both calls sit inside one
37
- // synchronous `appendFileSync`, so that needs the appending process to be
38
- // stopped between two adjacent syscalls for longer than the grace window (a
39
- // debugger, SIGSTOP, or a multi-second scheduler stall). Machine sleep
40
- // freezes both processes together and does not count. The other way out is
41
- // the drain's own copy failing (disk full, an I/O error) on every retry;
42
- // the descriptor cannot be held forever, so those bytes are then gone from
43
- // the buffer and reported as unrecovered, and the entries survive only in
44
- // the daily archive the same append wrote. Nothing after the rename throws:
45
- // once the rename has committed the pass is consumed, and the result says
46
- // so. If either residual ever proves reachable, the escalation is a
47
- // short-held `wx` lock file taken by both writers around their synchronous
48
- // critical sections (the idiom `substrate/consolidation-lock.ts` uses);
49
- // the consume shape above does not change, only its critical section gains
50
- // a guard.
51
- //
52
- // Ordering: consumption removes entries and copies the rest verbatim, so
53
- // the file stays in append order except for a drained late append, which
54
- // lands after any entry appended to N in the meantime. Readers only rely
55
- // on the tail being recent, never on strict order, so that is harmless.
56
- // The same holds if a late append's bytes were split across the two
57
- // drains (a partial `write()` on a regular file, well below the residual
58
- // above): the halves land in order on N, possibly with another appender's
59
- // entry between them, so the entry is split rather than lost, and the
60
- // next pass's snapshot selection treats an unterminated tail as
61
- // in-flight.
62
-
63
- import {
64
- appendFileSync,
65
- closeSync,
66
- existsSync,
67
- fstatSync,
68
- mkdirSync,
69
- openSync,
70
- readFileSync,
71
- readSync,
72
- renameSync,
73
- unlinkSync,
74
- writeFileSync,
75
- writeSync,
76
- } from "node:fs";
77
- import { dirname, join } from "node:path";
78
-
79
- import {
80
- type BufferEntryLines,
81
- bufferEntryText,
82
- joinBufferEntries,
83
- splitBufferContent,
84
- } from "./buffer-format.js";
85
- import { getLogger } from "./logging.js";
86
-
87
- const log = getLogger("memory-buffer-file");
88
-
89
- /**
90
- * How long the consumer keeps the replaced inode open after the rename
91
- * before its final drain. Only an appender stalled between its `open` and
92
- * its `write` for longer than this can lose an entry; a healthy appender
93
- * completes both within microseconds.
94
- */
95
- const LATE_APPEND_GRACE_MS = 500;
96
-
97
- /** Attempts to copy late-appended bytes back before giving them up. */
98
- const LATE_APPEND_COPY_ATTEMPTS = 3;
99
- /** Wait between those attempts; disk pressure and I/O errors are often brief. */
100
- const LATE_APPEND_COPY_RETRY_MS = 200;
101
-
102
- /**
103
- * Append `entry` to `<rootDir>/buffer.md` and `<rootDir>/archive/<today>.md`,
104
- * creating the archive directory and seeding the archive header if missing.
105
- *
106
- * Returns the absolute paths of both files so callers can fan out follow-up
107
- * work.
108
- *
109
- * Shared by `remember()` and the background jobs (`sweep`, future LLM-driven
110
- * extractors) so every appender writes exactly the same format and
111
- * downstream consumers (consolidation, search) cannot tell them apart.
112
- */
113
- export function appendBufferAndArchive(args: {
114
- rootDir: string;
115
- entry: string;
116
- now: Date;
117
- }): { bufferPath: string; archivePath: string } {
118
- const { rootDir, entry, now } = args;
119
- const archiveDir = join(rootDir, "archive");
120
- mkdirSync(archiveDir, { recursive: true });
121
-
122
- const bufferPath = join(rootDir, "buffer.md");
123
- appendFileSync(bufferPath, entry, "utf-8");
124
-
125
- const yyyy = now.getFullYear();
126
- const mm = String(now.getMonth() + 1).padStart(2, "0");
127
- const dd = String(now.getDate()).padStart(2, "0");
128
- const archivePath = join(archiveDir, `${yyyy}-${mm}-${dd}.md`);
129
- if (!existsSync(archivePath)) {
130
- const month = now.toLocaleString("en-US", { month: "short" });
131
- appendFileSync(
132
- archivePath,
133
- `# ${month} ${now.getDate()}, ${yyyy}\n\n`,
134
- "utf-8",
135
- );
136
- }
137
- appendFileSync(archivePath, entry, "utf-8");
138
-
139
- return { bufferPath, archivePath };
140
- }
141
-
142
- export interface ConsumeBufferEntriesResult {
143
- /** Consumed entries found in the live file and removed. */
144
- removed: number;
145
- /**
146
- * Consumed entries not present in the live file. Something other than an
147
- * appender rewrote the file during the run; the caller decides whether
148
- * that is worth a warning.
149
- */
150
- alreadyAbsent: number;
151
- /** Bytes appended to the replaced inode after the read and copied back. */
152
- lateAppendBytesRecovered: number;
153
- /**
154
- * Bytes appended to the replaced inode that could not be copied back
155
- * after every retry. The pass is consumed regardless (the rename had
156
- * committed); these entries remain only in the daily archive.
157
- */
158
- unrecoveredLateAppendBytes: number;
159
- /**
160
- * The replaced inode could not be inspected or read after the rename, so
161
- * any append that landed on it is unaccounted for (not even counted in
162
- * `unrecoveredLateAppendBytes`). The pass is consumed regardless.
163
- */
164
- lateAppendDrainFailed: boolean;
165
- }
166
-
167
- /**
168
- * Remove `consumed` from `bufferPath`, leaving every other entry in place.
169
- * Never throws once the rename has committed; see the result's
170
- * `unrecoveredLateAppendBytes`.
171
- *
172
- * Each consumed entry is matched by its exact text ({@link bufferEntryText})
173
- * against the live file, first occurrence, once per consumed entry, so an
174
- * identical fact appended during the run survives when the snapshot held
175
- * one copy. An entry that is absent from the live file is counted, never
176
- * searched for elsewhere. A missing file consumes nothing.
177
- *
178
- * The critical section (read through a pinned descriptor, rewrite via temp
179
- * file + rename) is synchronous; the drain of late appends to the replaced
180
- * inode follows, with a second drain after `lateAppendGraceMs`. See the
181
- * module header for why this is lossless and what the residual is.
182
- *
183
- * I/O failures other than a missing file throw: the caller (the
184
- * consolidation job) treats a failed consume as no progress and leaves the
185
- * buffer for the next pass rather than guessing at its state.
186
- */
187
- export async function consumeBufferEntries(
188
- bufferPath: string,
189
- consumed: readonly BufferEntryLines[],
190
- options: { lateAppendGraceMs?: number } = {},
191
- ): Promise<ConsumeBufferEntriesResult> {
192
- const graceMs = options.lateAppendGraceMs ?? LATE_APPEND_GRACE_MS;
193
- const pending = consumed.map(bufferEntryText);
194
-
195
- let fd: number;
196
- try {
197
- fd = openSync(bufferPath, "r");
198
- } catch (err) {
199
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
200
- return {
201
- removed: 0,
202
- alreadyAbsent: pending.length,
203
- lateAppendBytesRecovered: 0,
204
- unrecoveredLateAppendBytes: 0,
205
- lateAppendDrainFailed: false,
206
- };
207
- }
208
- throw err;
209
- }
210
-
211
- try {
212
- // Synchronous critical section: nothing in this process runs between
213
- // the read and the rename.
214
- const before = readFileSync(fd);
215
- let drainedTo = before.length;
216
- const live = splitBufferContent(before.toString("utf-8"));
217
- const remaining: BufferEntryLines[] = [];
218
- let removed = 0;
219
- for (const entry of live) {
220
- const index = pending.indexOf(bufferEntryText(entry));
221
- if (index === -1) {
222
- remaining.push(entry);
223
- continue;
224
- }
225
- pending.splice(index, 1);
226
- removed += 1;
227
- }
228
- const tmpPath = join(
229
- dirname(bufferPath),
230
- `.buffer.md.${process.pid}.${Date.now()}.tmp`,
231
- );
232
- writeFileSync(tmpPath, joinBufferEntries(remaining), "utf-8");
233
- try {
234
- renameSync(tmpPath, bufferPath);
235
- } catch (err) {
236
- unlinkSafely(tmpPath);
237
- throw err;
238
- }
239
-
240
- // Drain: appends that landed on the replaced inode after the read. The
241
- // rename has committed, so from here nothing throws: a copy that fails
242
- // is retried, bytes that cannot be copied are reported, and a replaced
243
- // inode that cannot even be read is reported, all without raising.
244
- let lateAppendBytesRecovered = 0;
245
- let unrecoveredLateAppendBytes = 0;
246
- let lateAppendDrainFailed = false;
247
- // Copies through an explicit write loop so a retry resumes from the
248
- // first unwritten byte: an append that wrote part of the bytes before
249
- // failing (disk pressure) must not have its prefix appended twice.
250
- const copyBack = async (bytes: Buffer): Promise<void> => {
251
- let written = 0;
252
- for (let attempt = 1; ; attempt++) {
253
- try {
254
- const out = openSync(bufferPath, "a");
255
- try {
256
- while (written < bytes.length) {
257
- const n = writeSync(out, bytes, written, bytes.length - written);
258
- if (n <= 0) {
259
- // A regular file never returns a zero-byte write without an
260
- // error, but a spin here would wedge the worker with the
261
- // consolidation lock held; treat it as a failed attempt.
262
- throw new Error("writeSync made no progress");
263
- }
264
- written += n;
265
- }
266
- } finally {
267
- closeSync(out);
268
- }
269
- lateAppendBytesRecovered += bytes.length;
270
- return;
271
- } catch (err) {
272
- if (attempt >= LATE_APPEND_COPY_ATTEMPTS) {
273
- log.error(
274
- { err, bufferPath, bytes: bytes.length, written, attempt },
275
- "buffer consume: could not copy entries appended during the rewrite back into the buffer; giving up the rest",
276
- );
277
- lateAppendBytesRecovered += written;
278
- unrecoveredLateAppendBytes += bytes.length - written;
279
- return;
280
- }
281
- log.warn(
282
- { err, bufferPath, bytes: bytes.length, written, attempt },
283
- "buffer consume: copying late-appended entries failed; retrying from the first unwritten byte",
284
- );
285
- await new Promise((resolve) =>
286
- setTimeout(resolve, LATE_APPEND_COPY_RETRY_MS),
287
- );
288
- }
289
- }
290
- };
291
- const drain = async (): Promise<void> => {
292
- let late: Buffer;
293
- try {
294
- const size = fstatSync(fd).size;
295
- if (size <= drainedTo) {
296
- return;
297
- }
298
- late = Buffer.alloc(size - drainedTo);
299
- const read = readSync(fd, late, 0, late.length, drainedTo);
300
- late = late.subarray(0, read);
301
- drainedTo += read;
302
- } catch (err) {
303
- log.error(
304
- { err, bufferPath },
305
- "buffer consume: could not read the replaced inode for entries appended during the rewrite",
306
- );
307
- lateAppendDrainFailed = true;
308
- return;
309
- }
310
- await copyBack(late);
311
- };
312
- await drain();
313
- if (graceMs > 0) {
314
- await new Promise((resolve) => setTimeout(resolve, graceMs));
315
- }
316
- if (!lateAppendDrainFailed) {
317
- await drain();
318
- }
319
- if (lateAppendBytesRecovered > 0) {
320
- log.info(
321
- { bufferPath, lateAppendBytesRecovered },
322
- "buffer consume: recovered entries appended during the rewrite",
323
- );
324
- }
325
-
326
- return {
327
- removed,
328
- alreadyAbsent: pending.length,
329
- lateAppendBytesRecovered,
330
- unrecoveredLateAppendBytes,
331
- lateAppendDrainFailed,
332
- };
333
- } finally {
334
- // After the rename, a failing close must not surface as a failure of
335
- // the consume: the pass is consumed and the drain has reported. Before
336
- // the rename, the original error is already propagating.
337
- try {
338
- closeSync(fd);
339
- } catch (err) {
340
- log.error(
341
- { err, bufferPath },
342
- "buffer consume: closing the replaced buffer inode failed",
343
- );
344
- }
345
- }
346
- }
347
-
348
- function unlinkSafely(path: string): void {
349
- try {
350
- unlinkSync(path);
351
- } catch {
352
- // best-effort cleanup of a temp file the rename never claimed
353
- }
354
- }
@@ -1,213 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Memory run evidence: what a background memory run durably produced.
3
- // ---------------------------------------------------------------------------
4
- //
5
- // A background run's persisted message rows are the one record of what it
6
- // did. A `tool_use` block proves only that the model asked; the durable write
7
- // happens inside the executor, so a call counts as evidence only when a
8
- // matching non-error `tool_result` is persisted on the same rows. The
9
- // readers here apply that rule uniformly so every memory job that gates a
10
- // state transition on "the run verifiably wrote something" (the
11
- // retrospective's cursor advance, consolidation's buffer consumption)
12
- // reads the same evidence the same way.
13
- //
14
- // This module sits at the plugin root (shared infra, not a tier) and imports
15
- // nothing from the plugin, so `substrate/` and the spine can both reach it
16
- // without a tier importing spine.
17
-
18
- import type { ContentBlock } from "@vellumai/plugin-api";
19
-
20
- /**
21
- * Whether the LAST persisted assistant row on the run's tail carries a text
22
- * block with non-whitespace content. Paired with a model-driven stop and
23
- * zero memory-write attempts, that closing reply is the persisted artifact
24
- * of a pass that read its window and had nothing to save: the model spoke
25
- * and then chose to end the run. Reading only the final row separates it
26
- * from a run whose narration went live but whose actual conclusion was
27
- * empty, as well as from a response that committed nothing at all
28
- * (thinking-only output, an empty content array).
29
- */
30
- export function hasCommittedTextReply(messages: MessageLike[]): boolean {
31
- for (let i = messages.length - 1; i >= 0; i--) {
32
- const msg = messages[i]!;
33
- if (msg.role !== "assistant") {
34
- continue;
35
- }
36
- const blocks = parseMessageBlocks(msg);
37
- if (blocks === null) {
38
- return false;
39
- }
40
- return blocks.some(
41
- (b) =>
42
- b.type === "text" && typeof b.text === "string" && b.text.trim() !== "",
43
- );
44
- }
45
- return false;
46
- }
47
-
48
- /**
49
- * Whether the run's FINAL persisted row is an assistant reply in its own
50
- * words: text with non-whitespace content and no `tool_use` block. This is
51
- * the shape of a run the model ended itself, since the loop stops when the
52
- * assistant answers without asking for a tool. It is stricter than
53
- * {@link hasCommittedTextReply} on purpose: narration on a row that also
54
- * calls a tool ("fixing that page now" followed by `file_write`) is not a
55
- * conclusion, and a run whose last row is a tool result stopped mid-loop.
56
- */
57
- export function endsWithTextReply(messages: MessageLike[]): boolean {
58
- const last = messages[messages.length - 1];
59
- if (last === undefined || last.role !== "assistant") {
60
- return false;
61
- }
62
- const blocks = parseMessageBlocks(last);
63
- if (blocks === null || blocks.some((b) => b.type === "tool_use")) {
64
- return false;
65
- }
66
- return blocks.some(
67
- (b) =>
68
- b.type === "text" && typeof b.text === "string" && b.text.trim() !== "",
69
- );
70
- }
71
-
72
- /**
73
- * Ids of `tool_result` blocks on the run's user rows whose execution did not
74
- * report an error. Robust to malformed content JSON the same way
75
- * `extractRememberContents` is.
76
- */
77
- export function collectSuccessfulToolResultIds(
78
- messages: MessageLike[],
79
- ): Set<string> {
80
- const ids = new Set<string>();
81
- for (const msg of messages) {
82
- if (msg.role !== "user") {
83
- continue;
84
- }
85
- for (const b of parseMessageBlocks(msg) ?? []) {
86
- // guard:allow-tool-result-only: success evidence for locally-executed
87
- // durable memory tools; server-side web_search_tool_result never
88
- // corresponds to a durable write and carries no is_error flag.
89
- if (
90
- b.type === "tool_result" &&
91
- typeof b.tool_use_id === "string" &&
92
- b.is_error !== true
93
- ) {
94
- ids.add(b.tool_use_id);
95
- }
96
- }
97
- }
98
- return ids;
99
- }
100
-
101
- /**
102
- * Count persisted `tool_use` blocks whose `name` is in `durableTools` across
103
- * the run's assistant rows. With a `succeededIds` set, only calls whose id
104
- * has a matching successful `tool_result` count (verified executions); with
105
- * `null`, every attempt counts regardless of outcome.
106
- */
107
- export function countDurableToolUses(
108
- messages: MessageLike[],
109
- durableTools: ReadonlySet<string>,
110
- succeededIds: ReadonlySet<string> | null,
111
- ): number {
112
- let count = 0;
113
- for (const msg of messages) {
114
- if (msg.role !== "assistant") {
115
- continue;
116
- }
117
- for (const b of parseMessageBlocks(msg) ?? []) {
118
- if (
119
- b.type === "tool_use" &&
120
- durableTools.has(String(b.name)) &&
121
- (succeededIds === null ||
122
- (typeof b.id === "string" && succeededIds.has(b.id)))
123
- ) {
124
- count += 1;
125
- }
126
- }
127
- }
128
- return count;
129
- }
130
-
131
- export interface MessageLike {
132
- role: string;
133
- content: string | ContentBlock[];
134
- }
135
-
136
- /**
137
- * Parse a message row's content into its block objects, or `null` when the
138
- * content is malformed (unparseable JSON, not an array). Non-object entries
139
- * are dropped. Every evidence reader in this module goes through this so
140
- * malformed rows degrade the same way everywhere: skipped, not propagated.
141
- */
142
- function parseMessageBlocks(
143
- msg: MessageLike,
144
- ): Record<string, unknown>[] | null {
145
- let blocks: unknown = msg.content;
146
- if (typeof blocks === "string") {
147
- try {
148
- blocks = JSON.parse(blocks);
149
- } catch {
150
- return null;
151
- }
152
- }
153
- if (!Array.isArray(blocks)) {
154
- return null;
155
- }
156
- return blocks.filter(
157
- (block): block is Record<string, unknown> =>
158
- typeof block === "object" && block !== null,
159
- );
160
- }
161
-
162
- /**
163
- * Scan an array of message rows for `tool_use` blocks where `name` is
164
- * `"remember"` and return the `input.content` strings in order. Robust to
165
- * malformed content JSON: unparseable rows are skipped, not propagated.
166
- */
167
- export function extractRememberContents(
168
- messages: MessageLike[],
169
- succeededIds?: ReadonlySet<string>,
170
- ): string[] {
171
- const contents: string[] = [];
172
- for (const msg of messages) {
173
- if (msg.role !== "assistant") {
174
- continue;
175
- }
176
- for (const b of parseMessageBlocks(msg) ?? []) {
177
- if (b.type !== "tool_use") {
178
- continue;
179
- }
180
- if (b.name !== "remember") {
181
- continue;
182
- }
183
- // When a success set is provided, only executions that reported a
184
- // non-error tool_result contribute facts: a failed remember never
185
- // wrote the buffer, and logging its facts would suppress the retry's
186
- // re-save via <already_remembered>.
187
- if (
188
- succeededIds !== undefined &&
189
- (typeof b.id !== "string" || !succeededIds.has(b.id))
190
- ) {
191
- continue;
192
- }
193
- const input = b.input;
194
- if (!input || typeof input !== "object") {
195
- continue;
196
- }
197
- const content = (input as Record<string, unknown>).content;
198
- // `remember` accepts a single string or an array of facts (batch form);
199
- // flatten both so batched saves still feed the dedup baseline.
200
- const facts = Array.isArray(content) ? content : [content];
201
- for (const fact of facts) {
202
- if (typeof fact !== "string") {
203
- continue;
204
- }
205
- const trimmed = fact.trim();
206
- if (trimmed.length > 0) {
207
- contents.push(trimmed);
208
- }
209
- }
210
- }
211
- }
212
- return contents;
213
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Tool surface the memory consolidation run is wire-scoped to.
3
- *
4
- * Consolidation is a local memory-file reorganization pass: it reads
5
- * `buffer.md` and the existing pages, writes and edits concept pages, rewrites
6
- * recent/essentials/threads, and trims the buffer.
7
- *
8
- * `bash` carries the corpus-wide inspection the pass leans on: slug and title
9
- * greps before spawning a page, status-marker counts, size checks against the
10
- * cap-bound files, frontmatter peeks across a batch of slugs. The run is
11
- * guardian-trust and non-interactive, so the permission checker's autonomous
12
- * threshold, not this list, decides which shell commands execute unattended.
13
- *
14
- * `delete_memory_page` retires merged, renamed, or dead pages. Unattended `rm`
15
- * is denied unless the autonomous threshold is Full access, so this slug-scoped
16
- * tool is the delete path everywhere else. It is allowlist-only (see
17
- * `ALLOWLIST_ONLY_TOOL_NAMES`), so naming it here is what surfaces it.
18
- *
19
- * Network egress and host-proxy tools (`web_fetch`, `web_search`,
20
- * `network_request`, `host_*`) are excluded: the pass has no use for them.
21
- *
22
- * Every name here must resolve onto the wire for a background conversation.
23
- * `SUBAGENT_ONLY_TOOL_NAMES` (`file_list`, `code_search`) are filtered off
24
- * before this allowlist applies, so listing one is a silent no-op. The wire
25
- * resolution is asserted in `daemon/__tests__/conversation-tool-setup.test.ts`.
26
- */
27
- export const CONSOLIDATION_ALLOWED_TOOLS: readonly string[] = [
28
- "file_read",
29
- "file_write",
30
- "file_edit",
31
- "bash",
32
- "delete_memory_page",
33
- "recall",
34
- ];