@nathapp/nax-agent 0.91.7 → 0.91.8-canary.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/CHANGELOG.md +8 -0
- package/dist/config/catalog-overrides.d.ts +13 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal/atomic-write.d.ts +22 -7
- package/dist/internal/atomic-write.js +107 -12
- package/dist/internal/pinned-directory-worker.d.ts +52 -0
- package/dist/internal/pinned-directory-worker.js +105 -0
- package/dist/internal/pinned-directory.d.ts +48 -0
- package/dist/internal/pinned-directory.js +168 -0
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +2 -0
- package/dist/memory/delivery.d.ts +38 -0
- package/dist/memory/delivery.js +28 -0
- package/dist/memory/index-render.d.ts +55 -0
- package/dist/memory/index-render.js +225 -0
- package/dist/memory/index.d.ts +12 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/local-provider.d.ts +3 -0
- package/dist/memory/local-provider.js +127 -0
- package/dist/memory/mirror-lock.d.ts +109 -0
- package/dist/memory/mirror-lock.js +334 -0
- package/dist/memory/note.d.ts +15 -0
- package/dist/memory/note.js +131 -0
- package/dist/memory/operations.d.ts +123 -0
- package/dist/memory/operations.js +471 -0
- package/dist/memory/outbox.d.ts +35 -0
- package/dist/memory/outbox.js +289 -0
- package/dist/memory/provider-types.d.ts +50 -0
- package/dist/memory/provider-types.js +1 -0
- package/dist/memory/records.d.ts +41 -0
- package/dist/memory/records.js +93 -0
- package/dist/memory/refusal.d.ts +36 -0
- package/dist/memory/refusal.js +25 -0
- package/dist/memory/search.d.ts +17 -0
- package/dist/memory/search.js +112 -0
- package/dist/memory/store.d.ts +106 -0
- package/dist/memory/store.js +459 -0
- package/dist/memory/support.d.ts +63 -0
- package/dist/memory/support.js +98 -0
- package/dist/memory/sync-flight.d.ts +25 -0
- package/dist/memory/sync-flight.js +65 -0
- package/dist/memory/sync.d.ts +82 -0
- package/dist/memory/sync.js +344 -0
- package/dist/memory/types.d.ts +108 -0
- package/dist/memory/types.js +51 -0
- package/dist/memory/validate.d.ts +31 -0
- package/dist/memory/validate.js +104 -0
- package/dist/native/adapter-deps.d.ts +9 -0
- package/dist/native/adapter-deps.js +9 -0
- package/dist/native/models.js +3 -0
- package/dist/native/session/compaction.d.ts +15 -1
- package/dist/native/session/compaction.js +90 -23
- package/dist/native/session/manual-compaction.d.ts +57 -0
- package/dist/native/session/manual-compaction.js +72 -0
- package/dist/native/session/session-summarizer.d.ts +75 -0
- package/dist/native/session/session-summarizer.js +73 -0
- package/dist/native/session/transcript-identity.d.ts +12 -0
- package/dist/native/session/transcript-identity.js +16 -0
- package/dist/native/session/turn-compaction-step.d.ts +30 -2
- package/dist/native/session/turn-compaction-step.js +7 -1
- package/dist/native/session/turn-loop-round-trip.js +71 -17
- package/dist/native/session/turn-loop.js +2 -6
- package/dist/native/session-adapter.d.ts +19 -0
- package/dist/native/session-adapter.js +42 -64
- package/dist/sandbox/srt-backend.js +9 -16
- package/dist/session/agent-session-options.js +1 -0
- package/dist/session/agent-session-turn.d.ts +6 -0
- package/dist/session/agent-session-turn.js +5 -4
- package/dist/session/agent-session-types.d.ts +38 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +163 -20
- package/dist/session/native-backend.js +8 -0
- package/dist/session/session-backend.d.ts +19 -1
- package/dist/session/session-types.d.ts +6 -0
- package/dist/session/steer-channel.d.ts +36 -0
- package/dist/session/steer-channel.js +39 -0
- package/dist/session-store/index.d.ts +1 -1
- package/dist/session-store/index.js +1 -1
- package/dist/session-store/session-lock.d.ts +2 -2
- package/dist/session-store/session-lock.js +4 -2
- package/dist/tools/ask-request.d.ts +8 -0
- package/dist/tools/ask-request.js +34 -1
- package/dist/tools/provider-adapt.js +5 -1
- package/dist/tools/provider-types.d.ts +7 -0
- package/dist/tools/types.d.ts +16 -0
- package/package.json +4 -4
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbox: the writes the agent has taken but a provider has not
|
|
3
|
+
* acknowledged, held on disk until a sync delivers them.
|
|
4
|
+
*
|
|
5
|
+
* One JSON file per entry, `<seq padded to 8 digits>-<clientId>.json`, written
|
|
6
|
+
* whole through `writeFileAtomic`: the bytes land in a sibling `.tmp` stage and
|
|
7
|
+
* are renamed over the entry name, so a crash leaves either the old entry or
|
|
8
|
+
* none, never a torn one, and a reader never sees a half-written file. `seq`
|
|
9
|
+
* numbers the entries in the order they were taken. It is assigned inside a
|
|
10
|
+
* promise chain local to this instance, so two concurrent `enqueue` calls on
|
|
11
|
+
* one outbox get distinct sequences; a second process writing the same
|
|
12
|
+
* directory is outside that chain and may hand out an equal sequence, which is
|
|
13
|
+
* why `list` breaks a tie by client id — the order stays deterministic rather
|
|
14
|
+
* than globally correct.
|
|
15
|
+
*
|
|
16
|
+
* A file whose name matches the entry pattern but whose content is not an entry
|
|
17
|
+
* is never silently dropped: `list` leaves it out and `listInvalid` names it, so
|
|
18
|
+
* a caller can report what it could not read. A rejected entry stays in the
|
|
19
|
+
* outbox (`state: "rejected"`) instead of being deleted — the reason is the
|
|
20
|
+
* record of why the provider refused it, and a later CLI may requeue it.
|
|
21
|
+
*
|
|
22
|
+
* The one length rule an entry carries — a reason is at most
|
|
23
|
+
* `MEMORY_FORGET_REASON_MAX_CHARS` — is enforced where an entry is *taken*
|
|
24
|
+
* (`enqueue`), never where it is read. An entry that fails a read check does not
|
|
25
|
+
* get reported: it leaves `list`, `heldKeys` and every count built on them, and
|
|
26
|
+
* the write it holds is never replayed. A read check would therefore retire the
|
|
27
|
+
* entries written before it existed, so the gate refuses the request instead, and
|
|
28
|
+
* a reason that is already on disk still lists and still replays.
|
|
29
|
+
*
|
|
30
|
+
* Reads, directory creation and removal go through the mirror store's
|
|
31
|
+
* filesystem seam, `_memoryStoreDeps`; the write path is `writeFileAtomic`, whose
|
|
32
|
+
* own seam is `_atomicWriteDeps`. Temp names are never spelled here.
|
|
33
|
+
*/
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import { constants } from "node:fs";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
39
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
40
|
+
import { byCodePoint, byNumber } from "#src/internal/sort";
|
|
41
|
+
import { parseMemoryNoteRecord } from "./note.js";
|
|
42
|
+
import { _memoryStoreDeps } from "./store.js";
|
|
43
|
+
import { MEMORY_CLIENT_ID_RE, MEMORY_SCOPES, memoryNoteKey } from "./types.js";
|
|
44
|
+
import { forgetReasonRefusal } from "./validate.js";
|
|
45
|
+
const ERROR_STAGE = "memory";
|
|
46
|
+
/** An entry file's name: the sequence, the client id that keys it, then the suffix. */
|
|
47
|
+
const ENTRY_FILE_RE = /^(\d{8})-([A-Za-z0-9_-]+)\.json$/;
|
|
48
|
+
const ENTRY_SEQ_DIGITS = 8;
|
|
49
|
+
const ENTRY_SUFFIX = ".json";
|
|
50
|
+
/** A link at an entry name is not an entry this outbox wrote, so reads never follow one. */
|
|
51
|
+
const READ_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
52
|
+
/**
|
|
53
|
+
* Every field an entry file has to carry to be replayed; the note is checked separately.
|
|
54
|
+
*
|
|
55
|
+
* `reason` is read unbounded on purpose. A queued entry's reason is the forget's own
|
|
56
|
+
* justification and a rejected one's is the provider's answer; the length a queued reason may
|
|
57
|
+
* have is enforced where an entry is taken (`enqueue`), not where it is read — see this file's
|
|
58
|
+
* header. A read check would turn every entry written before that gate into an invalid one.
|
|
59
|
+
*/
|
|
60
|
+
const EntrySchema = z.object({
|
|
61
|
+
kind: z.enum(["save", "forget"]),
|
|
62
|
+
clientId: z.string().regex(MEMORY_CLIENT_ID_RE),
|
|
63
|
+
scope: z.enum(MEMORY_SCOPES),
|
|
64
|
+
slug: z.string().min(1),
|
|
65
|
+
writeMode: z.enum(["direct", "proposal"]),
|
|
66
|
+
note: z.unknown().optional(),
|
|
67
|
+
reason: z.string().optional(),
|
|
68
|
+
requestReason: z.string().optional(),
|
|
69
|
+
seq: z.number().int().positive(),
|
|
70
|
+
enqueuedAt: z.string().min(1),
|
|
71
|
+
state: z.enum(["queued", "rejected"]),
|
|
72
|
+
});
|
|
73
|
+
/** A path that is not there: the outbox has no entries, rather than an error to report. */
|
|
74
|
+
function isMissing(error) {
|
|
75
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
76
|
+
return false;
|
|
77
|
+
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
|
78
|
+
}
|
|
79
|
+
function clientIdInvalid(clientId) {
|
|
80
|
+
return new NaxError(`memory outbox client id "${clientId}" is not a valid client id`, "MEMORY_CLIENT_ID_INVALID", {
|
|
81
|
+
stage: ERROR_STAGE,
|
|
82
|
+
clientId,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** The refusal a request the queue cannot carry is thrown as, in the store's own vocabulary. */
|
|
86
|
+
function writeRefused(refusal) {
|
|
87
|
+
return new NaxError(refusal.reason, "MEMORY_WRITE_REFUSED", { stage: ERROR_STAGE, field: refusal.field });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Refuse a request whose reason the queue could not carry, before any entry file exists.
|
|
91
|
+
*
|
|
92
|
+
* `forgetMemoryNote` already applies this rule to a forget, so this is the gate every caller
|
|
93
|
+
* of the outbox passes rather than a second, weaker copy of it: a reason that is empty, holds
|
|
94
|
+
* a line break or runs past `MEMORY_FORGET_REASON_MAX_CHARS` is refused here and nothing is
|
|
95
|
+
* written. The bound belongs on this side — the write — because a bound applied on the read
|
|
96
|
+
* side retires the entries that predate it: they leave `list` and `heldKeys` with no word,
|
|
97
|
+
* and the writes they hold are never replayed.
|
|
98
|
+
*/
|
|
99
|
+
function assertReasonCarries(request) {
|
|
100
|
+
if (request.reason === undefined)
|
|
101
|
+
return;
|
|
102
|
+
const refusal = forgetReasonRefusal(request.reason);
|
|
103
|
+
if (refusal !== null)
|
|
104
|
+
throw writeRefused(refusal);
|
|
105
|
+
}
|
|
106
|
+
/** An entry's file name; the sequence is padded so a plain name sort is a sequence sort. */
|
|
107
|
+
function entryFileName(seq, clientId) {
|
|
108
|
+
return `${String(seq).padStart(ENTRY_SEQ_DIGITS, "0")}-${clientId}${ENTRY_SUFFIX}`;
|
|
109
|
+
}
|
|
110
|
+
/** The sequence and client id an entry file's name carries, or `null` when it is not one. */
|
|
111
|
+
function entryFileNamed(name) {
|
|
112
|
+
const match = ENTRY_FILE_RE.exec(name);
|
|
113
|
+
if (match === null)
|
|
114
|
+
return null;
|
|
115
|
+
return { name, seq: Number(match[1]), clientId: match[2] };
|
|
116
|
+
}
|
|
117
|
+
/** Entries in sequence order, with equal sequences ordered by client id. */
|
|
118
|
+
function byEntryOrder(a, b) {
|
|
119
|
+
return byNumber(a.seq, b.seq) || byCodePoint(a.clientId, b.clientId);
|
|
120
|
+
}
|
|
121
|
+
/** One decoded JSON value, or `null` when the text is not JSON. */
|
|
122
|
+
function parseJson(text) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Not JSON: the caller files the file name under listInvalid.
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** One file's text as an entry, or `null` when its content is not an entry. */
|
|
132
|
+
function toEntry(text) {
|
|
133
|
+
const parsed = EntrySchema.safeParse(parseJson(text));
|
|
134
|
+
if (!parsed.success)
|
|
135
|
+
return null;
|
|
136
|
+
const { note, ...rest } = parsed.data;
|
|
137
|
+
if (note === undefined)
|
|
138
|
+
return rest;
|
|
139
|
+
try {
|
|
140
|
+
return { ...rest, note: parseMemoryNoteRecord(note) };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// A note the note schema refuses could never be replayed: the entry is not readable.
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One entry as a rejection: `reason` carries the rejection, which is what the caller
|
|
149
|
+
* reads to decide whether to requeue. The reason the request itself carried — a
|
|
150
|
+
* forget's justification — is not lost to it; it moves to `requestReason`. Re-marking
|
|
151
|
+
* an already rejected entry replaces the rejection reason and keeps the original.
|
|
152
|
+
*/
|
|
153
|
+
function toRejected(entry, rejectionReason) {
|
|
154
|
+
const { reason: currentReason, requestReason: keptReason, ...rest } = entry;
|
|
155
|
+
const requestReason = keptReason ?? (entry.state === "queued" ? currentReason : undefined);
|
|
156
|
+
return {
|
|
157
|
+
...rest,
|
|
158
|
+
...(requestReason === undefined ? {} : { requestReason }),
|
|
159
|
+
state: "rejected",
|
|
160
|
+
reason: rejectionReason,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function createMemoryOutbox(input) {
|
|
164
|
+
const { dir } = input;
|
|
165
|
+
const now = input.now ?? (() => new Date());
|
|
166
|
+
/** The tail of the enqueue chain: the one place a sequence is handed out. */
|
|
167
|
+
let chain = Promise.resolve();
|
|
168
|
+
/** Every name in the directory, whatever it holds; a missing directory holds nothing. */
|
|
169
|
+
async function entryNames() {
|
|
170
|
+
try {
|
|
171
|
+
const dirents = await _memoryStoreDeps.readdir(dir, { withFileTypes: true });
|
|
172
|
+
return dirents.map((entry) => entry.name);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (isMissing(error))
|
|
176
|
+
return [];
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Every entry file in the directory, in name order: the only names this outbox reads. */
|
|
181
|
+
async function entryFiles() {
|
|
182
|
+
return (await entryNames())
|
|
183
|
+
.map((name) => entryFileNamed(name))
|
|
184
|
+
.filter((file) => file !== null)
|
|
185
|
+
.sort((a, b) => byNumber(a.seq, b.seq) || byCodePoint(a.clientId, b.clientId));
|
|
186
|
+
}
|
|
187
|
+
/** One entry file's text; `null` when it cannot be read, which makes it an invalid entry. */
|
|
188
|
+
async function readEntryText(path) {
|
|
189
|
+
try {
|
|
190
|
+
const file = await _memoryStoreDeps.open(path, READ_FLAGS);
|
|
191
|
+
try {
|
|
192
|
+
return await file.readText();
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await file.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// A file that vanished or cannot be read is not an entry the caller can act on.
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** One named file as an entry, or `null` when its content is not one. */
|
|
204
|
+
async function readEntry(file) {
|
|
205
|
+
const text = await readEntryText(join(dir, file.name));
|
|
206
|
+
return text === null ? null : toEntry(text);
|
|
207
|
+
}
|
|
208
|
+
/** The whole directory: readable entries in sequence order, plus the names that are not. */
|
|
209
|
+
async function scan() {
|
|
210
|
+
const entries = [];
|
|
211
|
+
const invalid = [];
|
|
212
|
+
for (const file of await entryFiles()) {
|
|
213
|
+
const entry = await readEntry(file);
|
|
214
|
+
if (entry === null)
|
|
215
|
+
invalid.push(file.name);
|
|
216
|
+
else
|
|
217
|
+
entries.push(entry);
|
|
218
|
+
}
|
|
219
|
+
entries.sort(byEntryOrder);
|
|
220
|
+
return { entries, invalid };
|
|
221
|
+
}
|
|
222
|
+
/** The entry files naming one client id: found by name, so an unreadable entry is removable. */
|
|
223
|
+
async function filesFor(clientId) {
|
|
224
|
+
return (await entryFiles()).filter((file) => file.clientId === clientId);
|
|
225
|
+
}
|
|
226
|
+
/** The request with its client id settled: a supplied id is kept, a missing one is minted. */
|
|
227
|
+
function withClientId(request) {
|
|
228
|
+
const clientId = request.clientId ?? randomUUID();
|
|
229
|
+
if (!MEMORY_CLIENT_ID_RE.test(clientId))
|
|
230
|
+
throw clientIdInvalid(clientId);
|
|
231
|
+
return { ...request, clientId };
|
|
232
|
+
}
|
|
233
|
+
/** The highest sequence the directory already carries: 0 when it holds no entry. */
|
|
234
|
+
async function highestSeq() {
|
|
235
|
+
const files = await entryFiles();
|
|
236
|
+
return files.reduce((highest, file) => Math.max(highest, file.seq), 0);
|
|
237
|
+
}
|
|
238
|
+
/** Write one entry file, creating the directory only now that there is something to store. */
|
|
239
|
+
async function writeEntry(request) {
|
|
240
|
+
const seq = (await highestSeq()) + 1;
|
|
241
|
+
const entry = { ...request, seq, enqueuedAt: now().toISOString(), state: "queued" };
|
|
242
|
+
await _memoryStoreDeps.mkdir(dir, { recursive: true });
|
|
243
|
+
await writeFileAtomic(join(dir, entryFileName(seq, request.clientId)), `${JSON.stringify(entry)}\n`);
|
|
244
|
+
return entry;
|
|
245
|
+
}
|
|
246
|
+
async function enqueue(request) {
|
|
247
|
+
// The id and the reason are settled before joining the chain: a refused request must not
|
|
248
|
+
// delay the next one, and a reason the queue cannot carry writes no entry at all.
|
|
249
|
+
const prepared = withClientId(request);
|
|
250
|
+
assertReasonCarries(prepared);
|
|
251
|
+
const run = chain.then(() => writeEntry(prepared));
|
|
252
|
+
// The chain never carries a rejection forward: one failed enqueue must not poison the next.
|
|
253
|
+
chain = run.then(() => undefined, () => undefined);
|
|
254
|
+
return await run;
|
|
255
|
+
}
|
|
256
|
+
async function list() {
|
|
257
|
+
return (await scan()).entries;
|
|
258
|
+
}
|
|
259
|
+
async function listInvalid() {
|
|
260
|
+
return (await scan()).invalid;
|
|
261
|
+
}
|
|
262
|
+
async function remove(clientId) {
|
|
263
|
+
for (const file of await filesFor(clientId)) {
|
|
264
|
+
await _memoryStoreDeps.rm(join(dir, file.name), { force: true });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function markRejected(clientId, reason) {
|
|
268
|
+
for (const file of await filesFor(clientId)) {
|
|
269
|
+
const entry = await readEntry(file);
|
|
270
|
+
// An entry that cannot be read cannot be rewritten either; it stays for listInvalid.
|
|
271
|
+
if (entry === null)
|
|
272
|
+
continue;
|
|
273
|
+
const rejected = toRejected(entry, reason);
|
|
274
|
+
await writeFileAtomic(join(dir, file.name), `${JSON.stringify(rejected)}\n`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function heldKeys(kind) {
|
|
278
|
+
const keys = new Set();
|
|
279
|
+
for (const entry of (await scan()).entries) {
|
|
280
|
+
if (entry.state !== "queued")
|
|
281
|
+
continue;
|
|
282
|
+
if (kind !== undefined && entry.kind !== kind)
|
|
283
|
+
continue;
|
|
284
|
+
keys.add(memoryNoteKey(entry.scope, entry.slug));
|
|
285
|
+
}
|
|
286
|
+
return keys;
|
|
287
|
+
}
|
|
288
|
+
return { enqueue, list, listInvalid, remove, markRejected, heldKeys };
|
|
289
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider seam: the one interface the sync loop, the outbox drain and the
|
|
3
|
+
* save / forget operations talk to. A provider is any object that can pull a
|
|
4
|
+
* page of notes, push one write and say whether it is reachable; `search` is
|
|
5
|
+
* optional because the mirror answers keyword search locally
|
|
6
|
+
* (`searchMemoryNotes`), so only a provider with a remote search index needs it.
|
|
7
|
+
*
|
|
8
|
+
* A provider refuses a write it will never accept by throwing `NaxError` code
|
|
9
|
+
* `MEMORY_PUSH_REJECTED` with the reason as its message; `isMemoryPushRejected`
|
|
10
|
+
* recognises it. Every other throw, and a timeout, is transient and retryable.
|
|
11
|
+
*/
|
|
12
|
+
import type { MemoryDecision, MemoryHit, MemoryNote, MemoryNoteStatus, MemoryScope } from "./types.ts";
|
|
13
|
+
/** One write to hand a provider: a note to store, or a slug to forget (a tombstone). */
|
|
14
|
+
export interface MemoryWriteRequest {
|
|
15
|
+
readonly kind: "save" | "forget";
|
|
16
|
+
readonly clientId: string;
|
|
17
|
+
readonly scope: MemoryScope;
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
readonly writeMode: "direct" | "proposal";
|
|
20
|
+
readonly note?: MemoryNote;
|
|
21
|
+
readonly reason?: string;
|
|
22
|
+
}
|
|
23
|
+
/** What the provider made of a write: the note id (or the client id) and its status. */
|
|
24
|
+
export interface MemoryPushResult {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly status: MemoryNoteStatus;
|
|
27
|
+
}
|
|
28
|
+
/** One page of a pull; `done` is only true on the last page. */
|
|
29
|
+
export interface MemoryPage {
|
|
30
|
+
readonly notes: readonly MemoryNote[];
|
|
31
|
+
readonly decisions?: readonly MemoryDecision[];
|
|
32
|
+
readonly nextCursor?: string;
|
|
33
|
+
readonly done: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** The injectable provider seam; `local` is the built-in, always-reachable one. */
|
|
36
|
+
export interface MemoryProvider {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
pull(input: {
|
|
39
|
+
scope: MemoryScope;
|
|
40
|
+
cursor: string | null;
|
|
41
|
+
take: number;
|
|
42
|
+
}): Promise<MemoryPage>;
|
|
43
|
+
push(request: MemoryWriteRequest): Promise<MemoryPushResult>;
|
|
44
|
+
search?(input: {
|
|
45
|
+
scope: MemoryScope;
|
|
46
|
+
text: string;
|
|
47
|
+
limit: number;
|
|
48
|
+
}): Promise<readonly MemoryHit[]>;
|
|
49
|
+
reachable(): Promise<boolean>;
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record files the mirror keeps beside its notes, decoded.
|
|
3
|
+
*
|
|
4
|
+
* `decisions.jsonl` is one `MemoryDecision` per line, `pending-forgets.jsonl` one
|
|
5
|
+
* `MemoryPendingForget` per line, and `sync.json` is one `MemorySyncState`: all three are
|
|
6
|
+
* written whole by this store and read back by it, so what makes a line a decision — or
|
|
7
|
+
* a pending forget, or what makes a file a state — is a schema rather than a cast.
|
|
8
|
+
* Keeping the schemas here leaves the store to decide what a refused line *means*
|
|
9
|
+
* (skipped with a warning) and this module to decide what counts as one.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { type MemoryDecision, type MemoryPendingForget } from "./types.ts";
|
|
13
|
+
declare const SyncStateSchema: z.ZodObject<{
|
|
14
|
+
provider: z.ZodNullable<z.ZodString>;
|
|
15
|
+
cursor: z.ZodObject<{
|
|
16
|
+
project: z.ZodNullable<z.ZodString>;
|
|
17
|
+
user: z.ZodNullable<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
lastPullAt: z.ZodNullable<z.ZodString>;
|
|
20
|
+
lastError: z.ZodNullable<z.ZodString>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
/** What `sync.json` holds: the shape the store's `MemorySyncState` is read back as. */
|
|
23
|
+
export type ParsedSyncState = z.infer<typeof SyncStateSchema>;
|
|
24
|
+
/** The state of a store that has never synced. */
|
|
25
|
+
export declare function defaultSyncState(): ParsedSyncState;
|
|
26
|
+
/** One mirror line as a decision, or `null` when the line is not one. */
|
|
27
|
+
export declare function parseDecisionLine(line: string): MemoryDecision | null;
|
|
28
|
+
/** One mirror line as a pending forget, or `null` when the line is not one. */
|
|
29
|
+
export declare function parsePendingForgetLine(line: string): MemoryPendingForget | null;
|
|
30
|
+
/**
|
|
31
|
+
* A JSONL record's text as its entries. A line the decoder refuses is skipped with one
|
|
32
|
+
* report naming its 1-based line number, never fatal: the other lines are still the
|
|
33
|
+
* record. A record file that is not there (`null`) is the empty record, not the absent
|
|
34
|
+
* one — a store that has never recorded anything answers `[]` and creates nothing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function decodeRecordLines<T>(text: string | null, decode: (line: string) => T | null, warnSkipped: (line: number) => void): T[];
|
|
37
|
+
/** One record file's text: one JSON object per line, and nothing at all when empty. */
|
|
38
|
+
export declare function encodeRecordLines(entries: readonly unknown[]): string;
|
|
39
|
+
/** The whole `sync.json` text as a state, or `null` when it does not hold one. */
|
|
40
|
+
export declare function parseSyncStateText(text: string): ParsedSyncState | null;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record files the mirror keeps beside its notes, decoded.
|
|
3
|
+
*
|
|
4
|
+
* `decisions.jsonl` is one `MemoryDecision` per line, `pending-forgets.jsonl` one
|
|
5
|
+
* `MemoryPendingForget` per line, and `sync.json` is one `MemorySyncState`: all three are
|
|
6
|
+
* written whole by this store and read back by it, so what makes a line a decision — or
|
|
7
|
+
* a pending forget, or what makes a file a state — is a schema rather than a cast.
|
|
8
|
+
* Keeping the schemas here leaves the store to decide what a refused line *means*
|
|
9
|
+
* (skipped with a warning) and this module to decide what counts as one.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_SCOPES, MEMORY_SLUG_RE, } from "./types.js";
|
|
13
|
+
/** A decision as one mirror line; the shape is what makes a line a decision. */
|
|
14
|
+
const DecisionSchema = z.object({
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
subject: z.string().min(1),
|
|
17
|
+
predicate: z.string().min(1),
|
|
18
|
+
object: z.string().min(1),
|
|
19
|
+
updatedAt: z.string().min(1),
|
|
20
|
+
status: z.enum(MEMORY_DECISION_STATUSES),
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* A pending forget as one mirror line. The slug and the client id are the two values
|
|
24
|
+
* later code acts on — the slug names the note an approval retires, the client id names
|
|
25
|
+
* the write — so a line carries a note slug and an outbox id, never an arbitrary string.
|
|
26
|
+
*/
|
|
27
|
+
const PendingForgetSchema = z.object({
|
|
28
|
+
clientId: z.string().regex(MEMORY_CLIENT_ID_RE),
|
|
29
|
+
scope: z.enum(MEMORY_SCOPES),
|
|
30
|
+
slug: z.string().regex(MEMORY_SLUG_RE),
|
|
31
|
+
reason: z.string(),
|
|
32
|
+
});
|
|
33
|
+
const SyncStateSchema = z.object({
|
|
34
|
+
provider: z.string().nullable(),
|
|
35
|
+
cursor: z.object({ project: z.string().nullable(), user: z.string().nullable() }),
|
|
36
|
+
lastPullAt: z.string().nullable(),
|
|
37
|
+
lastError: z.string().nullable(),
|
|
38
|
+
});
|
|
39
|
+
/** The state of a store that has never synced. */
|
|
40
|
+
export function defaultSyncState() {
|
|
41
|
+
return { provider: null, cursor: { project: null, user: null }, lastPullAt: null, lastError: null };
|
|
42
|
+
}
|
|
43
|
+
/** One decoded JSON value, or `null` when the text is not JSON. */
|
|
44
|
+
function parseJson(text) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Not JSON: the caller decides whether that is a skipped line or a defaulted state.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** One mirror line as a decision, or `null` when the line is not one. */
|
|
54
|
+
export function parseDecisionLine(line) {
|
|
55
|
+
const parsed = DecisionSchema.safeParse(parseJson(line));
|
|
56
|
+
return parsed.success ? parsed.data : null;
|
|
57
|
+
}
|
|
58
|
+
/** One mirror line as a pending forget, or `null` when the line is not one. */
|
|
59
|
+
export function parsePendingForgetLine(line) {
|
|
60
|
+
const parsed = PendingForgetSchema.safeParse(parseJson(line));
|
|
61
|
+
return parsed.success ? parsed.data : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A JSONL record's text as its entries. A line the decoder refuses is skipped with one
|
|
65
|
+
* report naming its 1-based line number, never fatal: the other lines are still the
|
|
66
|
+
* record. A record file that is not there (`null`) is the empty record, not the absent
|
|
67
|
+
* one — a store that has never recorded anything answers `[]` and creates nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function decodeRecordLines(text, decode, warnSkipped) {
|
|
70
|
+
if (text === null)
|
|
71
|
+
return [];
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const [index, raw] of text.split("\n").entries()) {
|
|
74
|
+
if (raw.trim() === "")
|
|
75
|
+
continue;
|
|
76
|
+
const entry = decode(raw);
|
|
77
|
+
if (entry === null) {
|
|
78
|
+
warnSkipped(index + 1);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
entries.push(entry);
|
|
82
|
+
}
|
|
83
|
+
return entries;
|
|
84
|
+
}
|
|
85
|
+
/** One record file's text: one JSON object per line, and nothing at all when empty. */
|
|
86
|
+
export function encodeRecordLines(entries) {
|
|
87
|
+
return entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
|
88
|
+
}
|
|
89
|
+
/** The whole `sync.json` text as a state, or `null` when it does not hold one. */
|
|
90
|
+
export function parseSyncStateText(text) {
|
|
91
|
+
const parsed = SyncStateSchema.safeParse(parseJson(text));
|
|
92
|
+
return parsed.success ? parsed.data : null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a permanent refusal undoes, and in which order.
|
|
3
|
+
*
|
|
4
|
+
* A provider that refuses a write will refuse it again, so the guards a write relies on
|
|
5
|
+
* while it might still be delivered come off here: the request leaves the queue and the
|
|
6
|
+
* mirror goes back to the note it held before the write staged. Both are *consequences* of
|
|
7
|
+
* the refusal, not alternatives to it, so neither step can decide whether the other runs —
|
|
8
|
+
* and neither can replace the error the caller is owed. A queue entry left behind is
|
|
9
|
+
* refused again by the next sync and costs a line; a note nobody put back costs the mirror
|
|
10
|
+
* a fact it held.
|
|
11
|
+
*
|
|
12
|
+
* It lives apart from the operations because both halves of a refusal — a save's and a
|
|
13
|
+
* forget's — need exactly this and nothing of each other's.
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentLogger } from "#src/infra/agent-logger";
|
|
16
|
+
import type { MemoryOutbox } from "./outbox.ts";
|
|
17
|
+
import type { MemoryStore } from "./store.ts";
|
|
18
|
+
import type { MemoryNote, MemoryScope } from "./types.ts";
|
|
19
|
+
/** What a refusal needs of an operation's inputs: the mirror it restores and where it reports. */
|
|
20
|
+
export interface RefusalInput {
|
|
21
|
+
readonly store: MemoryStore;
|
|
22
|
+
readonly outbox: MemoryOutbox;
|
|
23
|
+
readonly logger: AgentLogger;
|
|
24
|
+
}
|
|
25
|
+
/** Take a refused write back off the queue, whatever the queue does. */
|
|
26
|
+
export declare function discardRefused(input: RefusalInput, clientId: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Undo a refused save: the note that was under the key before, or no note at all.
|
|
29
|
+
*
|
|
30
|
+
* A rollback that fails is reported, never thrown: the caller is owed the provider's
|
|
31
|
+
* refusal, and a second error raised here would replace it. The mirror is then left
|
|
32
|
+
* holding the refused note, which is the one case worth a warning of its own.
|
|
33
|
+
*/
|
|
34
|
+
export declare function restoreNote(input: RefusalInput, scope: MemoryScope, slug: string, previous: MemoryNote | null): Promise<void>;
|
|
35
|
+
/** Put a refused forget's note back; a failure is reported, never thrown, for the same reason. */
|
|
36
|
+
export declare function restoreForgotten(input: RefusalInput, previous: MemoryNote): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { bookkeeping } from "./support.js";
|
|
2
|
+
/** Take a refused write back off the queue, whatever the queue does. */
|
|
3
|
+
export async function discardRefused(input, clientId) {
|
|
4
|
+
const data = { path: input.store.paths.outboxDir, clientId };
|
|
5
|
+
const remove = () => input.outbox.remove(clientId);
|
|
6
|
+
await bookkeeping(input.logger, "memory operation could not take a refused write off the queue", data, remove);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Undo a refused save: the note that was under the key before, or no note at all.
|
|
10
|
+
*
|
|
11
|
+
* A rollback that fails is reported, never thrown: the caller is owed the provider's
|
|
12
|
+
* refusal, and a second error raised here would replace it. The mirror is then left
|
|
13
|
+
* holding the refused note, which is the one case worth a warning of its own.
|
|
14
|
+
*/
|
|
15
|
+
export async function restoreNote(input, scope, slug, previous) {
|
|
16
|
+
const data = { path: input.store.paths.notesDir, slug };
|
|
17
|
+
const restore = () => (previous === null ? input.store.deleteNote(scope, slug) : input.store.writeNote(previous));
|
|
18
|
+
await bookkeeping(input.logger, "memory save could not restore the previous note", data, restore);
|
|
19
|
+
}
|
|
20
|
+
/** Put a refused forget's note back; a failure is reported, never thrown, for the same reason. */
|
|
21
|
+
export async function restoreForgotten(input, previous) {
|
|
22
|
+
const data = { path: input.store.paths.notesDir, slug: previous.slug };
|
|
23
|
+
const rewrite = () => input.store.writeNote(previous);
|
|
24
|
+
await bookkeeping(input.logger, "memory forget could not restore the note", data, rewrite);
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword search over the mirror — the "did this note mention that?" lookup a
|
|
3
|
+
* session runs before it starts work.
|
|
4
|
+
*
|
|
5
|
+
* Matching is whole-token and Unicode-aware, never substring: `replay` finds a
|
|
6
|
+
* note about the replay budget and not one about something that "replays".
|
|
7
|
+
* Tokenising the query and the note through the same function is what makes
|
|
8
|
+
* that true by construction, on both sides of the comparison.
|
|
9
|
+
*
|
|
10
|
+
* Search is pure — notes in, hits out, no store, no filesystem, no provider —
|
|
11
|
+
* so the ranking a caller sees is a function of its arguments alone.
|
|
12
|
+
*/
|
|
13
|
+
import type { MemoryHit, MemoryNote } from "./types.ts";
|
|
14
|
+
/** The distinct, lower-cased tokens of `text`, in first-occurrence order. */
|
|
15
|
+
export declare function tokenizeMemoryQuery(text: string): readonly string[];
|
|
16
|
+
/** Search the mirror's notes for the words in `text`. */
|
|
17
|
+
export declare function searchMemoryNotes(notes: readonly MemoryNote[], text: string, limit: number): readonly MemoryHit[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** A run of Unicode letters and digits: everything else separates tokens. */
|
|
2
|
+
const TOKEN_RUN = /[\p{L}\p{N}]+/gu;
|
|
3
|
+
/** The shortest token worth matching: `a` and `2` carry no signal. */
|
|
4
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
5
|
+
/** The distinct, lower-cased tokens of `text`, in first-occurrence order. */
|
|
6
|
+
export function tokenizeMemoryQuery(text) {
|
|
7
|
+
const tokens = text.toLowerCase().match(TOKEN_RUN) ?? [];
|
|
8
|
+
return [...new Set(tokens.filter((token) => token.length >= MIN_TOKEN_LENGTH))];
|
|
9
|
+
}
|
|
10
|
+
/** The statuses a search may return: a note not yet pushed still counts, a dead one never does. */
|
|
11
|
+
const SEARCHABLE_STATUSES = new Set(["active", "pending"]);
|
|
12
|
+
/** How much of a body line a snippet keeps. */
|
|
13
|
+
const SNIPPET_MAX_CHARS = 160;
|
|
14
|
+
/** The line breaks a body is split on: `\n`, `\r\n` and a lone `\r`. */
|
|
15
|
+
const BODY_LINE_BREAK = /\r\n|\r|\n/;
|
|
16
|
+
/** A query token found in the note's own words is worth twice one found in its body. */
|
|
17
|
+
const HEAD_SCORE = 2;
|
|
18
|
+
/** A query token found only in the body still counts, once. */
|
|
19
|
+
const BODY_SCORE = 1;
|
|
20
|
+
/** The text of one note that ranks it highest: its name and its one-line summary. */
|
|
21
|
+
function headText(note) {
|
|
22
|
+
return `${note.slug} ${note.description}`;
|
|
23
|
+
}
|
|
24
|
+
/** How much one note is worth for one query: 2 per head token, 1 per body token. */
|
|
25
|
+
function scoreNote(note, queryTokens) {
|
|
26
|
+
const head = new Set(tokenizeMemoryQuery(headText(note)));
|
|
27
|
+
const body = new Set(tokenizeMemoryQuery(note.body));
|
|
28
|
+
let score = 0;
|
|
29
|
+
for (const token of queryTokens) {
|
|
30
|
+
if (head.has(token))
|
|
31
|
+
score += HEAD_SCORE;
|
|
32
|
+
if (body.has(token))
|
|
33
|
+
score += BODY_SCORE;
|
|
34
|
+
}
|
|
35
|
+
return score;
|
|
36
|
+
}
|
|
37
|
+
/** The instant a timestamp names in epoch milliseconds, or null when it names none. */
|
|
38
|
+
function instantOf(updatedAt) {
|
|
39
|
+
const ms = Date.parse(updatedAt);
|
|
40
|
+
return Number.isNaN(ms) ? null : ms;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Newest first by the instant `updatedAt` names, not by its literal text: the
|
|
44
|
+
* note codec accepts any non-empty single line, so two notes may carry the same
|
|
45
|
+
* moment in different UTC offsets (`…T12:00:00+09:00` is `…T03:00:00Z`), and a
|
|
46
|
+
* string compare would rank them wrongly.
|
|
47
|
+
*
|
|
48
|
+
* One total order covers every pair, so the comparator is transitive whatever the
|
|
49
|
+
* input order: values that name an instant come first, newest to oldest, and the
|
|
50
|
+
* values that name none follow, compared as text. Mixing the two comparisons
|
|
51
|
+
* instead would let an unparseable value sort lexically between two instants
|
|
52
|
+
* whose chronological order is the reverse, and the ranking would then depend on
|
|
53
|
+
* the order the notes arrived in.
|
|
54
|
+
*/
|
|
55
|
+
function compareUpdatedDesc(a, b) {
|
|
56
|
+
const aMs = instantOf(a);
|
|
57
|
+
const bMs = instantOf(b);
|
|
58
|
+
if (aMs === null || bMs === null) {
|
|
59
|
+
if (aMs !== null)
|
|
60
|
+
return -1;
|
|
61
|
+
if (bMs !== null)
|
|
62
|
+
return 1;
|
|
63
|
+
return a === b ? 0 : a < b ? 1 : -1;
|
|
64
|
+
}
|
|
65
|
+
return aMs === bMs ? 0 : aMs < bMs ? 1 : -1;
|
|
66
|
+
}
|
|
67
|
+
/** Rank: score descending, then newest first, then slug ascending. */
|
|
68
|
+
function compareScored(a, b) {
|
|
69
|
+
if (a.score !== b.score)
|
|
70
|
+
return b.score - a.score;
|
|
71
|
+
const byUpdatedAt = compareUpdatedDesc(a.note.updatedAt, b.note.updatedAt);
|
|
72
|
+
if (byUpdatedAt !== 0)
|
|
73
|
+
return byUpdatedAt;
|
|
74
|
+
return a.note.slug < b.note.slug ? -1 : a.note.slug > b.note.slug ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
/** The first body line that holds one of the query's tokens, cut to a snippet. */
|
|
77
|
+
function snippetOf(body, queryTokens) {
|
|
78
|
+
for (const line of body.split(BODY_LINE_BREAK)) {
|
|
79
|
+
const lineTokens = new Set(tokenizeMemoryQuery(line));
|
|
80
|
+
if (queryTokens.some((token) => lineTokens.has(token)))
|
|
81
|
+
return line.slice(0, SNIPPET_MAX_CHARS);
|
|
82
|
+
}
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
/** Search the mirror's notes for the words in `text`. */
|
|
86
|
+
export function searchMemoryNotes(notes, text, limit) {
|
|
87
|
+
const queryTokens = tokenizeMemoryQuery(text);
|
|
88
|
+
if (queryTokens.length === 0 || limit <= 0)
|
|
89
|
+
return [];
|
|
90
|
+
const scored = [];
|
|
91
|
+
for (const note of notes) {
|
|
92
|
+
if (!SEARCHABLE_STATUSES.has(note.status))
|
|
93
|
+
continue;
|
|
94
|
+
const score = scoreNote(note, queryTokens);
|
|
95
|
+
if (score > 0)
|
|
96
|
+
scored.push({ note, score });
|
|
97
|
+
}
|
|
98
|
+
scored.sort(compareScored);
|
|
99
|
+
return scored.slice(0, limit).map((ranked) => toHit(ranked, queryTokens));
|
|
100
|
+
}
|
|
101
|
+
/** The public shape of one ranked note. */
|
|
102
|
+
function toHit({ note, score }, queryTokens) {
|
|
103
|
+
return {
|
|
104
|
+
slug: note.slug,
|
|
105
|
+
scope: note.scope,
|
|
106
|
+
type: note.type,
|
|
107
|
+
description: note.description,
|
|
108
|
+
status: note.status,
|
|
109
|
+
score,
|
|
110
|
+
snippet: snippetOf(note.body, queryTokens),
|
|
111
|
+
};
|
|
112
|
+
}
|