openmausbot 0.1.69 → 0.1.71
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/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
- package/dist/assets/index-CYLdYtYp.css +1 -0
- package/dist/assets/index-Di4ovwu5.js +289 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +6 -2
- package/dist-server/drivers/agents-proxy.js +305 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +3141 -1124
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/local-computer.js +2 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +772 -260
- package/dist-server/pair-cli.js +772 -260
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/config.js +40 -4
- package/dist-server/server/delegations.js +132 -74
- package/dist-server/server/drivers/acp/core.js +2 -0
- package/dist-server/server/drivers/acp/cursor.js +1 -1
- package/dist-server/server/drivers/acp/grok.js +5 -1
- package/dist-server/server/drivers/agents-proxy.js +324 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +298 -21
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/harness/registry.js +31 -6
- package/dist-server/server/index.js +860 -215
- package/dist-server/server/local-computer.js +1 -1
- package/dist-server/server/mcp-gate-config.js +56 -0
- package/dist-server/server/mcp-gate.js +227 -0
- package/dist-server/server/mcp-trim.js +182 -0
- package/dist-server/server/memory-journal.js +440 -0
- package/dist-server/server/memory-store.js +308 -0
- package/dist-server/server/message-db.js +90 -3
- package/dist-server/server/package-export.js +25 -0
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/peer-provenance.js +6 -2
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +15 -0
- package/dist-server/server/resume-recovery.js +46 -0
- package/dist-server/server/routines.js +116 -55
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/steer-queue.js +20 -1
- package/dist-server/server/store.js +29 -7
- package/dist-server/server/system-prompt.js +14 -1
- package/dist-server/server/team-backup.js +53 -4
- package/dist-server/server/tool-summary.js +39 -0
- package/dist-server/server/tts/speech-text.js +3 -1
- package/dist-server/server/turn-context.js +18 -0
- package/dist-server/server/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/team-backup.js +14 -2
- package/dist-server/vps-container-mcp.js +6 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-COp5lMnv.js +0 -279
- package/dist/assets/index-Jk7TIeyW.css +0 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// What changed about what the bot believes — and a way back.
|
|
2
|
+
//
|
|
3
|
+
// Every change to a memory file that goes through the server becomes one
|
|
4
|
+
// row here: the person's edits (routes), the bot's (memory_update or its
|
|
5
|
+
// own file tools), imports, reverts. Bot writes have no hook to tap — a
|
|
6
|
+
// file tool is a process writing to disk — so the turn boundary is the
|
|
7
|
+
// hook: the last text this module saw of every memory file is kept as a
|
|
8
|
+
// baseline, and at turn end whatever differs from it is the bot's work.
|
|
9
|
+
//
|
|
10
|
+
// The journal lives OUTSIDE the workspace on purpose, at
|
|
11
|
+
// DATA_DIR/memory-journal/<botId>.ndjson. The workspace is the bot's desk —
|
|
12
|
+
// it has file tools pointed at it and acceptEdits on. An audit trail the
|
|
13
|
+
// audited party can rewrite is not an audit trail.
|
|
14
|
+
//
|
|
15
|
+
// Same discipline as profile-versions.ts: 0600, through redactSecrets,
|
|
16
|
+
// serialized per bot, fire-and-forget — a journal write must never take
|
|
17
|
+
// down the change it records, and a turn must never fail because an audit
|
|
18
|
+
// line did.
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
21
|
+
import { appendFile } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { DATA_DIR } from "./config.js";
|
|
24
|
+
import { MEMORY_INDEX, MemoryStoreError, deleteMemoryDoc, hashMemoryText, memoryOverview, readMemoryDoc, writeMemoryDoc, } from "./memory-store.js";
|
|
25
|
+
import { redactSecretsInText } from "./redact.js";
|
|
26
|
+
export const JOURNAL_DIR = join(DATA_DIR, "memory-journal");
|
|
27
|
+
/** A diff is for reading in a list, not for replay; past this it is noise. */
|
|
28
|
+
export const DIFF_CAP = 8_000;
|
|
29
|
+
/** Past this the prior text is not kept, so the row cannot be reverted. Far
|
|
30
|
+
* above what the store lets anyone write (256KB); only a bot's own file
|
|
31
|
+
* tools can make a memory file this large. */
|
|
32
|
+
export const BEFORE_CAP = 512 * 1024;
|
|
33
|
+
const MAX_JOURNAL_READ_BYTES = 8 * 1024 * 1024;
|
|
34
|
+
const MAX_ROWS = 500;
|
|
35
|
+
/** Beyond this many line pairs the LCS table is not worth building; the
|
|
36
|
+
* diff degrades to "everything removed, everything added". */
|
|
37
|
+
const LCS_CELL_CAP = 4_000_000;
|
|
38
|
+
const CONTEXT = 2;
|
|
39
|
+
export function journalFile(botId) {
|
|
40
|
+
return join(JOURNAL_DIR, `${botId}.ndjson`);
|
|
41
|
+
}
|
|
42
|
+
function editScript(a, b) {
|
|
43
|
+
if (a.length * b.length > LCS_CELL_CAP) {
|
|
44
|
+
return [...a.map((line) => ({ tag: "-", line })), ...b.map((line) => ({ tag: "+", line }))];
|
|
45
|
+
}
|
|
46
|
+
// classic LCS lengths table, then walk back from the corner
|
|
47
|
+
const rows = a.length + 1;
|
|
48
|
+
const cols = b.length + 1;
|
|
49
|
+
const table = new Uint32Array(rows * cols);
|
|
50
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
51
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
52
|
+
table[i * cols + j] = a[i] === b[j]
|
|
53
|
+
? table[(i + 1) * cols + j + 1] + 1
|
|
54
|
+
: Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const ops = [];
|
|
58
|
+
let i = 0;
|
|
59
|
+
let j = 0;
|
|
60
|
+
while (i < a.length && j < b.length) {
|
|
61
|
+
if (a[i] === b[j]) {
|
|
62
|
+
ops.push({ tag: " ", line: a[i] });
|
|
63
|
+
i++;
|
|
64
|
+
j++;
|
|
65
|
+
}
|
|
66
|
+
else if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
|
|
67
|
+
ops.push({ tag: "-", line: a[i++] });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
ops.push({ tag: "+", line: b[j++] });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
while (i < a.length)
|
|
74
|
+
ops.push({ tag: "-", line: a[i++] });
|
|
75
|
+
while (j < b.length)
|
|
76
|
+
ops.push({ tag: "+", line: b[j++] });
|
|
77
|
+
return ops;
|
|
78
|
+
}
|
|
79
|
+
function splitLines(text) {
|
|
80
|
+
if (text === "")
|
|
81
|
+
return [];
|
|
82
|
+
const lines = text.split("\n");
|
|
83
|
+
// a trailing newline is not an extra empty line for diff purposes
|
|
84
|
+
if (lines[lines.length - 1] === "")
|
|
85
|
+
lines.pop();
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
/** A plain unified diff with two lines of context, the shape every
|
|
89
|
+
* developer tool renders. Exported so the row can be checked in tests. */
|
|
90
|
+
export function unifiedDiff(before, after, path, cap = DIFF_CAP) {
|
|
91
|
+
if (before === after)
|
|
92
|
+
return { text: "", added: 0, removed: 0, truncated: false };
|
|
93
|
+
const ops = editScript(splitLines(before), splitLines(after));
|
|
94
|
+
let added = 0;
|
|
95
|
+
let removed = 0;
|
|
96
|
+
for (const op of ops) {
|
|
97
|
+
if (op.tag === "+")
|
|
98
|
+
added++;
|
|
99
|
+
else if (op.tag === "-")
|
|
100
|
+
removed++;
|
|
101
|
+
}
|
|
102
|
+
// hunks: runs of changes with CONTEXT equal lines on either side; two
|
|
103
|
+
// runs whose context would touch or overlap share one hunk
|
|
104
|
+
const changes = [];
|
|
105
|
+
ops.forEach((op, k) => {
|
|
106
|
+
if (op.tag !== " ")
|
|
107
|
+
changes.push(k);
|
|
108
|
+
});
|
|
109
|
+
const hunks = [];
|
|
110
|
+
let index = 0;
|
|
111
|
+
while (index < changes.length) {
|
|
112
|
+
let lastChange = changes[index];
|
|
113
|
+
let next = index + 1;
|
|
114
|
+
while (next < changes.length && changes[next] - lastChange <= CONTEXT * 2 + 1)
|
|
115
|
+
lastChange = changes[next++];
|
|
116
|
+
hunks.push({ start: Math.max(0, changes[index] - CONTEXT), end: Math.min(ops.length - 1, lastChange + CONTEXT) });
|
|
117
|
+
index = next;
|
|
118
|
+
}
|
|
119
|
+
const lines = [`--- a/${path}`, `+++ b/${path}`];
|
|
120
|
+
let oldLine = 1;
|
|
121
|
+
let newLine = 1;
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
for (const hunk of hunks) {
|
|
124
|
+
for (; cursor < hunk.start; cursor++) {
|
|
125
|
+
if (ops[cursor].tag !== "+")
|
|
126
|
+
oldLine++;
|
|
127
|
+
if (ops[cursor].tag !== "-")
|
|
128
|
+
newLine++;
|
|
129
|
+
}
|
|
130
|
+
const slice = ops.slice(hunk.start, hunk.end + 1);
|
|
131
|
+
const oldCount = slice.filter((op) => op.tag !== "+").length;
|
|
132
|
+
const newCount = slice.filter((op) => op.tag !== "-").length;
|
|
133
|
+
// an empty side is conventionally anchored on the line before it
|
|
134
|
+
lines.push(`@@ -${oldCount ? oldLine : oldLine - 1},${oldCount} +${newCount ? newLine : newLine - 1},${newCount} @@`);
|
|
135
|
+
for (const op of slice)
|
|
136
|
+
lines.push(`${op.tag}${op.line}`);
|
|
137
|
+
for (; cursor <= hunk.end; cursor++) {
|
|
138
|
+
if (ops[cursor].tag !== "+")
|
|
139
|
+
oldLine++;
|
|
140
|
+
if (ops[cursor].tag !== "-")
|
|
141
|
+
newLine++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let text = lines.join("\n");
|
|
145
|
+
let truncated = false;
|
|
146
|
+
if (text.length > cap) {
|
|
147
|
+
text = `${text.slice(0, cap)}\n… (diff cut here)`;
|
|
148
|
+
truncated = true;
|
|
149
|
+
}
|
|
150
|
+
return { text, added, removed, truncated };
|
|
151
|
+
}
|
|
152
|
+
// ── the append-only file ──────────────────────────────────────────────
|
|
153
|
+
const writeQueues = new Map();
|
|
154
|
+
function enqueue(botId, entry) {
|
|
155
|
+
const previous = writeQueues.get(botId) ?? Promise.resolve();
|
|
156
|
+
const queued = previous
|
|
157
|
+
.then(async () => {
|
|
158
|
+
mkdirSync(JOURNAL_DIR, { recursive: true, mode: 0o700 });
|
|
159
|
+
await appendFile(journalFile(botId), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
160
|
+
})
|
|
161
|
+
.catch((error) => {
|
|
162
|
+
// fire-and-forget: the change this row records already happened
|
|
163
|
+
console.error(`memory-journal: could not append for ${botId}:`, error instanceof Error ? error.message : error);
|
|
164
|
+
});
|
|
165
|
+
writeQueues.set(botId, queued);
|
|
166
|
+
void queued.finally(() => {
|
|
167
|
+
if (writeQueues.get(botId) === queued)
|
|
168
|
+
writeQueues.delete(botId);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Test/route seam: a GET right after a write must see the row. */
|
|
172
|
+
export async function flushMemoryJournal(botId) {
|
|
173
|
+
await writeQueues.get(botId);
|
|
174
|
+
}
|
|
175
|
+
/** Shutdown seam. Each queued promise swallows its own error, so this
|
|
176
|
+
* never rejects either. */
|
|
177
|
+
export async function flushAllMemoryJournals() {
|
|
178
|
+
await Promise.allSettled(writeQueues.values());
|
|
179
|
+
}
|
|
180
|
+
function isEntry(value) {
|
|
181
|
+
if (typeof value !== "object" || value === null)
|
|
182
|
+
return false;
|
|
183
|
+
// SAFETY: shape probe on a parsed JSON line; the fields the panel keys on are checked below
|
|
184
|
+
const row = value;
|
|
185
|
+
return typeof row.id === "string" && typeof row.at === "number" && typeof row.path === "string" && typeof row.kind === "string";
|
|
186
|
+
}
|
|
187
|
+
/** Newest first, bounded to MAX_ROWS from the newest 8 MiB of the file, so
|
|
188
|
+
* an old bot with a long history does not make the panel load all of it. */
|
|
189
|
+
export function readMemoryJournal(botId, limit = 50) {
|
|
190
|
+
let text;
|
|
191
|
+
let offset;
|
|
192
|
+
try {
|
|
193
|
+
const fd = openSync(journalFile(botId), "r");
|
|
194
|
+
try {
|
|
195
|
+
const size = fstatSync(fd).size;
|
|
196
|
+
offset = Math.max(0, size - MAX_JOURNAL_READ_BYTES);
|
|
197
|
+
const buffer = Buffer.alloc(size - offset);
|
|
198
|
+
let length = 0;
|
|
199
|
+
while (length < buffer.length) {
|
|
200
|
+
const count = readSync(fd, buffer, length, buffer.length - length, offset + length);
|
|
201
|
+
if (!count)
|
|
202
|
+
break;
|
|
203
|
+
length += count;
|
|
204
|
+
}
|
|
205
|
+
text = buffer.subarray(0, length).toString("utf8");
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
closeSync(fd);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const rows = [];
|
|
215
|
+
const lines = text.split("\n");
|
|
216
|
+
const maxRows = Math.min(MAX_ROWS, Math.max(1, limit));
|
|
217
|
+
// when the window starts mid-file its first line is a fragment: skip it
|
|
218
|
+
for (let index = lines.length - 1; index >= (offset > 0 ? 1 : 0) && rows.length < maxRows; index--) {
|
|
219
|
+
const line = lines[index];
|
|
220
|
+
if (!line)
|
|
221
|
+
continue;
|
|
222
|
+
try {
|
|
223
|
+
const value = JSON.parse(line);
|
|
224
|
+
if (isEntry(value))
|
|
225
|
+
rows.push(value);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// a torn final line from a crash mid-append is skipped, not fatal
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
/** The last text this module saw for each memory file of each bot. What
|
|
234
|
+
* the turn-boundary diff compares against, and what a route write
|
|
235
|
+
* advances — so a person's save between two turns is never re-journaled
|
|
236
|
+
* as the bot's work when the turn ends. */
|
|
237
|
+
const baselines = new Map();
|
|
238
|
+
function baselineFor(botId) {
|
|
239
|
+
let baseline = baselines.get(botId);
|
|
240
|
+
if (!baseline) {
|
|
241
|
+
baseline = new Map();
|
|
242
|
+
baselines.set(botId, baseline);
|
|
243
|
+
}
|
|
244
|
+
return baseline;
|
|
245
|
+
}
|
|
246
|
+
function advanceBaseline(botId, path, after) {
|
|
247
|
+
const baseline = baselineFor(botId);
|
|
248
|
+
if (after === null)
|
|
249
|
+
baseline.delete(path);
|
|
250
|
+
else
|
|
251
|
+
baseline.set(path, after);
|
|
252
|
+
}
|
|
253
|
+
/** Builds and queues one row. Never throws: hashing and diffing are pure,
|
|
254
|
+
* and the append is queued behind a catch. Returns the row so a route can
|
|
255
|
+
* answer with it. */
|
|
256
|
+
export function recordMemoryChange(botId, change) {
|
|
257
|
+
const { before, after } = change;
|
|
258
|
+
if (before === after)
|
|
259
|
+
return null;
|
|
260
|
+
advanceBaseline(botId, change.path, after);
|
|
261
|
+
const kind = before === null ? "created" : after === null ? "deleted" : "edited";
|
|
262
|
+
const diff = unifiedDiff(before ?? "", after ?? "", change.path);
|
|
263
|
+
let keptBefore = null;
|
|
264
|
+
let canRevert = true;
|
|
265
|
+
let revertUnavailableReason;
|
|
266
|
+
if (before !== null) {
|
|
267
|
+
if (Buffer.byteLength(before, "utf8") > BEFORE_CAP) {
|
|
268
|
+
canRevert = false;
|
|
269
|
+
revertUnavailableReason = "The earlier version was too large to keep, so this change cannot be reverted.";
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
keptBefore = redactSecretsInText(before);
|
|
273
|
+
if (keptBefore !== before) {
|
|
274
|
+
// a redacted body is not the body that was there; restoring it
|
|
275
|
+
// would write our marker into the bot's memory as if it were text
|
|
276
|
+
canRevert = false;
|
|
277
|
+
revertUnavailableReason = "The earlier version contained sensitive text that was hidden, so it cannot be restored exactly.";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const entry = {
|
|
282
|
+
id: randomUUID(),
|
|
283
|
+
at: Date.now(),
|
|
284
|
+
botId,
|
|
285
|
+
path: change.path,
|
|
286
|
+
actor: change.actor,
|
|
287
|
+
via: change.via,
|
|
288
|
+
kind,
|
|
289
|
+
beforeHash: before === null ? null : hashMemoryText(before),
|
|
290
|
+
afterHash: after === null ? null : hashMemoryText(after),
|
|
291
|
+
before: keptBefore,
|
|
292
|
+
diff: redactSecretsInText(diff.text),
|
|
293
|
+
added: diff.added,
|
|
294
|
+
removed: diff.removed,
|
|
295
|
+
canRevert,
|
|
296
|
+
};
|
|
297
|
+
if (change.threadId)
|
|
298
|
+
entry.threadId = change.threadId;
|
|
299
|
+
if (revertUnavailableReason)
|
|
300
|
+
entry.revertUnavailableReason = revertUnavailableReason;
|
|
301
|
+
enqueue(botId, entry);
|
|
302
|
+
return entry;
|
|
303
|
+
}
|
|
304
|
+
/** The write every route uses: store first (it may refuse — 409, 413,
|
|
305
|
+
* containment), journal second. */
|
|
306
|
+
export function journalMemoryWrite(botId, path, text, opts) {
|
|
307
|
+
const result = writeMemoryDoc(botId, path, text, { expectedHash: opts.expectedHash });
|
|
308
|
+
const entry = recordMemoryChange(botId, {
|
|
309
|
+
path,
|
|
310
|
+
actor: opts.actor,
|
|
311
|
+
via: opts.via,
|
|
312
|
+
threadId: opts.threadId,
|
|
313
|
+
before: result.before,
|
|
314
|
+
after: result.after,
|
|
315
|
+
});
|
|
316
|
+
return { doc: { path, text: result.after, hash: result.hash, exists: true }, entry };
|
|
317
|
+
}
|
|
318
|
+
export function journalMemoryDelete(botId, path, opts) {
|
|
319
|
+
const result = deleteMemoryDoc(botId, path);
|
|
320
|
+
return recordMemoryChange(botId, { path, actor: opts.actor, via: opts.via, threadId: opts.threadId, before: result.before, after: null });
|
|
321
|
+
}
|
|
322
|
+
/** Puts the file back to the text the row recorded and journals that as
|
|
323
|
+
* its own change, so a revert is itself revertible. Unconditional on
|
|
324
|
+
* purpose: reverting an older row over newer ones is the person's call,
|
|
325
|
+
* and the row this writes carries the newer text should they change their
|
|
326
|
+
* mind. */
|
|
327
|
+
export function revertMemoryChange(botId, entryId, opts = {}) {
|
|
328
|
+
const entry = readMemoryJournal(botId, MAX_ROWS).find((row) => row.id === entryId);
|
|
329
|
+
if (!entry)
|
|
330
|
+
return { ok: false, status: 404, error: "no such memory change" };
|
|
331
|
+
if (!entry.canRevert)
|
|
332
|
+
return { ok: false, status: 400, error: entry.revertUnavailableReason ?? "this change cannot be reverted" };
|
|
333
|
+
try {
|
|
334
|
+
if (entry.kind === "created") {
|
|
335
|
+
// MEMORY.md is never deleted (the store refuses); emptying it is the
|
|
336
|
+
// same end state for the bot
|
|
337
|
+
if (entry.path === MEMORY_INDEX) {
|
|
338
|
+
const written = journalMemoryWrite(botId, entry.path, "", { actor: "person", via: "revert", threadId: opts.threadId });
|
|
339
|
+
return { ok: true, entry: written.entry, doc: written.doc };
|
|
340
|
+
}
|
|
341
|
+
const removed = journalMemoryDelete(botId, entry.path, { actor: "person", via: "revert", threadId: opts.threadId });
|
|
342
|
+
return { ok: true, entry: removed, doc: { path: entry.path, text: "", hash: hashMemoryText(""), exists: false } };
|
|
343
|
+
}
|
|
344
|
+
const written = journalMemoryWrite(botId, entry.path, entry.before ?? "", { actor: "person", via: "revert", threadId: opts.threadId });
|
|
345
|
+
return { ok: true, entry: written.entry, doc: written.doc };
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error instanceof MemoryStoreError)
|
|
349
|
+
return { ok: false, status: error.status, error: error.message };
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ── the turn boundary ─────────────────────────────────────────────────
|
|
354
|
+
/** Bots with a turn in flight on each thread. A set, not one id: a room
|
|
355
|
+
* thread can have more than one member speaking on it. */
|
|
356
|
+
const turnBots = new Map();
|
|
357
|
+
function currentDocs(botId) {
|
|
358
|
+
const docs = new Map();
|
|
359
|
+
const overview = memoryOverview(botId);
|
|
360
|
+
for (const path of [MEMORY_INDEX, ...overview.topics.map((t) => t.path), ...overview.logs.map((l) => l.path)]) {
|
|
361
|
+
const doc = readMemoryDoc(botId, path);
|
|
362
|
+
if (doc.exists)
|
|
363
|
+
docs.set(path, doc.text);
|
|
364
|
+
}
|
|
365
|
+
return docs;
|
|
366
|
+
}
|
|
367
|
+
/** Everything on disk that differs from the baseline becomes a row under
|
|
368
|
+
* `actor`/`via`, and the baseline moves up to match. A path the baseline
|
|
369
|
+
* has never seen is a first sighting at turn start (the server may just
|
|
370
|
+
* have booted; what is already there is nobody's change now) and a
|
|
371
|
+
* creation at turn end (begin seeded everything, so anything new since
|
|
372
|
+
* is the turn's) — `unknownIsNew` says which. */
|
|
373
|
+
function reconcile(botId, actor, via, threadId, unknownIsNew) {
|
|
374
|
+
const baseline = baselineFor(botId);
|
|
375
|
+
const docs = currentDocs(botId);
|
|
376
|
+
const rows = [];
|
|
377
|
+
for (const [path, text] of docs) {
|
|
378
|
+
const known = baseline.get(path);
|
|
379
|
+
if (known === text)
|
|
380
|
+
continue;
|
|
381
|
+
if (known === undefined && !unknownIsNew) {
|
|
382
|
+
baseline.set(path, text);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const row = recordMemoryChange(botId, { path, actor, via, threadId, before: known ?? null, after: text });
|
|
386
|
+
if (row)
|
|
387
|
+
rows.push(row);
|
|
388
|
+
}
|
|
389
|
+
// deleting the current key mid-iteration is defined for a Map
|
|
390
|
+
for (const path of baseline.keys()) {
|
|
391
|
+
if (docs.has(path))
|
|
392
|
+
continue;
|
|
393
|
+
const row = recordMemoryChange(botId, { path, actor, via, threadId, before: baseline.get(path) ?? null, after: null });
|
|
394
|
+
if (row)
|
|
395
|
+
rows.push(row);
|
|
396
|
+
}
|
|
397
|
+
return rows;
|
|
398
|
+
}
|
|
399
|
+
/** Call at dispatch, after ensureWorkspace. A file seen for the first
|
|
400
|
+
* time seeds the baseline silently; a file that changed since the last
|
|
401
|
+
* look was edited outside the app between turns — an editor, Obsidian, a
|
|
402
|
+
* sync client — and is journaled as the person's, via "disk". */
|
|
403
|
+
export function beginMemoryTurn(botId, threadId) {
|
|
404
|
+
try {
|
|
405
|
+
let bots = turnBots.get(threadId);
|
|
406
|
+
if (!bots) {
|
|
407
|
+
bots = new Set();
|
|
408
|
+
turnBots.set(threadId, bots);
|
|
409
|
+
}
|
|
410
|
+
bots.add(botId);
|
|
411
|
+
reconcile(botId, "person", "disk", undefined, false);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
// a hashing or read failure must not stop the turn from dispatching
|
|
415
|
+
console.error(`memory-journal: could not snapshot ${botId}:`, error instanceof Error ? error.message : error);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** Call at turn.completed (and session.exited — a turn that died may still
|
|
419
|
+
* have written). Whatever moved since the baseline is the bot's work. */
|
|
420
|
+
export function endMemoryTurn(threadId) {
|
|
421
|
+
const bots = turnBots.get(threadId);
|
|
422
|
+
if (!bots)
|
|
423
|
+
return [];
|
|
424
|
+
turnBots.delete(threadId);
|
|
425
|
+
const rows = [];
|
|
426
|
+
for (const botId of bots) {
|
|
427
|
+
try {
|
|
428
|
+
rows.push(...reconcile(botId, "bot", "turn", threadId, true));
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
console.error(`memory-journal: could not diff ${botId}:`, error instanceof Error ? error.message : error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return rows;
|
|
435
|
+
}
|
|
436
|
+
/** Test seam: forget every baseline and in-flight turn. */
|
|
437
|
+
export function resetMemoryJournalState() {
|
|
438
|
+
baselines.clear();
|
|
439
|
+
turnBots.clear();
|
|
440
|
+
}
|