@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,471 @@
1
+ /**
2
+ * The memory operations: what an agent's intent becomes once the store agrees to
3
+ * keep it.
4
+ *
5
+ * `saveMemoryNote` is the write path, and the only place a `MemoryNoteWrite`
6
+ * becomes a `MemoryNote`. Validation comes first and everything else after it:
7
+ * `validateMemoryNoteWrite` owns what may be stored (`validate.ts` — the field
8
+ * limits and the secret scan), so a refusal is thrown as `MEMORY_WRITE_REFUSED`
9
+ * with that reason as its message and nothing on disk is touched. This file adds
10
+ * no rule of its own; it only decides what to do with the answer.
11
+ *
12
+ * A write that passes is staged in the mirror before any provider is asked, and the
13
+ * staging is what outlives a provider that cannot take it: a provider that does not
14
+ * answer, and a push that fails or times out, leave the note exactly where it is and
15
+ * put the request — under the id the note was staged with — on the outbox, so a later
16
+ * sync delivers what this session already remembers. Only a permanent refusal is
17
+ * undone: the note under the key is read before staging (`previous`) and written back
18
+ * — or removed, when there was none — because a refusal is the one answer that will
19
+ * never change. After acceptance there is no rollback either: the provider holds the
20
+ * note, so a mirror write that then fails leaves the staged note in place and reports
21
+ * the bookkeeping failure. See `refusal.ts` for what a refusal does to the queue and
22
+ * the mirror, and why each step stands on its own.
23
+ *
24
+ * The note is minted with its own UUID — which is also the request's client id, so the save
25
+ * and the write that delivers it are named by one id — starts `active` under `direct` and
26
+ * `pending` under `proposal`, and is stamped with the caller's clock. The provider is probed
27
+ * and pushed through their deadlines as `raceTimeout` races, and its answer, never the
28
+ * caller's guess, decides the note's final id and status.
29
+ *
30
+ * A key the outbox already holds a queued write for is not pushed at all: the new one joins
31
+ * the queue behind it, so the provider receives them in the order the agent made them. That
32
+ * order is only meaningful if two saves of the key cannot both decide it, so a mirror's saves
33
+ * run one after another — in this process (`orderedByMirrorLocks`) and across processes (the
34
+ * store's lock files, project and user alike). A contending save waits for the holder to
35
+ * release (`waitForTurn`) before it stages or queues, so a holder paused before enqueueing
36
+ * still keeps the earlier sequence; an entry for the key alone is not permission to proceed,
37
+ * because it may predate the current holder. Only a save fenced by a sync known to this
38
+ * process queues without staging — it leaves the mirror and index to that sync — and every
39
+ * save that stages refreshes the index, so the next session reads the note whether or not this
40
+ * session could deliver it.
41
+ *
42
+ * `forgetMemoryNote` is the other half, and it only retires a note the mirror holds: an absent
43
+ * slug is a refusal. It declares its queue entry *before* touching the note, so the intent is
44
+ * durable first; under `direct` the note then goes, kept aside for a refusal to put back, and
45
+ * under `proposal` it stays, with the marker coming from the outbox alone.
46
+ *
47
+ * `approveMemoryNote` is the transition a proposal waits for: a pending note is rewritten in force
48
+ * under the caller's clock and the index is regenerated from the mirror, a note already in force is
49
+ * returned as it stands, and a note that is history — or one this mirror does not hold — is the
50
+ * same `MEMORY_NOTE_NOT_FOUND` refusal a forget of it would be. A note the mirror's
51
+ * `pending-forgets.jsonl` record holds a forget for is retired instead of approved: the note is
52
+ * deleted, its entry comes out of the record and the index is regenerated, so the answer is that
53
+ * note, unchanged but for `status: "deleted"` — a recorded forget wins over a pending save. It asks
54
+ * no provider and writes nothing to the queue, but it writes the mirror and the record, so it takes
55
+ * the same ordering and the same locks a save takes. It reads the queue only to keep the index's
56
+ * marker, and approves no queued forget: an entry the outbox still holds stays the outbox's.
57
+ */
58
+ import { randomUUID } from "node:crypto";
59
+ import { getLogger } from "#src/infra/agent-logger";
60
+ import { NaxError } from "#src/infra/nax-error";
61
+ import { refreshMemoryIndex } from "./index-render.js";
62
+ import { clockOf, mirrorSyncInFlight, orderedByMirrorLocks, withMirrorLock, } from "./mirror-lock.js";
63
+ import { isMemoryPushRejected } from "./note.js";
64
+ import { createMemoryOutbox } from "./outbox.js";
65
+ import { discardRefused, restoreForgotten, restoreNote } from "./refusal.js";
66
+ import { GLOBAL_MEMORY_TIMERS, providerAnswers, raceTimeout } from "./support.js";
67
+ import { MEMORY_OP_TIMEOUT_MS, memoryNoteKey, } from "./types.js";
68
+ import { forgetReasonRefusal, validateMemoryNoteWrite } from "./validate.js";
69
+ const ERROR_STAGE = "memory";
70
+ /** The status a write starts in: a proposal is not in force until it is approved. */
71
+ const STATUS_BY_MODE = {
72
+ direct: "active",
73
+ proposal: "pending",
74
+ };
75
+ /** The status a forget rests in with no provider answer: retired, or still awaiting approval. */
76
+ const FORGET_STATUS_BY_MODE = {
77
+ direct: "deleted",
78
+ proposal: "pending",
79
+ };
80
+ /** The refusal a rejected write is thrown as: the validator's reason as the message, its field as context. */
81
+ function writeRefused(refusal) {
82
+ return new NaxError(refusal.reason, "MEMORY_WRITE_REFUSED", { stage: ERROR_STAGE, field: refusal.field });
83
+ }
84
+ /** The note a write becomes: a fresh id, the mode's starting status and the caller's clock. */
85
+ function stagedNote(input) {
86
+ const { write } = input;
87
+ return {
88
+ id: randomUUID(),
89
+ slug: write.slug,
90
+ type: write.type,
91
+ scope: write.scope,
92
+ description: write.description,
93
+ source: input.source,
94
+ updatedAt: clockOf(input)().toISOString(),
95
+ status: STATUS_BY_MODE[input.writeMode],
96
+ body: write.body,
97
+ };
98
+ }
99
+ /** The request one staged note is pushed as: the note's own id keys the write. */
100
+ function saveRequest(note, writeMode) {
101
+ return { kind: "save", clientId: note.id, scope: note.scope, slug: note.slug, writeMode, note };
102
+ }
103
+ /**
104
+ * The post-acceptance half: record what the provider made of the note.
105
+ *
106
+ * Only the provider's id and status are taken from the answer — the note's own
107
+ * source and timestamp are the caller's, and the delivery says what became of the
108
+ * write, not when it was made. This runs after the provider has accepted the
109
+ * write, so a failure here leaves the staged note in place: the mirror keeps the
110
+ * note the provider holds, and the bookkeeping failure reaches the caller. Rolling
111
+ * back instead would hand the mirror a note the provider no longer agrees with.
112
+ */
113
+ async function recordDelivered(input, note, pushed) {
114
+ await input.store.writeNote({ ...note, id: pushed.id, status: pushed.status });
115
+ }
116
+ /**
117
+ * Regenerate the indexes from what the store now holds, so the next session reads this write.
118
+ *
119
+ * The queue is read for the notes whose forget is still waiting: that marker is the outbox
120
+ * speaking, and an index rendered without it would report a retire that is still in flight as
121
+ * though it had never been asked for.
122
+ */
123
+ async function refreshIndex(input) {
124
+ await refreshMemoryIndex(input.store, { heldForgets: await input.outbox.heldKeys("forget") });
125
+ }
126
+ /** The undelivered answer: the write is on the queue, not delivered. */
127
+ function queued(note) {
128
+ return { slug: note.slug, status: note.status, delivered: false };
129
+ }
130
+ /**
131
+ * Queue a write this save could not deliver and rebuild the index from what the mirror holds:
132
+ * the staged note is what the mirror keeps, so the next session has to see it — whether this
133
+ * save staged it or it was already there when this one joined the queue behind another.
134
+ */
135
+ async function queueUnderLock(input, note, request) {
136
+ await input.outbox.enqueue(request);
137
+ await refreshIndex(input);
138
+ return queued(note);
139
+ }
140
+ /**
141
+ * The answer for a write that is already on the queue and whose staged note the mirror
142
+ * keeps: the entry is what makes it durable, so the index is the only thing left to rebuild.
143
+ */
144
+ async function queuedEntryResult(input, note) {
145
+ await refreshIndex(input);
146
+ return queued(note);
147
+ }
148
+ /**
149
+ * Queue a write another writer's mirror could not take: the entry is the only thing this
150
+ * save writes.
151
+ *
152
+ * The index is not rebuilt here on purpose: rendering it from a mirror its owner is still
153
+ * changing could publish their half-written state over the view this save refused to touch,
154
+ * and the owner's own refresh (or the sync that delivers this entry) is the one that counts.
155
+ * The request keeps the id the note was minted with, so the note, the entry and the write a
156
+ * later sync sends share one client id and a replay is recognisable.
157
+ */
158
+ async function queueFenced(input, note, request) {
159
+ await input.outbox.enqueue(request);
160
+ return queued(note);
161
+ }
162
+ /**
163
+ * Stage the note in the mirror and declare the write on the queue, while holding the lock.
164
+ *
165
+ * The note under the key is read first, because a permanent refusal has to be able to put it
166
+ * back. A key the outbox already holds a queued write for is not taken any further: this
167
+ * write joins the queue behind the one already waiting, and the order the agent wrote them
168
+ * in survives. Otherwise the note is staged and its request goes on the queue *before* the
169
+ * provider is asked. The lock covers both declaration and delivery, so a contending
170
+ * save cannot take an earlier sequence even if this holder is paused before enqueueing.
171
+ * A delivery that succeeds takes the entry back off again.
172
+ */
173
+ async function deliverLocked(input) {
174
+ const note = stagedNote(input);
175
+ const request = saveRequest(note, input.writeMode);
176
+ const previous = await input.store.readNote(input.write.scope, input.write.slug);
177
+ await input.store.writeNote(note);
178
+ if ((await input.outbox.heldKeys()).has(memoryNoteKey(note.scope, note.slug))) {
179
+ return await queueUnderLock(input, note, request);
180
+ }
181
+ await input.outbox.enqueue(request);
182
+ return await handOver(input, note, previous);
183
+ }
184
+ /**
185
+ * Ask the provider for the note the queue already carries, and take the entry back on success.
186
+ *
187
+ * Anything short of an acceptance leaves the entry where it is and reports the save as
188
+ * undelivered; the staged note stays too, because the local write is the newer fact and is
189
+ * exactly what the queue will deliver.
190
+ *
191
+ * `MEMORY_PUSH_REJECTED` is the provider saying it will never take this write, so queueing it
192
+ * would only repeat the refusal: the entry comes back off, the mirror is put back the way it
193
+ * was, and the provider's error reaches the caller. Only the provider's answer changes the
194
+ * note's id and status, and only after it has taken the write — the delivered arm is the one
195
+ * arm that cannot be undone.
196
+ */
197
+ async function handOver(input, note, previous) {
198
+ const timers = input.timers ?? GLOBAL_MEMORY_TIMERS;
199
+ if (!(await providerAnswers(input.provider, timers)))
200
+ return await queuedEntryResult(input, note);
201
+ let answer;
202
+ try {
203
+ answer = await raceTimeout(input.provider.push(saveRequest(note, input.writeMode)), MEMORY_OP_TIMEOUT_MS, timers);
204
+ }
205
+ catch (error) {
206
+ if (!isMemoryPushRejected(error))
207
+ return await queuedEntryResult(input, note);
208
+ await discardRefused(input, note.id);
209
+ await restoreNote(input, note.scope, note.slug, previous);
210
+ throw error;
211
+ }
212
+ await input.outbox.remove(note.id);
213
+ await recordDelivered(input, note, answer);
214
+ await refreshIndex(input);
215
+ return { slug: note.slug, status: answer.status, delivered: true };
216
+ }
217
+ /**
218
+ * Store one note and deliver it to the provider, or queue it for the next sync.
219
+ *
220
+ * The validator decides first, so a refusal leaves the store exactly as it found it.
221
+ * Everything after it runs in the mirror's own order — one save at a time in this process
222
+ * (`orderedByMirrorLocks`) and one writer at a time across processes (the store's locks) —
223
+ * because the "is this key already queued?" answer, the note staged in the mirror and the
224
+ * entry the queue takes are one decision: interleaved, they deliver in the wrong order or
225
+ * leave the mirror on the older note.
226
+ */
227
+ export async function saveMemoryNote(input) {
228
+ const refusal = validateMemoryNoteWrite(input.write, input.source);
229
+ if (refusal !== null)
230
+ throw writeRefused(refusal);
231
+ return await orderedByMirrorLocks(input.store, () => commitSave(input));
232
+ }
233
+ /**
234
+ * The ordered half of a save: take the mirror's lock, and stage and declare under it.
235
+ *
236
+ * A mirror another writer owns is not this save's to change, and nothing is lost by
237
+ * leaving it alone: the note goes on the outbox under its own client id, and the sync that
238
+ * does own the mirror delivers it in sequence. Staging into a mirror someone else is
239
+ * draining or pulling would be the very interleaving the lock is there to prevent.
240
+ *
241
+ * A sync in this process is the one owner that needs no wait: it queues no save of its own,
242
+ * so this write can join the queue immediately. Any other owner has to release the lock
243
+ * before this save stages or declares (`waitForTurn`), preserving sequence allocation even
244
+ * when the holder is paused before enqueueing.
245
+ */
246
+ async function commitSave(input) {
247
+ if (mirrorSyncInFlight(input.store))
248
+ return await queueWithoutMirror(input);
249
+ return await withMirrorLock(input, () => deliverLocked(input));
250
+ }
251
+ /**
252
+ * The answer when another writer owns the mirror: the write is queued and the mirror is
253
+ * left alone.
254
+ *
255
+ * Nothing is staged, so no index is rebuilt either — the mirror still holds what its owner
256
+ * put there, and this note reaches it when a sync delivers the entry. The queue is the one
257
+ * part of the mirror any writer may add to: an entry is a whole file written atomically,
258
+ * named by its sequence and client id.
259
+ */
260
+ async function queueWithoutMirror(input) {
261
+ const note = stagedNote(input);
262
+ return await queueFenced(input, note, saveRequest(note, input.writeMode));
263
+ }
264
+ /** The refusal a note this mirror does not hold — or one that is history — is thrown as. */
265
+ function noteNotFound(input) {
266
+ return new NaxError(`memory note "${memoryNoteKey(input.scope, input.slug)}" is not in this mirror`, "MEMORY_NOTE_NOT_FOUND", { stage: ERROR_STAGE, scope: input.scope, slug: input.slug });
267
+ }
268
+ /** The request one forget is pushed as: its own UUID keys the write, and the reason rides along. */
269
+ function forgetRequest(input) {
270
+ const { scope, slug, writeMode, reason } = input;
271
+ return { kind: "forget", clientId: randomUUID(), scope, slug, writeMode, reason };
272
+ }
273
+ /** Queue a forget this operation could not deliver, and rebuild the index so its marker shows. */
274
+ async function queuedForget(input) {
275
+ await refreshIndex(input);
276
+ return { slug: input.slug, status: FORGET_STATUS_BY_MODE[input.writeMode], delivered: false };
277
+ }
278
+ /**
279
+ * The ordered half of a forget: join a sync that owns the mirror, or take the lock and decide
280
+ * under it. Nothing is read from the mirror before the lock is held, so what a forget retires
281
+ * and what the queue is asked about are both the mirror's current answer rather than a stale one.
282
+ */
283
+ async function commitForget(input) {
284
+ const request = forgetRequest(input);
285
+ if (mirrorSyncInFlight(input.store))
286
+ return await queueFencedForget(input, request);
287
+ return await withMirrorLock(input, () => forgetLocked(input, request));
288
+ }
289
+ /**
290
+ * The answer while a sync of this process owns the mirror: the request is queued, and the
291
+ * mirror is only read.
292
+ *
293
+ * The note is still required — a forget of a slug the mirror does not hold is refused rather
294
+ * than queued — but nothing is deleted and no index is rendered: an index built from a mirror
295
+ * the sync is halfway through changing would publish its half-written state, and the sync's own
296
+ * refresh is the one that counts.
297
+ */
298
+ async function queueFencedForget(input, request) {
299
+ if ((await input.store.readNote(input.scope, input.slug)) === null)
300
+ throw noteNotFound(input);
301
+ await input.outbox.enqueue(request);
302
+ return { slug: input.slug, status: FORGET_STATUS_BY_MODE[input.writeMode], delivered: false };
303
+ }
304
+ /**
305
+ * Retire the note and deliver the request, while the mirror's lock is held.
306
+ *
307
+ * The intent is declared on the queue *before* the note it names is touched. Under `direct`
308
+ * this method deletes that note, and a delete no durable request records is a retire the
309
+ * provider never hears about — the next pull restores the note and the agent's decision is
310
+ * silently undone. Writing the entry first costs one file write and makes every later failure
311
+ * recoverable: a failed delete leaves the entry to be replayed, a transient push failure
312
+ * leaves it queued, and only a permanent refusal takes it back off (with the note put back,
313
+ * under `direct`, because a refusal will never change).
314
+ */
315
+ async function forgetLocked(input, request) {
316
+ const previous = await input.store.readNote(input.scope, input.slug);
317
+ if (previous === null)
318
+ throw noteNotFound(input);
319
+ // Read before the entry goes on: the one this call is about to add cannot be "earlier work".
320
+ const waiting = (await input.outbox.heldKeys()).has(memoryNoteKey(input.scope, input.slug));
321
+ await input.outbox.enqueue(request);
322
+ if (input.writeMode === "direct")
323
+ await input.store.deleteNote(input.scope, input.slug);
324
+ if (waiting)
325
+ return await queuedForget(input);
326
+ return await handOverForget(input, previous, request);
327
+ }
328
+ /**
329
+ * Ask the provider to forget the note this operation already queued.
330
+ *
331
+ * Anything short of an acceptance leaves the entry where it is: the entry is what makes the
332
+ * retire durable, not this attempt. Acceptance and a permanent refusal both take it back off
333
+ * — retrying a refusal would repeat it — and the refusal puts the note back under `direct`.
334
+ */
335
+ async function handOverForget(input, previous, request) {
336
+ const timers = input.timers ?? GLOBAL_MEMORY_TIMERS;
337
+ if (!(await providerAnswers(input.provider, timers)))
338
+ return await queuedForget(input);
339
+ let answer;
340
+ try {
341
+ answer = await raceTimeout(input.provider.push(request), MEMORY_OP_TIMEOUT_MS, timers);
342
+ }
343
+ catch (error) {
344
+ if (!isMemoryPushRejected(error))
345
+ return await queuedForget(input);
346
+ // The refusal is the caller's answer; the entry and the note are put back regardless of
347
+ // whether removing it works, so a refused forget never leaves the note deleted.
348
+ await discardRefused(input, request.clientId);
349
+ if (input.writeMode === "direct")
350
+ await restoreForgotten(input, previous);
351
+ throw error;
352
+ }
353
+ await input.outbox.remove(request.clientId);
354
+ await refreshIndex(input);
355
+ return { slug: input.slug, status: answer.status, delivered: true };
356
+ }
357
+ /**
358
+ * Forget one note: retire it in the mirror and tell the provider, or queue the request.
359
+ *
360
+ * The reason is checked first, before the lock and before anything is written: a justification
361
+ * that is empty, carries a line break or runs past `MEMORY_FORGET_REASON_MAX_CHARS` is refused
362
+ * as `MEMORY_WRITE_REFUSED`, so no such text reaches the request the provider is pushed or the
363
+ * entry a queued forget leaves in the mirror's outbox.
364
+ *
365
+ * A mirror another writer owns is not this forget's to change, so it is coordinated exactly as
366
+ * a save is: this process's chain (`orderedByStore`) orders the forgets and saves of one mirror,
367
+ * and the store's lock file orders them across processes. Without the lock a forget could delete
368
+ * a note while another process's save restored or overwrote it, and the queue's order would
369
+ * describe writes that never happened in that order. A permanent refusal is the one answer
370
+ * undone, under `direct`, and it is rethrown; an absent slug is never queued.
371
+ */
372
+ export async function forgetMemoryNote(input) {
373
+ const refusal = forgetReasonRefusal(input.reason);
374
+ if (refusal !== null)
375
+ throw writeRefused(refusal);
376
+ return await orderedByMirrorLocks(input.store, () => commitForget(input));
377
+ }
378
+ /** The statuses an approval may not bring into force: a note that is already history. */
379
+ const RETIRED_STATUSES = new Set(["superseded", "rejected", "deleted"]);
380
+ /** The note one approval rewrites: the same note, in force, stamped with the caller's clock. */
381
+ function inForce(note, now) {
382
+ return { ...note, status: "active", updatedAt: now().toISOString() };
383
+ }
384
+ /** The mirror's own queue, as an approval reads it: where the index's forget markers live. */
385
+ function mirrorOutbox(store) {
386
+ return createMemoryOutbox({ dir: store.paths.outboxDir });
387
+ }
388
+ /** The mirror's lock as this approval takes it: the caller's seams, else the process defaults. */
389
+ function lockInputOf(input) {
390
+ return {
391
+ store: input.store,
392
+ logger: input.logger ?? getLogger(),
393
+ now: input.now,
394
+ timers: input.timers,
395
+ isAlive: input.isAlive,
396
+ };
397
+ }
398
+ /**
399
+ * The locked half of an approval: read, decide and rewrite while no other writer of this
400
+ * mirror can change the note in between — the read and the write are one decision, which is
401
+ * the whole reason this takes the lock at all.
402
+ *
403
+ * A recorded pending forget is checked before the note's own status, because a pending forget
404
+ * wins over a pending save: the note the mirror holds was written first, and the forget of it
405
+ * came after. A note that is history, or one the mirror does not hold, is the refusal it was
406
+ * before the record is consulted at all.
407
+ */
408
+ async function approveLocked(input) {
409
+ const note = await input.store.readNote(input.scope, input.slug);
410
+ if (note === null || RETIRED_STATUSES.has(note.status))
411
+ throw noteNotFound(input);
412
+ if (await holdsRecordedForget(input))
413
+ return await retireRecorded(input, note);
414
+ if (note.status === "active")
415
+ return note;
416
+ const approved = inForce(note, clockOf(input));
417
+ await input.store.writeNote(approved);
418
+ await refreshIndex({ store: input.store, outbox: mirrorOutbox(input.store) });
419
+ return approved;
420
+ }
421
+ /** Whether the mirror's record holds a pending forget for the key this approval names. */
422
+ async function holdsRecordedForget(input) {
423
+ const key = memoryNoteKey(input.scope, input.slug);
424
+ const recorded = await input.store.readPendingForgets();
425
+ return recorded.some((entry) => memoryNoteKey(entry.scope, entry.slug) === key);
426
+ }
427
+ /**
428
+ * The note a recorded forget retires: the note goes, then its entry comes out of the record.
429
+ *
430
+ * That order is the failure contract: a delete that throws leaves the record holding the entry, so
431
+ * the forget is still outstanding and still findable. The record is re-read after the delete — an
432
+ * entry another writer appended meanwhile survives — and the note comes back as it was read, with
433
+ * `status: "deleted"` and its own `updatedAt`: an approval retires a note, it does not rewrite one.
434
+ */
435
+ async function retireRecorded(input, note) {
436
+ const key = memoryNoteKey(input.scope, input.slug);
437
+ await input.store.deleteNote(input.scope, input.slug);
438
+ const recorded = await input.store.readPendingForgets();
439
+ await input.store.writePendingForgets(recorded.filter((entry) => memoryNoteKey(entry.scope, entry.slug) !== key));
440
+ await refreshIndex({ store: input.store, outbox: mirrorOutbox(input.store) });
441
+ return { ...note, status: "deleted" };
442
+ }
443
+ /**
444
+ * Put one pending note in force in this mirror — or retire the note a recorded forget names.
445
+ *
446
+ * Only the note the mirror holds is consulted, so approval is a local transition rather than an
447
+ * authorization step: it asks no provider and writes nothing to the queue, and a forget waiting
448
+ * there is still waiting afterwards. What it does read besides the note is the mirror's
449
+ * pending-forget record — `pending-forgets.jsonl`, written by the local provider for a proposal
450
+ * forget — and the queue's held keys, because the index it regenerates carries those markers.
451
+ *
452
+ * An entry in the record is the one thing an approval can carry out that a rewrite cannot: the note
453
+ * is deleted, the entry is taken out of the record, the index is regenerated, and the note comes
454
+ * back as it was read with `status: "deleted"` — its `updatedAt` still says when it was written.
455
+ * That arm runs before the activation of a `pending` note, because a pending forget wins over a
456
+ * pending save: the agent asked for the note to go after asking for it to exist. A delete that
457
+ * throws leaves the entry where it is, so the next approval finds the forget again.
458
+ *
459
+ * The read and the rewrite are one decision, so they run under the same ordering and the same lock
460
+ * file a save and a forget use: this is a writer of the mirror, and what it approves must be the
461
+ * note the mirror holds now rather than a copy read before another writer's newer one landed.
462
+ * Waiting is the right answer here, unlike a save's — an approval has no queue to put its write on.
463
+ *
464
+ * A note already in force, with nothing recorded against it, is returned exactly as it stands —
465
+ * approving it again is not a rewrite, so its `updatedAt` still says when it was made active. A
466
+ * note that is history is not a note waiting for approval, and neither is one this mirror does
467
+ * not hold: both are `MEMORY_NOTE_NOT_FOUND`, because there is nothing to approve.
468
+ */
469
+ export async function approveMemoryNote(input) {
470
+ return await orderedByMirrorLocks(input.store, () => withMirrorLock(lockInputOf(input), () => approveLocked(input)));
471
+ }
@@ -0,0 +1,35 @@
1
+ import type { MemoryWriteRequest } from "./provider-types.ts";
2
+ /** The write the caller hands over: it owns the content, the outbox may own the id. */
3
+ type OutboxRequest = Omit<MemoryWriteRequest, "clientId"> & {
4
+ clientId?: string;
5
+ };
6
+ /** One queued write as the outbox holds it: the request plus where it sits in the order. */
7
+ export type MemoryOutboxEntry = MemoryWriteRequest & {
8
+ readonly seq: number;
9
+ readonly enqueuedAt: string;
10
+ readonly state: "queued" | "rejected";
11
+ /** The rejection reason once rejected; the request's own reason until then. */
12
+ readonly reason?: string;
13
+ /** A reason the request itself carried (a forget's justification), kept across a rejection. */
14
+ readonly requestReason?: string;
15
+ };
16
+ /** The durable queue of unacknowledged writes. */
17
+ export interface MemoryOutbox {
18
+ /**
19
+ * Take one write, stamped with its sequence. A request the queue cannot carry is refused
20
+ * before any file is written: `MEMORY_WRITE_REFUSED` for a reason past
21
+ * `MEMORY_FORGET_REASON_MAX_CHARS` (or empty, or spanning lines) and
22
+ * `MEMORY_CLIENT_ID_INVALID` for an id that is not one.
23
+ */
24
+ enqueue(request: OutboxRequest): Promise<MemoryOutboxEntry>;
25
+ list(): Promise<MemoryOutboxEntry[]>;
26
+ listInvalid(): Promise<string[]>;
27
+ remove(clientId: string): Promise<void>;
28
+ markRejected(clientId: string, reason: string): Promise<void>;
29
+ heldKeys(kind?: "save" | "forget"): Promise<ReadonlySet<string>>;
30
+ }
31
+ export declare function createMemoryOutbox(input: {
32
+ readonly dir: string;
33
+ readonly now?: () => Date;
34
+ }): MemoryOutbox;
35
+ export {};