@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,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 {};
|