@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/config/catalog-overrides.d.ts +13 -0
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +1 -0
  5. package/dist/internal/atomic-write.d.ts +22 -7
  6. package/dist/internal/atomic-write.js +107 -12
  7. package/dist/internal/pinned-directory-worker.d.ts +52 -0
  8. package/dist/internal/pinned-directory-worker.js +105 -0
  9. package/dist/internal/pinned-directory.d.ts +48 -0
  10. package/dist/internal/pinned-directory.js +168 -0
  11. package/dist/internal.d.ts +2 -0
  12. package/dist/internal.js +2 -0
  13. package/dist/memory/delivery.d.ts +38 -0
  14. package/dist/memory/delivery.js +28 -0
  15. package/dist/memory/index-render.d.ts +55 -0
  16. package/dist/memory/index-render.js +225 -0
  17. package/dist/memory/index.d.ts +12 -0
  18. package/dist/memory/index.js +11 -0
  19. package/dist/memory/local-provider.d.ts +3 -0
  20. package/dist/memory/local-provider.js +127 -0
  21. package/dist/memory/mirror-lock.d.ts +109 -0
  22. package/dist/memory/mirror-lock.js +334 -0
  23. package/dist/memory/note.d.ts +15 -0
  24. package/dist/memory/note.js +131 -0
  25. package/dist/memory/operations.d.ts +123 -0
  26. package/dist/memory/operations.js +471 -0
  27. package/dist/memory/outbox.d.ts +35 -0
  28. package/dist/memory/outbox.js +289 -0
  29. package/dist/memory/provider-types.d.ts +50 -0
  30. package/dist/memory/provider-types.js +1 -0
  31. package/dist/memory/records.d.ts +41 -0
  32. package/dist/memory/records.js +93 -0
  33. package/dist/memory/refusal.d.ts +36 -0
  34. package/dist/memory/refusal.js +25 -0
  35. package/dist/memory/search.d.ts +17 -0
  36. package/dist/memory/search.js +112 -0
  37. package/dist/memory/store.d.ts +106 -0
  38. package/dist/memory/store.js +459 -0
  39. package/dist/memory/support.d.ts +63 -0
  40. package/dist/memory/support.js +98 -0
  41. package/dist/memory/sync-flight.d.ts +25 -0
  42. package/dist/memory/sync-flight.js +65 -0
  43. package/dist/memory/sync.d.ts +82 -0
  44. package/dist/memory/sync.js +344 -0
  45. package/dist/memory/types.d.ts +108 -0
  46. package/dist/memory/types.js +51 -0
  47. package/dist/memory/validate.d.ts +31 -0
  48. package/dist/memory/validate.js +104 -0
  49. package/dist/native/adapter-deps.d.ts +9 -0
  50. package/dist/native/adapter-deps.js +9 -0
  51. package/dist/native/models.js +3 -0
  52. package/dist/native/session/compaction.d.ts +15 -1
  53. package/dist/native/session/compaction.js +90 -23
  54. package/dist/native/session/manual-compaction.d.ts +57 -0
  55. package/dist/native/session/manual-compaction.js +72 -0
  56. package/dist/native/session/session-summarizer.d.ts +75 -0
  57. package/dist/native/session/session-summarizer.js +73 -0
  58. package/dist/native/session/transcript-identity.d.ts +12 -0
  59. package/dist/native/session/transcript-identity.js +16 -0
  60. package/dist/native/session/turn-compaction-step.d.ts +30 -2
  61. package/dist/native/session/turn-compaction-step.js +7 -1
  62. package/dist/native/session/turn-loop-round-trip.js +71 -17
  63. package/dist/native/session/turn-loop.js +2 -6
  64. package/dist/native/session-adapter.d.ts +19 -0
  65. package/dist/native/session-adapter.js +42 -64
  66. package/dist/sandbox/srt-backend.js +9 -16
  67. package/dist/session/agent-session-options.js +1 -0
  68. package/dist/session/agent-session-turn.d.ts +6 -0
  69. package/dist/session/agent-session-turn.js +5 -4
  70. package/dist/session/agent-session-types.d.ts +38 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +163 -20
  73. package/dist/session/native-backend.js +8 -0
  74. package/dist/session/session-backend.d.ts +19 -1
  75. package/dist/session/session-types.d.ts +6 -0
  76. package/dist/session/steer-channel.d.ts +36 -0
  77. package/dist/session/steer-channel.js +39 -0
  78. package/dist/session-store/index.d.ts +1 -1
  79. package/dist/session-store/index.js +1 -1
  80. package/dist/session-store/session-lock.d.ts +2 -2
  81. package/dist/session-store/session-lock.js +4 -2
  82. package/dist/tools/ask-request.d.ts +8 -0
  83. package/dist/tools/ask-request.js +34 -1
  84. package/dist/tools/provider-adapt.js +5 -1
  85. package/dist/tools/provider-types.d.ts +7 -0
  86. package/dist/tools/types.d.ts +16 -0
  87. package/package.json +4 -4
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The mirror's lock protocol, as the operations that change a mirror use it.
3
+ *
4
+ * A store owns more than one mirror: its project directory, and — when it has one — the
5
+ * user directory every project shares. The lock file is the artifact that already means
6
+ * "this mirror has one writer", and everything a save, a forget or an approval decides —
7
+ * is this key already queued, what the mirror holds, which sequence the queue takes —
8
+ * means nothing if two writers compute it at the same time. That is why the unit here is a
9
+ * *set* of locks rather than the project's one: two projects sharing a user directory are
10
+ * two writers of the same user notes, and a project-only lock lets both of them stage the
11
+ * same user slug at once. A set is taken in one canonical order (the locks sorted by their
12
+ * resolved path), so no two writers can hold one lock each and wait for the other's.
13
+ *
14
+ * The two halves are both needed and neither replaces the other. The in-process chain is
15
+ * what the lock file cannot be: the protocol classifies the caller's own pid as `own` and
16
+ * would let a second writer in this process take the lock over from the first, so a
17
+ * same-process writer waits behind the chain instead. The lock file is what the chain
18
+ * cannot be: it is the one fact another process can read.
19
+ *
20
+ * It is separate from the operations because all of them need exactly this and nothing of
21
+ * each other's: `operations.ts` reaches it for saves, forgets and approvals alike, and the
22
+ * split keeps that file inside its size limit without forking the protocol.
23
+ */
24
+ import { join } from "node:path";
25
+ import { errorMessage } from "#src/infra/errors";
26
+ import { NaxError } from "#src/infra/nax-error";
27
+ import { isProcessAlive } from "#src/internal/process-alive";
28
+ import { realOrRaw } from "#src/internal/realpath";
29
+ import { byCodePoint } from "#src/internal/sort";
30
+ import { acquireSessionLock } from "#src/session-store/index";
31
+ import { holderState, readSessionLock } from "#src/session-store/session-lock";
32
+ import { _memoryStoreDeps } from "./store.js";
33
+ import { GLOBAL_MEMORY_TIMERS, warnQuietly } from "./support.js";
34
+ import { claimOperation, operationInFlight, releaseOperation, syncInFlight } from "./sync-flight.js";
35
+ /** The code the lock protocol throws for a live holder another process owns. */
36
+ const SESSION_LOCKED = "SESSION_LOCKED";
37
+ /** The lock file's name inside the directory it guards. */
38
+ const LOCK_FILE = "sync.lock";
39
+ /** The scopes a store can have a mirror for: the project's, and the shared user one. */
40
+ const ALL_SCOPES = ["project", "user"];
41
+ /** How often a contending operation retries the mirror's existing lock protocol. */
42
+ const FENCED_POLL_MS = 10;
43
+ /**
44
+ * The clock an operation stamps with: the caller's, or the process clock.
45
+ *
46
+ * Every operation that stamps a note or a lock file takes the same default, so this is
47
+ * the one place it is spelled — an approval stamps a note without asking a provider, and
48
+ * still reads its clock here.
49
+ */
50
+ export function clockOf(input) {
51
+ return input.now ?? (() => new Date());
52
+ }
53
+ /**
54
+ * The lock guarding one scope's mirror, or `null` when this store has no such scope.
55
+ *
56
+ * The target is spelled in its *resolved* form, and that is the identity every part of the
57
+ * protocol works with: the map key that deduplicates the set, the path a claim or a writer
58
+ * chain is registered under, and the file whose holder is read. Two stores can name one
59
+ * directory through different spellings — nax hands each store its own `userDir`, and a
60
+ * symlinked alias of the same directory is a different string — so a raw path would let two
61
+ * writers of one mirror believe they hold two different locks, which is exactly the pair the
62
+ * chain exists to order.
63
+ */
64
+ function targetOf(store, scope) {
65
+ const spelling = scope === "user" ? store.paths.userDir : store.paths.projectDir;
66
+ if (spelling === null)
67
+ return null;
68
+ const dir = realOrRaw(spelling);
69
+ return { path: join(dir, LOCK_FILE), dir };
70
+ }
71
+ /**
72
+ * The locks that guard the given scopes, deduplicated and in the one order every caller
73
+ * takes them.
74
+ *
75
+ * The set is keyed and sorted by resolved path because two processes must agree on it
76
+ * without sharing any state: a writer holding one lock and waiting for the other while its
77
+ * counterpart holds that one and waits for this one is a deadlock, and it only stays
78
+ * impossible while both sides sort the same way. Sorting the spellings instead would break
79
+ * as soon as one caller reached the same directory through a link.
80
+ */
81
+ export function lockTargetsFor(store, scopes) {
82
+ const byPath = new Map();
83
+ for (const scope of scopes) {
84
+ const target = targetOf(store, scope);
85
+ if (target !== null)
86
+ byPath.set(target.path, target);
87
+ }
88
+ return [...byPath.values()].sort((left, right) => byCodePoint(left.path, right.path));
89
+ }
90
+ /** Every lock this store's writers share: the project's mirror and, when it has one, the user's. */
91
+ export function storeLockTargets(store) {
92
+ return lockTargetsFor(store, ALL_SCOPES);
93
+ }
94
+ /** Whether a sync of this process is in flight for any mirror of this store. */
95
+ export function mirrorSyncInFlight(store) {
96
+ return storeLockTargets(store).some((target) => syncInFlight(target.path));
97
+ }
98
+ /** The liveness probe a lock holder is judged by: the caller's, or the real one. */
99
+ function livenessOf(input) {
100
+ return input.isAlive ?? isProcessAlive;
101
+ }
102
+ /** Everything the lock protocol needs for one lock: the directory its guard lives in, the holder. */
103
+ function lockDeps(input, dir) {
104
+ return {
105
+ dir,
106
+ pid: input.pid ?? process.pid,
107
+ now: clockOf(input),
108
+ logger: input.logger,
109
+ isAlive: livenessOf(input),
110
+ };
111
+ }
112
+ /**
113
+ * Take one mirror's lock, or `null` when another writer already holds it.
114
+ *
115
+ * A lock file another live process owns is that process's mirror, and this caller leaves it
116
+ * alone. A lock file naming this very process is *not* such a holder: either a sync of this
117
+ * process is running, or the file is a lock this process leaked when a release failed, and
118
+ * taking it over is the only way the next writer can deliver again. `ownSync` says which of
119
+ * the two this caller is — a sync passing its own claim in, an operation passing `false` and
120
+ * so refusing while a sync of this process is in flight (the protocol would call our own pid
121
+ * `own` and hand over the lock the sync still believes it holds).
122
+ *
123
+ * The sync is checked before the file is even read: a sync that has claimed this mirror but
124
+ * has not written its lock yet is still a sync, and waiting for it is what keeps the two
125
+ * of them off the mirror at once.
126
+ */
127
+ async function takeMirrorLock(input, target, ownSync) {
128
+ if (!ownSync && syncInFlight(target.path))
129
+ return null;
130
+ const deps = lockDeps(input, target.dir);
131
+ const isAlive = livenessOf(input);
132
+ await _memoryStoreDeps.mkdir(target.dir, { recursive: true });
133
+ const holder = readSessionLock(target.path).body;
134
+ if (holder !== undefined && !reclaimable(holder, deps, isAlive))
135
+ return null;
136
+ try {
137
+ return acquireSessionLock(target.path, deps, isAlive);
138
+ }
139
+ catch (error) {
140
+ if (error instanceof NaxError && error.code === SESSION_LOCKED)
141
+ return null;
142
+ throw error;
143
+ }
144
+ }
145
+ /**
146
+ * Whether a lock file that is already there is this caller's to take over: a live holder is
147
+ * not, and everything else is — a stale holder is a process that is gone, and a pid of our
148
+ * own is either the sync this caller is, or a lock this process leaked.
149
+ */
150
+ function reclaimable(holder, deps, isAlive) {
151
+ return holderState(holder, deps, isAlive) !== "live";
152
+ }
153
+ /** Hand one lock back; a failed release is reported, never thrown, so the result still returns. */
154
+ export async function releaseMirrorLock(input, release) {
155
+ try {
156
+ await release();
157
+ }
158
+ catch (error) {
159
+ warnQuietly(input.logger, "memory operation could not release the mirror lock", {
160
+ path: input.store.paths.lock,
161
+ error: errorMessage(error),
162
+ });
163
+ }
164
+ }
165
+ /** Release every lock taken, latest first: the reverse of the order they were acquired in. */
166
+ export async function releaseMirrorLocks(input, held) {
167
+ for (const release of [...held].reverse())
168
+ await releaseMirrorLock(input, release);
169
+ }
170
+ /** Wait one interval on the operation's own timer seam. */
171
+ async function pause(timers, ms) {
172
+ await new Promise((resolve) => {
173
+ timers.setTimeout(resolve, ms);
174
+ });
175
+ }
176
+ /**
177
+ * Take one lock before declaring any write, regardless of how long its holder pauses.
178
+ *
179
+ * Neither elapsed time nor an existing queued key proves the current holder has declared
180
+ * its write. Waiting for ownership coordinates staging and sequence allocation across
181
+ * processes without guessing whether the holder is saving or syncing. A dead or stale
182
+ * holder is reclaimed by the existing lock protocol; a live holder must release it.
183
+ */
184
+ async function waitForTurn(input, target) {
185
+ const timers = input.timers ?? GLOBAL_MEMORY_TIMERS;
186
+ for (;;) {
187
+ const lock = await takeMirrorLock(input, target, false);
188
+ if (lock !== null)
189
+ return lock;
190
+ await pause(timers, FENCED_POLL_MS);
191
+ }
192
+ }
193
+ /**
194
+ * Take every one of `targets`, in the order given, and hand them all back whatever happens.
195
+ *
196
+ * Nothing is awaited between the claims and the last acquisition that another writer of this
197
+ * process could see: the claims mark this process as a writer for the whole wait, so a sync
198
+ * that starts meanwhile (`operationInFlight`) leaves the mirror alone instead of taking our
199
+ * own pid's lock file over.
200
+ */
201
+ export async function withMirrorLocks(input, targets, work) {
202
+ for (const target of targets)
203
+ claimOperation(target.path);
204
+ const held = [];
205
+ try {
206
+ for (const target of targets)
207
+ held.push(await waitForTurn(input, target));
208
+ return await work();
209
+ }
210
+ finally {
211
+ await releaseMirrorLocks(input, held);
212
+ for (const target of targets)
213
+ releaseOperation(target.path);
214
+ }
215
+ }
216
+ /**
217
+ * Run `work` as this store's only writer — of the project mirror and of the user mirror its
218
+ * projects share — and hand every lock back whatever `work` does.
219
+ *
220
+ * Waiting is the only ordering this can offer, so it is the right one for an operation that
221
+ * has nowhere else to put its write: a save or a forget can queue an entry and return, but an
222
+ * approval has to change the note it read, which means it has to be the writer when it reads
223
+ * it. A live holder — another process, or a sync of this process — is waited out on the
224
+ * operation's timers, and a failed release is reported rather than thrown, so `work`'s own
225
+ * answer still reaches the caller.
226
+ */
227
+ export function withMirrorLock(input, work) {
228
+ return withMirrorLocks(input, storeLockTargets(input.store), work);
229
+ }
230
+ /**
231
+ * One release for every lock taken: each is handed back even when an earlier one fails, and
232
+ * the first failure is re-thrown afterwards so the caller reports it in its own terms.
233
+ *
234
+ * A run that stopped at the first failure would leave the rest held, which is a mirror nobody
235
+ * can write to; a run that swallowed it would report a clean release that never happened.
236
+ */
237
+ function combinedRelease(releases) {
238
+ return async () => {
239
+ let failure;
240
+ for (const release of releases) {
241
+ try {
242
+ await release();
243
+ }
244
+ catch (error) {
245
+ failure ??= error;
246
+ }
247
+ }
248
+ if (failure !== undefined)
249
+ throw failure;
250
+ };
251
+ }
252
+ /**
253
+ * Take every lock a run needs, or `null` when any of them is held.
254
+ *
255
+ * A sync that waited would be a sync the run's caller cannot bound, so contention is a
256
+ * result — `locked` — rather than a wait. The locks already taken are handed back before
257
+ * returning, because a lock kept while reporting "I got nothing" is a mirror nobody can
258
+ * write to. The operation claim is read first: a write operation of this process is midway
259
+ * through the mirror, and the protocol would call our own pid `own` and hand this run the
260
+ * file that operation believes it holds.
261
+ *
262
+ * A failure that is not contention — a directory that cannot be created, a lock file that
263
+ * cannot be read — unwinds exactly the same way: what this run took is handed back before the
264
+ * error leaves. A lock abandoned here holds its heartbeat too, so the mirror it guards would
265
+ * stay locked to every other writer until the heartbeat aged out.
266
+ */
267
+ export async function acquireMirrorLocks(input, targets) {
268
+ const held = [];
269
+ try {
270
+ for (const target of targets) {
271
+ const lock = operationInFlight(target.path) ? null : await takeMirrorLock(input, target, true);
272
+ if (lock === null) {
273
+ await releaseMirrorLocks(input, held);
274
+ return null;
275
+ }
276
+ held.push(lock);
277
+ }
278
+ }
279
+ catch (error) {
280
+ await releaseMirrorLocks(input, held);
281
+ throw error;
282
+ }
283
+ return combinedRelease([...held].reverse());
284
+ }
285
+ /** The tail a store with no writer in flight waits behind: nothing. */
286
+ const NO_WRITER = Promise.resolve();
287
+ /**
288
+ * The writers this process is running, by the mirror they write: the tail a writer waits behind.
289
+ *
290
+ * The lock file is what makes the mirror's writers exclusive across processes, and a
291
+ * same-process writer cannot rely on it — the protocol classifies our own pid as ours and
292
+ * would let the second writer take the lock over from the first — so this chain is what
293
+ * orders them here. It is keyed by the mirror rather than by the note key because one store's
294
+ * writers are already one writer: ordering them wholesale is what the lock says about other
295
+ * processes, and it costs a writer of a second key only the wait for the first to finish. The
296
+ * stored tail is the one that cannot reject, so one failed operation never poisons the next,
297
+ * and the last one of a store drops its entry — a chain left behind is a leak, not a guard.
298
+ *
299
+ * It is keyed by *every* shared lock path for the same reason the file protocol takes a set:
300
+ * two projects sharing a user directory are two writers of that directory, and their chains
301
+ * have to meet on the path they share.
302
+ */
303
+ const writerChains = new Map();
304
+ /**
305
+ * Run `work` once every earlier writer of the same mirror has settled.
306
+ *
307
+ * Taking the tail happens before the first `await`: a queue read after a yield is one two
308
+ * writers both pass, which is exactly the interleaving this orders away. The caller's own
309
+ * rejection is re-thrown unchanged — a refusal must reach its caller, not the next writer —
310
+ * and the chain continues from a tail that swallows it.
311
+ */
312
+ export async function orderedByStore(lockPath, work) {
313
+ const run = (writerChains.get(lockPath) ?? NO_WRITER).then(work);
314
+ const tail = run.then(() => undefined, () => undefined);
315
+ writerChains.set(lockPath, tail);
316
+ try {
317
+ return await run;
318
+ }
319
+ finally {
320
+ if (writerChains.get(lockPath) === tail)
321
+ writerChains.delete(lockPath);
322
+ }
323
+ }
324
+ /**
325
+ * Run `work` behind every earlier writer of any mirror this store shares.
326
+ *
327
+ * One chain per lock path, nested in the same canonical order the locks themselves are taken
328
+ * in, so a writer of project A and a writer of project B — both sharing one user directory —
329
+ * meet on that directory's chain before either reaches its own lock. The nesting order has to
330
+ * match the file locks' order for the same deadlock reason they are sorted.
331
+ */
332
+ export function orderedByMirrorLocks(store, work) {
333
+ return storeLockTargets(store).reduce((inner, target) => () => orderedByStore(target.path, inner), work)();
334
+ }
@@ -0,0 +1,15 @@
1
+ import { type MemoryNote } from "./types.ts";
2
+ /**
3
+ * Validate an already-decoded note-shaped object (the shared shape validation a
4
+ * provider pull reuses). Throws `MEMORY_NOTE_INVALID` naming the first bad key.
5
+ */
6
+ export declare function parseMemoryNoteRecord(record: unknown): MemoryNote;
7
+ /** Decode one note file. Throws `MEMORY_NOTE_INVALID` naming the first bad key. */
8
+ export declare function parseMemoryNote(text: string): MemoryNote;
9
+ /** Encode one note as a file: the keys in `NOTE_KEYS` order, then a blank line, then the body. */
10
+ export declare function serializeMemoryNote(note: MemoryNote): string;
11
+ /**
12
+ * Whether a provider refused a write for a reason retrying cannot fix. Every
13
+ * other throw (and a timeout) is transient.
14
+ */
15
+ export declare function isMemoryPushRejected(error: unknown): boolean;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The note codec: one Markdown file per note, frontmatter between two `---`
3
+ * lines (one `key: value` per line, single-line plain scalars, unknown keys
4
+ * ignored), then one blank line, then the body.
5
+ *
6
+ * `parseMemoryNote` validates the frontmatter with a zod schema and reports the
7
+ * first bad key as `MEMORY_NOTE_INVALID`; `parseMemoryNoteRecord` is the same
8
+ * validation over an already-decoded object, so a provider pull validates a
9
+ * note exactly as a mirror read does. That schema is also where the model's
10
+ * field limits live (`MEMORY_DESCRIPTION_MAX_CHARS`, `MEMORY_SOURCE_MAX_CHARS`,
11
+ * `MEMORY_NOTE_BODY_MAX_BYTES`), so a note over them can be neither read back
12
+ * from the mirror nor pulled into it.
13
+ */
14
+ import { z } from "zod";
15
+ import { NaxError } from "#src/infra/nax-error";
16
+ import { MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_STATUSES, MEMORY_NOTE_TYPES, MEMORY_SCOPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, } from "./types.js";
17
+ const ERROR_STAGE = "memory";
18
+ const FRONTMATTER_FENCE = "---";
19
+ const FENCE_LINE = /^---[ \t]*$/;
20
+ const BLANK_LINE = /^[ \t]*$/;
21
+ const LINE_BREAK = /[\r\n]/;
22
+ /** The frontmatter keys a note carries, in the order `serializeMemoryNote` writes them. */
23
+ const NOTE_KEYS = ["id", "slug", "type", "scope", "description", "source", "updatedAt", "status"];
24
+ const NOTE_KEY_SET = new Set(NOTE_KEYS);
25
+ /**
26
+ * A one-line field: non-empty, no line break, and at most `maxChars` characters when the
27
+ * field is capped. The model's limits are enforced here rather than only on the save path,
28
+ * so a note arriving from a provider is held to the same shape a note file must have — a
29
+ * pull cannot persist a description, source or body the write validator would have refused.
30
+ */
31
+ function singleLine(minMessage, maxChars) {
32
+ const bounded = maxChars === undefined ? z.string() : z.string().max(maxChars, `must be at most ${maxChars} characters`);
33
+ return bounded.min(1, minMessage).refine((value) => !LINE_BREAK.test(value), "must be a single line");
34
+ }
35
+ /** The body's cap is in UTF-8 bytes, so a multi-byte body can fail below the character count. */
36
+ function noteBody() {
37
+ return z.string().refine((value) => Buffer.byteLength(value, "utf8") <= MEMORY_NOTE_BODY_MAX_BYTES, {
38
+ message: `must be at most ${MEMORY_NOTE_BODY_MAX_BYTES} bytes`,
39
+ });
40
+ }
41
+ const NoteSchema = z.object({
42
+ id: singleLine("must not be empty"),
43
+ slug: singleLine("must not be empty").regex(MEMORY_SLUG_RE, "must be a slug (lower-case letters, digits and '-')"),
44
+ type: z.enum(MEMORY_NOTE_TYPES),
45
+ scope: z.enum(MEMORY_SCOPES),
46
+ description: singleLine("must not be empty", MEMORY_DESCRIPTION_MAX_CHARS),
47
+ source: singleLine("must not be empty", MEMORY_SOURCE_MAX_CHARS),
48
+ updatedAt: singleLine("must not be empty"),
49
+ status: z.enum(MEMORY_NOTE_STATUSES),
50
+ body: noteBody(),
51
+ });
52
+ function invalidNote(message, key) {
53
+ return new NaxError(`memory note invalid: ${message}`, "MEMORY_NOTE_INVALID", key === undefined ? { stage: ERROR_STAGE } : { stage: ERROR_STAGE, key });
54
+ }
55
+ function stripLineBreak(line) {
56
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
57
+ }
58
+ function isNoteKey(key) {
59
+ return NOTE_KEY_SET.has(key);
60
+ }
61
+ /** Split one `key: value` line; the value is everything after the first `: `. */
62
+ function splitField(line) {
63
+ const separator = line.indexOf(": ");
64
+ if (separator !== -1)
65
+ return { key: line.slice(0, separator), value: line.slice(separator + 2) };
66
+ if (line.endsWith(":"))
67
+ return { key: line.slice(0, -1), value: "" };
68
+ return null;
69
+ }
70
+ /**
71
+ * The lines between the fences, plus the body after the required blank separator line.
72
+ *
73
+ * A note file has exactly one body layout: the closing fence, one blank line, then the
74
+ * body. Text on the line straight after the fence is not a body — it is a file written by
75
+ * something that does not follow the format — so it is refused rather than adopted as one:
76
+ * a body that swallowed the first line of a malformed file would be persisted and served
77
+ * as if it had been written deliberately.
78
+ */
79
+ function splitNoteText(text) {
80
+ const lines = text.split("\n").map(stripLineBreak);
81
+ const [first] = lines;
82
+ if (first === undefined || !FENCE_LINE.test(first))
83
+ throw invalidNote("frontmatter must start with a --- line");
84
+ const close = lines.findIndex((line, index) => index > 0 && FENCE_LINE.test(line));
85
+ if (close === -1)
86
+ throw invalidNote("frontmatter has no closing --- line");
87
+ const fields = {};
88
+ for (const line of lines.slice(1, close)) {
89
+ const field = splitField(line);
90
+ if (field !== null && isNoteKey(field.key))
91
+ fields[field.key] = field.value;
92
+ }
93
+ const rest = lines.slice(close + 1);
94
+ const [separator] = rest;
95
+ if (separator !== undefined && !BLANK_LINE.test(separator)) {
96
+ throw invalidNote("the body must start on the line after a blank line");
97
+ }
98
+ return { fields, body: separator === undefined ? "" : rest.slice(1).join("\n") };
99
+ }
100
+ /**
101
+ * Validate an already-decoded note-shaped object (the shared shape validation a
102
+ * provider pull reuses). Throws `MEMORY_NOTE_INVALID` naming the first bad key.
103
+ */
104
+ export function parseMemoryNoteRecord(record) {
105
+ const parsed = NoteSchema.safeParse(record);
106
+ if (parsed.success)
107
+ return parsed.data;
108
+ const issue = parsed.error.issues[0];
109
+ const key = issue?.path[0] === undefined ? null : String(issue.path[0]);
110
+ if (key === null)
111
+ throw invalidNote(issue?.message ?? "invalid note");
112
+ throw invalidNote(`${key}: ${issue?.message ?? "invalid"}`, key);
113
+ }
114
+ /** Decode one note file. Throws `MEMORY_NOTE_INVALID` naming the first bad key. */
115
+ export function parseMemoryNote(text) {
116
+ const { fields, body } = splitNoteText(text);
117
+ return parseMemoryNoteRecord({ ...fields, body });
118
+ }
119
+ /** Encode one note as a file: the keys in `NOTE_KEYS` order, then a blank line, then the body. */
120
+ export function serializeMemoryNote(note) {
121
+ const frontmatter = NOTE_KEYS.map((key) => `${key}: ${note[key]}`).join("\n");
122
+ const head = `${FRONTMATTER_FENCE}\n${frontmatter}\n${FRONTMATTER_FENCE}\n`;
123
+ return note.body === "" ? head : `${head}\n${note.body}`;
124
+ }
125
+ /**
126
+ * Whether a provider refused a write for a reason retrying cannot fix. Every
127
+ * other throw (and a timeout) is transient.
128
+ */
129
+ export function isMemoryPushRejected(error) {
130
+ return error instanceof NaxError && error.code === "MEMORY_PUSH_REJECTED";
131
+ }
@@ -0,0 +1,123 @@
1
+ import { type AgentLogger } from "#src/infra/agent-logger";
2
+ import { type MemoryOutbox } from "./outbox.ts";
3
+ import type { MemoryProvider } from "./provider-types.ts";
4
+ import type { MemoryStore } from "./store.ts";
5
+ import { type MemoryNote, type MemoryNoteStatus, type MemoryNoteWrite, type MemoryScope, type MemoryTimers } from "./types.ts";
6
+ /** What every operation is given: the mirror it writes, the queue and the provider it talks to. */
7
+ export interface MemoryOperationInput {
8
+ readonly store: MemoryStore;
9
+ readonly outbox: MemoryOutbox;
10
+ readonly provider: MemoryProvider;
11
+ /**
12
+ * Reported through when the save cannot be undone; the other outcomes say what
13
+ * became of the write in the result, so a delivered save has nothing to say.
14
+ */
15
+ readonly logger: AgentLogger;
16
+ /** The clock a note is stamped from; default `() => new Date()`. */
17
+ readonly now?: () => Date;
18
+ /** The timer seam the probe and the push race against; default the process globals. */
19
+ readonly timers?: MemoryTimers;
20
+ /** The liveness probe a lock holder is judged by; default `isProcessAlive`. */
21
+ readonly isAlive?: (pid: number) => boolean;
22
+ }
23
+ /** One save: the write to store, the mode it is stored in, and the label it is stored under. */
24
+ export interface MemorySaveInput extends MemoryOperationInput {
25
+ readonly write: MemoryNoteWrite;
26
+ /** `direct` takes effect at once; `proposal` is held pending until it is approved. */
27
+ readonly writeMode: "direct" | "proposal";
28
+ /** The one-line label the note records as its `source` (`"nax-chat"`, `"nax-run"`, ...). */
29
+ readonly source: string;
30
+ }
31
+ /** What a save came to: the note's slug, the status it ended in, and whether a provider took it. */
32
+ export interface MemorySaveResult {
33
+ readonly slug: string;
34
+ readonly status: MemoryNoteStatus;
35
+ readonly delivered: boolean;
36
+ }
37
+ /** One forget: the note to retire, why, and the mode it is retired in. */
38
+ export interface MemoryForgetInput extends MemoryOperationInput {
39
+ readonly scope: MemoryScope;
40
+ readonly slug: string;
41
+ readonly reason: string;
42
+ /** `direct` retires the note at once; `proposal` asks for approval and leaves it in place. */
43
+ readonly writeMode: "direct" | "proposal";
44
+ }
45
+ /** What a forget came to: the slug it names, the status it ended in, and whether it was delivered. */
46
+ export interface MemoryForgetResult {
47
+ readonly slug: string;
48
+ readonly status: MemoryNoteStatus;
49
+ readonly delivered: boolean;
50
+ }
51
+ /**
52
+ * One approval: the note to put in force, named by its scope and slug.
53
+ *
54
+ * The mirror is the whole of what an approval decides: no queue to declare a write on, no provider
55
+ * to ask, and every lock seam defaulted, so `{ store, scope, slug }` is a complete input.
56
+ */
57
+ export interface MemoryApproveInput {
58
+ readonly store: MemoryStore;
59
+ readonly scope: MemoryScope;
60
+ readonly slug: string;
61
+ /** The clock an approved note is re-stamped from; default `() => new Date()`. */
62
+ readonly now?: () => Date;
63
+ /** Reported through when the lock cannot be handed back; default the host logger slot. */
64
+ readonly logger?: AgentLogger;
65
+ /** The timer seam the lock wait polls on; default the process globals. */
66
+ readonly timers?: MemoryTimers;
67
+ /** The liveness probe a lock holder is judged by; default `isProcessAlive`. */
68
+ readonly isAlive?: (pid: number) => boolean;
69
+ }
70
+ /**
71
+ * Store one note and deliver it to the provider, or queue it for the next sync.
72
+ *
73
+ * The validator decides first, so a refusal leaves the store exactly as it found it.
74
+ * Everything after it runs in the mirror's own order — one save at a time in this process
75
+ * (`orderedByMirrorLocks`) and one writer at a time across processes (the store's locks) —
76
+ * because the "is this key already queued?" answer, the note staged in the mirror and the
77
+ * entry the queue takes are one decision: interleaved, they deliver in the wrong order or
78
+ * leave the mirror on the older note.
79
+ */
80
+ export declare function saveMemoryNote(input: MemorySaveInput): Promise<MemorySaveResult>;
81
+ /**
82
+ * Forget one note: retire it in the mirror and tell the provider, or queue the request.
83
+ *
84
+ * The reason is checked first, before the lock and before anything is written: a justification
85
+ * that is empty, carries a line break or runs past `MEMORY_FORGET_REASON_MAX_CHARS` is refused
86
+ * as `MEMORY_WRITE_REFUSED`, so no such text reaches the request the provider is pushed or the
87
+ * entry a queued forget leaves in the mirror's outbox.
88
+ *
89
+ * A mirror another writer owns is not this forget's to change, so it is coordinated exactly as
90
+ * a save is: this process's chain (`orderedByStore`) orders the forgets and saves of one mirror,
91
+ * and the store's lock file orders them across processes. Without the lock a forget could delete
92
+ * a note while another process's save restored or overwrote it, and the queue's order would
93
+ * describe writes that never happened in that order. A permanent refusal is the one answer
94
+ * undone, under `direct`, and it is rethrown; an absent slug is never queued.
95
+ */
96
+ export declare function forgetMemoryNote(input: MemoryForgetInput): Promise<MemoryForgetResult>;
97
+ /**
98
+ * Put one pending note in force in this mirror — or retire the note a recorded forget names.
99
+ *
100
+ * Only the note the mirror holds is consulted, so approval is a local transition rather than an
101
+ * authorization step: it asks no provider and writes nothing to the queue, and a forget waiting
102
+ * there is still waiting afterwards. What it does read besides the note is the mirror's
103
+ * pending-forget record — `pending-forgets.jsonl`, written by the local provider for a proposal
104
+ * forget — and the queue's held keys, because the index it regenerates carries those markers.
105
+ *
106
+ * An entry in the record is the one thing an approval can carry out that a rewrite cannot: the note
107
+ * is deleted, the entry is taken out of the record, the index is regenerated, and the note comes
108
+ * back as it was read with `status: "deleted"` — its `updatedAt` still says when it was written.
109
+ * That arm runs before the activation of a `pending` note, because a pending forget wins over a
110
+ * pending save: the agent asked for the note to go after asking for it to exist. A delete that
111
+ * throws leaves the entry where it is, so the next approval finds the forget again.
112
+ *
113
+ * The read and the rewrite are one decision, so they run under the same ordering and the same lock
114
+ * file a save and a forget use: this is a writer of the mirror, and what it approves must be the
115
+ * note the mirror holds now rather than a copy read before another writer's newer one landed.
116
+ * Waiting is the right answer here, unlike a save's — an approval has no queue to put its write on.
117
+ *
118
+ * A note already in force, with nothing recorded against it, is returned exactly as it stands —
119
+ * approving it again is not a rewrite, so its `updatedAt` still says when it was made active. A
120
+ * note that is history is not a note waiting for approval, and neither is one this mirror does
121
+ * not hold: both are `MEMORY_NOTE_NOT_FOUND`, because there is nothing to approve.
122
+ */
123
+ export declare function approveMemoryNote(input: MemoryApproveInput): Promise<MemoryNote>;