@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.
- package/docs/architecture/memory.md +2 -11
- package/docs/desktop-browser-cli.md +2 -4
- package/node_modules/@vellumai/environments/src/shell.test.ts +0 -21
- package/node_modules/@vellumai/environments/src/shell.ts +0 -24
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +2 -8
- package/openapi.yaml +2 -6
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +0 -1
- package/src/__tests__/agent-loop.test.ts +0 -124
- package/src/__tests__/approval-interception-trust-gates.test.ts +0 -40
- package/src/__tests__/channel-approval.test.ts +14 -9
- package/src/__tests__/conversation-agent-loop.test.ts +0 -25
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +1 -138
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +0 -169
- package/src/__tests__/terminal-tools.test.ts +0 -8
- package/src/__tests__/unicode.test.ts +0 -36
- package/src/agent/loop.ts +0 -19
- package/src/api/index.ts +0 -6
- package/src/approvals/approval-primitive.ts +2 -5
- package/src/approvals/scoped-approval-grants.ts +2 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -43
- package/src/daemon/conversation-agent-loop.ts +0 -2
- package/src/daemon/conversation-tool-setup.ts +1 -57
- package/src/daemon/conversation.ts +0 -17
- package/src/daemon/daemon-control.ts +6 -2
- package/src/daemon/orphan-reaper.ts +3 -4
- package/src/daemon/tool-setup-types.ts +0 -6
- package/src/daemon/wake-conversation-ops.ts +15 -38
- package/src/desktop/desktop-automation-lease.test.ts +0 -143
- package/src/desktop/desktop-automation-lease.ts +3 -39
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/notifications/__tests__/copy-composer.test.ts +0 -70
- package/src/notifications/copy-composer.ts +3 -11
- package/src/oauth/seed-providers.ts +30 -0
- package/src/persistence/conversation-plugin-facade.ts +0 -13
- package/src/persistence/schema/index.ts +0 -1
- package/src/persistence/steps.ts +0 -2
- package/src/plugin-api/conversation-turn.ts +7 -31
- package/src/plugin-api/index.ts +1 -9
- package/src/plugins/defaults/memory/AGENTS.md +2 -14
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +0 -43
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -46
- package/src/plugins/defaults/memory/buffer-format.ts +0 -40
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +2 -1
- package/src/plugins/defaults/memory/context-search/format.ts +1 -2
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +1 -2
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -2
- package/src/plugins/defaults/memory/graph/capability-seed.ts +2 -1
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +42 -1
- package/src/plugins/defaults/memory/host-utils.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +3 -4
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +181 -55
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +99 -407
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +0 -10
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +7 -107
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +86 -307
- package/src/plugins/defaults/memory/substrate/page-index.ts +1 -2
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +49 -89
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +1 -2
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +0 -5
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +2 -5
- package/src/plugins/defaults/memory/v2/reranker.ts +1 -2
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +1 -81
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +0 -87
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +0 -21
- package/src/plugins/defaults/memory/v3/card.ts +1 -2
- package/src/plugins/defaults/memory/v3/injector.ts +178 -212
- package/src/plugins/defaults/memory/v3/orchestrate.ts +22 -86
- package/src/plugins/defaults/memory/v3/pool-select.ts +7 -10
- package/src/plugins/defaults/memory/v3/sections.ts +1 -2
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +1 -10
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +46 -1
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +1 -86
- package/src/runtime/agent-wake.ts +4 -20
- package/src/runtime/guardian-action-service.ts +17 -2
- package/src/runtime/guardian-reply-router.ts +8 -1
- package/src/runtime/routes/channel-route-shared.ts +9 -1
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +3 -7
- package/src/runtime/routes/guardian-approval-interception.ts +0 -24
- package/src/runtime/routes/inbound-message-handler.ts +3 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -7
- package/src/schedule/run-script.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +6 -12
- package/src/tools/shared/filesystem/file-ops-service.ts +31 -1
- package/src/tools/shared/shell-output.test.ts +0 -10
- package/src/tools/shared/shell-output.ts +2 -14
- package/src/tools/skills/sandbox-runner.ts +2 -13
- package/src/tools/skills/scaffold-managed.ts +1 -2
- package/src/tools/terminal/__tests__/safe-env.test.ts +0 -29
- package/src/tools/terminal/safe-env.ts +1 -30
- package/src/tools/terminal/sanitized-bash.ts +2 -15
- package/src/tools/terminal/shell.test.ts +0 -29
- package/src/tools/terminal/shell.ts +7 -13
- package/src/util/host-process.test.ts +1 -17
- package/src/util/host-process.ts +0 -24
- package/src/util/unicode.ts +0 -29
- package/src/__tests__/db-conversation-tool-surface.test.ts +0 -144
- package/src/api/events/desktop-activity-changed.ts +0 -10
- package/src/persistence/conversation-tool-surface.ts +0 -86
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +0 -78
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +0 -29
- package/src/persistence/schema/conversation-tool-surfaces.ts +0 -22
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +0 -133
- package/src/plugin-api/plugin-channel-turn-trust.ts +0 -71
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +0 -320
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +0 -17
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +0 -161
- package/src/plugins/defaults/memory/buffer-file.ts +0 -354
- package/src/plugins/defaults/memory/memory-run-evidence.ts +0 -213
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +0 -34
- 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
|
-
];
|