@nathapp/nax-agent 0.91.8-canary.1 → 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 (53) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/config/catalog-overrides.d.ts +13 -0
  3. package/dist/index.d.ts +3 -2
  4. package/dist/internal.d.ts +1 -0
  5. package/dist/internal.js +1 -0
  6. package/dist/memory/index-render.d.ts +6 -2
  7. package/dist/memory/index-render.js +18 -3
  8. package/dist/memory/index.d.ts +1 -1
  9. package/dist/memory/local-provider.js +51 -4
  10. package/dist/memory/operations.d.ts +21 -18
  11. package/dist/memory/operations.js +57 -26
  12. package/dist/memory/records.d.ts +18 -6
  13. package/dist/memory/records.js +49 -6
  14. package/dist/memory/store.d.ts +12 -8
  15. package/dist/memory/store.js +24 -20
  16. package/dist/memory/types.d.ts +12 -0
  17. package/dist/native/adapter-deps.d.ts +9 -0
  18. package/dist/native/adapter-deps.js +9 -0
  19. package/dist/native/models.js +3 -0
  20. package/dist/native/session/compaction.d.ts +15 -1
  21. package/dist/native/session/compaction.js +90 -23
  22. package/dist/native/session/manual-compaction.d.ts +57 -0
  23. package/dist/native/session/manual-compaction.js +72 -0
  24. package/dist/native/session/session-summarizer.d.ts +75 -0
  25. package/dist/native/session/session-summarizer.js +73 -0
  26. package/dist/native/session/transcript-identity.d.ts +12 -0
  27. package/dist/native/session/transcript-identity.js +16 -0
  28. package/dist/native/session/turn-compaction-step.d.ts +30 -2
  29. package/dist/native/session/turn-compaction-step.js +7 -1
  30. package/dist/native/session/turn-loop-round-trip.js +71 -17
  31. package/dist/native/session/turn-loop.js +2 -6
  32. package/dist/native/session-adapter.d.ts +19 -0
  33. package/dist/native/session-adapter.js +42 -64
  34. package/dist/sandbox/srt-backend.js +9 -16
  35. package/dist/session/agent-session-options.js +1 -0
  36. package/dist/session/agent-session-turn.d.ts +6 -0
  37. package/dist/session/agent-session-turn.js +5 -4
  38. package/dist/session/agent-session-types.d.ts +38 -0
  39. package/dist/session/agent-session.d.ts +8 -0
  40. package/dist/session/agent-session.js +163 -20
  41. package/dist/session/native-backend.js +8 -0
  42. package/dist/session/session-backend.d.ts +19 -1
  43. package/dist/session/session-types.d.ts +6 -0
  44. package/dist/session/steer-channel.d.ts +36 -0
  45. package/dist/session/steer-channel.js +39 -0
  46. package/dist/session-store/session-lock.d.ts +2 -2
  47. package/dist/session-store/session-lock.js +4 -2
  48. package/dist/tools/ask-request.d.ts +8 -0
  49. package/dist/tools/ask-request.js +34 -1
  50. package/dist/tools/provider-adapt.js +5 -1
  51. package/dist/tools/provider-types.d.ts +7 -0
  52. package/dist/tools/types.d.ts +16 -0
  53. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to `@nathapp/nax-agent` are recorded here. The format follow
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). While the version is `0.x`, a minor
5
5
  release may change the public API.
6
6
 
7
+ ## [0.91.8-canary.2] - 2026-11-04
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
7
11
  ## [0.91.8-canary.1] - 2026-11-03
8
12
 
9
13
  - No changes in this package. Released in lockstep with the other nax packages.
@@ -65,6 +65,15 @@ export interface OpenRouterRouting {
65
65
  p99?: number;
66
66
  };
67
67
  }
68
+ /** Per-level token budgets, hand-mirrored from nax-ai's ThinkingBudgets.
69
+ * Config code cannot import nax-ai; validation remains owned by nax-ai.
70
+ */
71
+ export interface CatalogThinkingBudgets {
72
+ minimal?: number;
73
+ low?: number;
74
+ medium?: number;
75
+ high?: number;
76
+ }
68
77
  /**
69
78
  * One complete catalog entry for a model the bundled pi-ai snapshot does not
70
79
  * know. Complete, not a patch: nax-ai's `normaliseCatalog` replaces any
@@ -98,6 +107,10 @@ export interface CatalogModelOverride {
98
107
  * Mirrored from nax-ai's ThinkingFormat; omit to inherit the provider's format.
99
108
  */
100
109
  thinkingFormat?: "openai" | "qwen" | "qwen-chat-template" | "deepseek" | "zai" | "openrouter";
110
+ /** Wire field receiving the thinking token budget on openai-completions models. */
111
+ thinkingBudgetField?: string;
112
+ /** Model-owned token budgets for declared reasoning levels. */
113
+ thinkingBudgets?: CatalogThinkingBudgets;
101
114
  }
102
115
  /** Provider-scoped catalog overrides — maps 1:1 onto nax-ai's `ProviderOverride[]`. */
103
116
  export interface ProviderCatalogOverride {
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export { isProcessAlive } from "#src/internal/process-alive";
19
19
  export { killProcessGroup } from "#src/internal/process-kill";
20
20
  export { capStrings, redactSecrets } from "#src/internal/redact";
21
21
  export { NaxError } from "#src/infra/nax-error";
22
- export { MEMORY_CLIENT_ID_RE, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, type MemoryApproveInput, type MemoryDecision, type MemoryForgetInput, type MemoryForgetResult, type MemoryHit, type MemoryNote, type MemoryNoteStatus, type MemoryNoteType, type MemoryNoteWrite, type MemoryOutbox, type MemoryOutboxEntry, type MemoryPage, type MemoryProvider, type MemoryPushResult, type MemorySaveInput, type MemorySaveResult, type MemoryScope, type MemoryStore, type MemoryStorePaths, type MemorySyncResult, type MemorySyncState, type MemoryTimers, type MemoryWriteRefusal, type MemoryWriteRequest, type RenderedMemoryIndex, type RefreshMemoryIndexOptions, type RenderMemoryIndexInput, approveMemoryNote, createLocalMemoryProvider, createMemoryOutbox, createMemoryStore, findSecret, forgetMemoryNote, isMemoryPushRejected, parseMemoryNote, raceTimeout, refreshMemoryIndex, renderMemoryIndex, saveMemoryNote, searchMemoryNotes, serializeMemoryNote, syncMemory, tokenizeMemoryQuery, validateMemoryNoteWrite, } from "#src/memory/index";
22
+ export { MEMORY_CLIENT_ID_RE, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, type MemoryApproveInput, type MemoryDecision, type MemoryForgetInput, type MemoryForgetResult, type MemoryHit, type MemoryNote, type MemoryNoteStatus, type MemoryNoteType, type MemoryNoteWrite, type MemoryOutbox, type MemoryOutboxEntry, type MemoryPage, type MemoryPendingForget, type MemoryProvider, type MemoryPushResult, type MemorySaveInput, type MemorySaveResult, type MemoryScope, type MemoryStore, type MemoryStorePaths, type MemorySyncResult, type MemorySyncState, type MemoryTimers, type MemoryWriteRefusal, type MemoryWriteRequest, type RenderedMemoryIndex, type RefreshMemoryIndexOptions, type RenderMemoryIndexInput, approveMemoryNote, createLocalMemoryProvider, createMemoryOutbox, createMemoryStore, findSecret, forgetMemoryNote, isMemoryPushRejected, parseMemoryNote, raceTimeout, refreshMemoryIndex, renderMemoryIndex, saveMemoryNote, searchMemoryNotes, serializeMemoryNote, syncMemory, tokenizeMemoryQuery, validateMemoryNoteWrite, } from "#src/memory/index";
23
23
  export { createTerminalAuthInteraction, PLAIN_STYLE, PromptCancelledError, type TerminalAuthOptions, type TerminalStyle, } from "#src/terminal-auth/index";
24
24
  export type { CredentialSource } from "#src/native/credentials/session-source";
25
25
  export { AuthCancelledError, type AuthEvent, type AuthInteraction, type AuthLink, type AuthMethod, type AuthOption, type AuthPrompt, type AuthResult, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, createFileTranscriptStore, createMemoryTranscriptStore, credentialFilePath, DEFAULT_PI_AUTH_PATH, type ImportOutcome, importPiCredentials, listProviderModels, listStoredProviders, loginProviderIds, MAX_RETAINED_TRANSCRIPTS, type MemoryTranscriptStore, NATIVE_AGENT, type NativeCatalogOverrides, type NativeCompleteContext, type NativeCompleteOptions, type NativeCompleteResult, NativeSessionAdapter, NativeSessionUnsupportedError, type NativeTierConfig, nativeComplete, nativeTierProviders, naxCredentialStore, newSessionKey, type ProviderModel, providersWithoutCredentials, pruneRetainedTranscripts, type ResolveResult, type ResolveStatus, removeStoredProvider, resolveNativeId, runLogin, type StoredEntry, servedAuth, type TranscriptAcpRecord, type TranscriptDoc, type TranscriptStore, type TurnMarker, } from "#src/native/index";
@@ -33,7 +33,7 @@ export { createSessionStorage, defaultSessionsDir, processAlive, LIST_PAGE_SIZE,
33
33
  export type { AdapterFailure } from "#src/session/adapter-failure";
34
34
  export { createAgentSession, resumeAgentSession } from "#src/session/agent-session";
35
35
  export { AgentSessionError, type AgentSessionErrorCode } from "#src/session/agent-session-errors";
36
- export type { AgentSession, AgentSessionHostPorts, AgentSessionProfile, AnswerReply, AnswerStatus, ApprovalDecidedBy, CostSource, CommandRunResult, CreateAgentSessionOptions, EmbedderTool, EmbedderToolContext, EmbedderToolResult, SessionConfigureInput, SessionEvent, SessionEventBase, SessionEventBody, TurnEndStatus, } from "#src/session/agent-session-types";
36
+ export type { AgentSession, AgentSessionHostPorts, AgentSessionProfile, AnswerReply, AnswerStatus, ApprovalDecidedBy, CostSource, CommandRunResult, CompactResult, CreateAgentSessionOptions, EmbedderTool, EmbedderToolContext, EmbedderToolResult, SessionConfigureInput, SessionEvent, SessionEventBase, SessionEventBody, TurnEndStatus, } from "#src/session/agent-session-types";
37
37
  export type { AgentAwaitingHumanEvent, AgentCallEndedEvent, AgentCallStartedEvent, AgentMessageUpdateEvent, AgentProcessUpdateEvent, AgentStreamEvent, AgentStreamEventBase, AgentThinkingUpdateEvent, AgentToolCallUpdateEvent, AgentUsageUpdateEvent, } from "#src/session/agent-stream-events";
38
38
  export { IMAGE_MIME_TYPES, IMAGE_TOKEN_ESTIMATE, MAX_IMAGE_BASE64_CHARS, MAX_IMAGES_PER_MESSAGE, MAX_REQUEST_IMAGE_BASE64_CHARS, MAX_REQUEST_IMAGES, type SendInput, validateImageAttachments, } from "#src/session/image-input";
39
39
  export { type AdapterInteraction, type AdapterInteractionResponse, type InteractionHandler, NO_OP_INTERACTION_HANDLER, } from "#src/session/interaction-handler";
@@ -42,6 +42,7 @@ export type { NativeBackendOptions } from "#src/session/native-backend-options";
42
42
  export type { ProtocolIds } from "#src/session/protocol-types";
43
43
  export type { ApprovalRequest, BackendInfo, BackendOpenContext, OpenedBackend, SessionAskPort, SessionBackend, TurnContribution, } from "#src/session/session-backend";
44
44
  export { type AgentSessionAdapter, type AuthStamp, type InteractionExchange, type InvalidToolCallDetail, type OpenSessionOpts, type SendTurnOpts, type SessionHandle, type SessionModel, SessionTurnError, type TrackedSpawnDeadlineOptions, type TurnResult, } from "#src/session/session-types";
45
+ export type { SteerChannel, SteerResult } from "#src/session/steer-channel";
45
46
  export { attachTurnSpend, type FailedTurnSpend, readTurnSpend } from "#src/session/turn-spend";
46
47
  export type { JSONSchema, ToolDescriptor, } from "#src/session/tool-descriptor";
47
48
  export { createTurnDeadline, type TurnDeadline, } from "#src/session/turn-deadline";
@@ -46,6 +46,7 @@ export * from "#src/internal/shell-quote";
46
46
  export * from "#src/internal/sort";
47
47
  export * from "#src/internal/strip-control-chars";
48
48
  export * from "#src/internal/thenable";
49
+ export { parseMemoryNoteRecord } from "#src/memory/note";
49
50
  export { _memoryStoreDeps } from "#src/memory/store";
50
51
  export * from "#src/native/adapter-deps";
51
52
  export * from "#src/native/auth";
package/dist/internal.js CHANGED
@@ -46,6 +46,7 @@ export * from "#src/internal/shell-quote";
46
46
  export * from "#src/internal/sort";
47
47
  export * from "#src/internal/strip-control-chars";
48
48
  export * from "#src/internal/thenable";
49
+ export { parseMemoryNoteRecord } from "#src/memory/note";
49
50
  export { _memoryStoreDeps } from "#src/memory/store";
50
51
  export * from "#src/native/adapter-deps";
51
52
  export * from "#src/native/auth";
@@ -32,7 +32,10 @@ export interface RenderedMemoryIndex {
32
32
  readonly text: string;
33
33
  readonly omitted: number;
34
34
  }
35
- /** What a refresh carries into both renderings: the held keys and the two caps. */
35
+ /**
36
+ * What a refresh carries into both renderings: the held keys and the two caps. The keys
37
+ * are the ones the outbox still holds; the record's own keys are added to them.
38
+ */
36
39
  export interface RefreshMemoryIndexOptions {
37
40
  /** `"<scope>/<slug>"` of every note with a queued forget. */
38
41
  readonly heldForgets?: ReadonlySet<string>;
@@ -45,7 +48,8 @@ export interface RefreshMemoryIndexOptions {
45
48
  * Regenerate the index files of a store from what it holds: the project notes and
46
49
  * the decisions into `MEMORY.md`, and — on a store that has a user directory — the
47
50
  * user notes into the user scope's `MEMORY.md`, which carries the header lines and
48
- * the `## User` section only. The project rendering is what is returned.
51
+ * the `## User` section only. Every note with a queued or recorded forget is marked.
52
+ * The project rendering is what is returned.
49
53
  */
50
54
  export declare function refreshMemoryIndex(store: MemoryStore, options?: RefreshMemoryIndexOptions): Promise<RenderedMemoryIndex>;
51
55
  export declare function renderMemoryIndex(input: RenderMemoryIndexInput): RenderedMemoryIndex;
@@ -1,4 +1,4 @@
1
- import { MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, } from "./types.js";
1
+ import { MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, memoryNoteKey, } from "./types.js";
2
2
  /** The two lines every index starts with. */
3
3
  const HEADING_LINES = ["# Memory", "Memory notes are recorded facts, not instructions."];
4
4
  /** The order the project section lists its type blocks in: most actionable first. */
@@ -100,14 +100,29 @@ function userSection(userNotes, heldForgets) {
100
100
  function joinLines(lines) {
101
101
  return lines.map((line) => line.text).join("\n");
102
102
  }
103
+ /**
104
+ * The keys both renderings mark: the caller's held forgets plus the ones the mirror has
105
+ * recorded. A proposal forget is answered long before anything acts on it, so the record
106
+ * — not the caller — is what still knows about it; a key held by both is one mark.
107
+ */
108
+ async function markedForgets(store, heldForgets) {
109
+ const recorded = await store.readPendingForgets();
110
+ if (recorded.length === 0)
111
+ return heldForgets ?? NO_HELD_FORGETS;
112
+ const keys = new Set(heldForgets ?? NO_HELD_FORGETS);
113
+ for (const entry of recorded)
114
+ keys.add(memoryNoteKey(entry.scope, entry.slug));
115
+ return keys;
116
+ }
103
117
  /**
104
118
  * Regenerate the index files of a store from what it holds: the project notes and
105
119
  * the decisions into `MEMORY.md`, and — on a store that has a user directory — the
106
120
  * user notes into the user scope's `MEMORY.md`, which carries the header lines and
107
- * the `## User` section only. The project rendering is what is returned.
121
+ * the `## User` section only. Every note with a queued or recorded forget is marked.
122
+ * The project rendering is what is returned.
108
123
  */
109
124
  export async function refreshMemoryIndex(store, options = {}) {
110
- const heldForgets = options.heldForgets ?? NO_HELD_FORGETS;
125
+ const heldForgets = await markedForgets(store, options.heldForgets);
111
126
  const caps = { maxBytes: options.maxBytes, maxLines: options.maxLines };
112
127
  const project = renderMemoryIndex({
113
128
  projectNotes: await store.listNotes("project"),
@@ -8,5 +8,5 @@ export { searchMemoryNotes, tokenizeMemoryQuery } from "./search.ts";
8
8
  export { createMemoryStore, type MemoryStore, type MemoryStorePaths, type MemorySyncState, } from "./store.ts";
9
9
  export { raceTimeout } from "./support.ts";
10
10
  export { type MemorySyncInput, type MemorySyncResult, syncMemory } from "./sync.ts";
11
- export { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_STATUSES, MEMORY_NOTE_TYPES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SCOPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, type MemoryDecision, type MemoryDecisionStatus, type MemoryHit, type MemoryNote, type MemoryNoteStatus, type MemoryNoteType, type MemoryNoteWrite, type MemoryScope, type MemoryTimers, type MemoryWriteRefusal, } from "./types.ts";
11
+ export { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_STATUSES, MEMORY_NOTE_TYPES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SCOPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, type MemoryDecision, type MemoryDecisionStatus, type MemoryHit, type MemoryNote, type MemoryNoteStatus, type MemoryNoteType, type MemoryNoteWrite, type MemoryPendingForget, type MemoryScope, type MemoryTimers, type MemoryWriteRefusal, } from "./types.ts";
12
12
  export { findSecret, forgetReasonRefusal, validateMemoryNoteWrite } from "./validate.ts";
@@ -6,16 +6,26 @@
6
6
  * `proposal`, which is not in force until an approval); a `forget` deletes the
7
7
  * note under `direct` and leaves it in place under `proposal`. Both resolve the
8
8
  * id the caller can key the mirror on — the note's id for a save, the write's
9
- * client id for a forget, which has no note left to name. A write the store can
10
- * never accept — an unslugged note, a scope this store does not have, a target
11
- * outside it — is refused as `MEMORY_PUSH_REJECTED`, never retried; a failure
12
- * that a retry can fix (a permission error, a full disk) is left as it is.
9
+ * client id for a forget, which has no note left to name.
10
+ *
11
+ * A proposal forget is the one write the mirror cannot carry out, so it is queued in
12
+ * the store's `pending-forgets.jsonl` instead: the record is what an index refresh and
13
+ * an approval read later, in a process that never saw the request. A `direct` forget or
14
+ * a save of that key takes the entry back out — the mirror then holds what was asked
15
+ * for. Nothing else may write the record on this provider's behalf.
16
+ *
17
+ * A write the store can never accept — an unslugged note, a scope this store does not
18
+ * have, a target outside it — is refused as `MEMORY_PUSH_REJECTED`, never retried.
19
+ * A failure that a retry can fix (a permission error, a full disk) is left as it is,
20
+ * including one from the record write: the operation keeps the write queued and a
21
+ * later sync replays it.
13
22
  *
14
23
  * `pull` is the empty finished page: the mirror is the store the caller already
15
24
  * reads, so there is nothing to pull back into it. There is no `search`; keyword
16
25
  * search runs over the mirror through `searchMemoryNotes`.
17
26
  */
18
27
  import { NaxError } from "#src/infra/nax-error";
28
+ import { memoryNoteKey } from "./types.js";
19
29
  const ERROR_STAGE = "memory";
20
30
  /** The status a write lands with: a proposal is not in force until an approval. */
21
31
  const WRITE_MODE_STATUS = {
@@ -53,6 +63,38 @@ async function throughStore(action) {
53
63
  throw pushRejectedFrom(error);
54
64
  }
55
65
  }
66
+ /** The entry a proposal forget leaves in the record: the request's own id and reason. */
67
+ function queuedForget(request) {
68
+ return { clientId: request.clientId, scope: request.scope, slug: request.slug, reason: request.reason ?? "" };
69
+ }
70
+ /** Whether rewriting the record would change it: an unchanged list is not written back. */
71
+ function sameEntries(before, after) {
72
+ if (before.length !== after.length)
73
+ return false;
74
+ return before.every((entry, index) => {
75
+ const other = after[index];
76
+ return (other !== undefined &&
77
+ entry.clientId === other.clientId &&
78
+ entry.scope === other.scope &&
79
+ entry.slug === other.slug &&
80
+ entry.reason === other.reason);
81
+ });
82
+ }
83
+ /**
84
+ * The record rewritten without `key`'s entry, and with `queued` appended after the
85
+ * others when a proposal forget is what took the entry out. The store validates the
86
+ * slugs it is handed, so a forget of a slug no note can carry is refused here — before
87
+ * anything is recorded — even though a proposal never reaches the notes themselves.
88
+ */
89
+ async function rewriteRecord(store, key, queued) {
90
+ const entries = await store.readPendingForgets();
91
+ const kept = entries.filter((entry) => memoryNoteKey(entry.scope, entry.slug) !== key);
92
+ const next = queued === null ? kept : [...kept, queued];
93
+ // A key the record does not hold leaves the file alone: absent stays absent.
94
+ if (sameEntries(entries, next))
95
+ return;
96
+ await store.writePendingForgets(next);
97
+ }
56
98
  export function createLocalMemoryProvider(store) {
57
99
  /** A save is accepted by definition: the mirror is what the provider is. */
58
100
  async function save(request) {
@@ -61,6 +103,9 @@ export function createLocalMemoryProvider(store) {
61
103
  throw pushRejected(`memory save for "${request.slug}" carries no note`);
62
104
  const status = WRITE_MODE_STATUS[request.writeMode];
63
105
  await throughStore(() => store.writeNote({ ...note, status }));
106
+ // Saving the slug again means it is wanted, so whatever forget it was queued for goes.
107
+ const key = memoryNoteKey(note.scope, note.slug);
108
+ await throughStore(() => rewriteRecord(store, key, null));
64
109
  return { id: note.id, status };
65
110
  }
66
111
  /** A forget under direct removes the note; a proposal leaves it for an approval. */
@@ -68,6 +113,8 @@ export function createLocalMemoryProvider(store) {
68
113
  const direct = request.writeMode === "direct";
69
114
  if (direct)
70
115
  await throughStore(() => store.deleteNote(request.scope, request.slug));
116
+ const key = memoryNoteKey(request.scope, request.slug);
117
+ await throughStore(() => rewriteRecord(store, key, direct ? null : queuedForget(request)));
71
118
  return { id: request.clientId, status: direct ? "deleted" : "pending" };
72
119
  }
73
120
  return {
@@ -51,10 +51,8 @@ export interface MemoryForgetResult {
51
51
  /**
52
52
  * One approval: the note to put in force, named by its scope and slug.
53
53
  *
54
- * The mirror is the whole of what an approval decides, so it is given no queue to declare a
55
- * write on and no provider to ask. It is given the seams its lock wait runs on, because the
56
- * note it rewrites has to be the note the mirror holds at that moment — and every one of them
57
- * has a default, so `{ store, scope, slug }` is a complete input.
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.
58
56
  */
59
57
  export interface MemoryApproveInput {
60
58
  readonly store: MemoryStore;
@@ -97,24 +95,29 @@ export declare function saveMemoryNote(input: MemorySaveInput): Promise<MemorySa
97
95
  */
98
96
  export declare function forgetMemoryNote(input: MemoryForgetInput): Promise<MemoryForgetResult>;
99
97
  /**
100
- * Put one pending note in force in this mirror.
98
+ * Put one pending note in force in this mirror — or retire the note a recorded forget names.
101
99
  *
102
100
  * Only the note the mirror holds is consulted, so approval is a local transition rather than an
103
101
  * authorization step: it asks no provider and writes nothing to the queue, and a forget waiting
104
- * on that queue is still waiting afterwards. What it does read from the queue is the set of
105
- * notes whose forget is queued, because the index it regenerates carries that marker and an
106
- * index rendered without it would report a still-pending retire as though it had never been
107
- * asked for.
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.
108
105
  *
109
- * The read and the rewrite are one decision, so they run under the same ordering and the same
110
- * lock file a save and a forget use: this is a writer of the mirror, and what it approves must
111
- * be the note the mirror holds now rather than a copy read before another writer's newer one
112
- * landed. Waiting is the right answer here, unlike a save's — an approval has no queue to put
113
- * its write on, so it has to be the writer when it reads.
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.
114
112
  *
115
- * A note already in force is returned exactly as it stands — approving it again is not a
116
- * rewrite, so its `updatedAt` still says when it was made active. A note that is history is not
117
- * a note waiting for approval, and neither is one this mirror does not hold: both are
118
- * `MEMORY_NOTE_NOT_FOUND`, because there is nothing to approve.
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.
119
122
  */
120
123
  export declare function approveMemoryNote(input: MemoryApproveInput): Promise<MemoryNote>;
@@ -47,10 +47,13 @@
47
47
  * `approveMemoryNote` is the transition a proposal waits for: a pending note is rewritten in force
48
48
  * under the caller's clock and the index is regenerated from the mirror, a note already in force is
49
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. It asks no provider and writes
51
- * nothing to the queue, but it still writes the mirror, so it takes the same ordering and the same
52
- * locks a save takes. It reads the held-forget set only to keep the index's marker, and approves
53
- * no queued forget: the entry stays the outbox's.
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.
54
57
  */
55
58
  import { randomUUID } from "node:crypto";
56
59
  import { getLogger } from "#src/infra/agent-logger";
@@ -382,10 +385,7 @@ function inForce(note, now) {
382
385
  function mirrorOutbox(store) {
383
386
  return createMemoryOutbox({ dir: store.paths.outboxDir });
384
387
  }
385
- /**
386
- * The mirror's lock as this approval takes it: the caller's seam where it gave one, and the
387
- * process defaults — including the logger slot — where it did not.
388
- */
388
+ /** The mirror's lock as this approval takes it: the caller's seams, else the process defaults. */
389
389
  function lockInputOf(input) {
390
390
  return {
391
391
  store: input.store,
@@ -397,16 +397,20 @@ function lockInputOf(input) {
397
397
  }
398
398
  /**
399
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.
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.
401
402
  *
402
- * The read and the write have to be one decision — that is the whole reason this takes the
403
- * lock at all — so a note another writer replaced while this waited is the note that gets
404
- * approved, never the copy this operation would otherwise have read before the wait.
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.
405
407
  */
406
408
  async function approveLocked(input) {
407
409
  const note = await input.store.readNote(input.scope, input.slug);
408
410
  if (note === null || RETIRED_STATUSES.has(note.status))
409
411
  throw noteNotFound(input);
412
+ if (await holdsRecordedForget(input))
413
+ return await retireRecorded(input, note);
410
414
  if (note.status === "active")
411
415
  return note;
412
416
  const approved = inForce(note, clockOf(input));
@@ -414,26 +418,53 @@ async function approveLocked(input) {
414
418
  await refreshIndex({ store: input.store, outbox: mirrorOutbox(input.store) });
415
419
  return approved;
416
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
+ }
417
427
  /**
418
- * Put one pending note in force in this mirror.
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.
419
445
  *
420
446
  * Only the note the mirror holds is consulted, so approval is a local transition rather than an
421
447
  * authorization step: it asks no provider and writes nothing to the queue, and a forget waiting
422
- * on that queue is still waiting afterwards. What it does read from the queue is the set of
423
- * notes whose forget is queued, because the index it regenerates carries that marker and an
424
- * index rendered without it would report a still-pending retire as though it had never been
425
- * asked for.
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.
426
458
  *
427
- * The read and the rewrite are one decision, so they run under the same ordering and the same
428
- * lock file a save and a forget use: this is a writer of the mirror, and what it approves must
429
- * be the note the mirror holds now rather than a copy read before another writer's newer one
430
- * landed. Waiting is the right answer here, unlike a save's — an approval has no queue to put
431
- * its write on, so it has to be the writer when it reads.
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.
432
463
  *
433
- * A note already in force is returned exactly as it stands — approving it again is not a
434
- * rewrite, so its `updatedAt` still says when it was made active. A note that is history is not
435
- * a note waiting for approval, and neither is one this mirror does not hold: both are
436
- * `MEMORY_NOTE_NOT_FOUND`, because there is nothing to approve.
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.
437
468
  */
438
469
  export async function approveMemoryNote(input) {
439
470
  return await orderedByMirrorLocks(input.store, () => withMirrorLock(lockInputOf(input), () => approveLocked(input)));
@@ -1,14 +1,15 @@
1
1
  /**
2
- * The two record files the mirror keeps beside its notes, decoded.
2
+ * The record files the mirror keeps beside its notes, decoded.
3
3
  *
4
- * `decisions.jsonl` is one `MemoryDecision` per line and `sync.json` is one
5
- * `MemorySyncState`: both are written whole by this store and read back by it, so what
6
- * makes a line a decision — and what makes a file a state — is a schema rather than a
7
- * cast. Keeping the schemas here leaves the store to decide what a refused line *means*
4
+ * `decisions.jsonl` is one `MemoryDecision` per line, `pending-forgets.jsonl` one
5
+ * `MemoryPendingForget` per line, and `sync.json` is one `MemorySyncState`: all three are
6
+ * written whole by this store and read back by it, so what makes a line a decision — or
7
+ * a pending forget, or what makes a file a state — is a schema rather than a cast.
8
+ * Keeping the schemas here leaves the store to decide what a refused line *means*
8
9
  * (skipped with a warning) and this module to decide what counts as one.
9
10
  */
10
11
  import { z } from "zod";
11
- import { type MemoryDecision } from "./types.ts";
12
+ import { type MemoryDecision, type MemoryPendingForget } from "./types.ts";
12
13
  declare const SyncStateSchema: z.ZodObject<{
13
14
  provider: z.ZodNullable<z.ZodString>;
14
15
  cursor: z.ZodObject<{
@@ -24,6 +25,17 @@ export type ParsedSyncState = z.infer<typeof SyncStateSchema>;
24
25
  export declare function defaultSyncState(): ParsedSyncState;
25
26
  /** One mirror line as a decision, or `null` when the line is not one. */
26
27
  export declare function parseDecisionLine(line: string): MemoryDecision | null;
28
+ /** One mirror line as a pending forget, or `null` when the line is not one. */
29
+ export declare function parsePendingForgetLine(line: string): MemoryPendingForget | null;
30
+ /**
31
+ * A JSONL record's text as its entries. A line the decoder refuses is skipped with one
32
+ * report naming its 1-based line number, never fatal: the other lines are still the
33
+ * record. A record file that is not there (`null`) is the empty record, not the absent
34
+ * one — a store that has never recorded anything answers `[]` and creates nothing.
35
+ */
36
+ export declare function decodeRecordLines<T>(text: string | null, decode: (line: string) => T | null, warnSkipped: (line: number) => void): T[];
37
+ /** One record file's text: one JSON object per line, and nothing at all when empty. */
38
+ export declare function encodeRecordLines(entries: readonly unknown[]): string;
27
39
  /** The whole `sync.json` text as a state, or `null` when it does not hold one. */
28
40
  export declare function parseSyncStateText(text: string): ParsedSyncState | null;
29
41
  export {};
@@ -1,14 +1,15 @@
1
1
  /**
2
- * The two record files the mirror keeps beside its notes, decoded.
2
+ * The record files the mirror keeps beside its notes, decoded.
3
3
  *
4
- * `decisions.jsonl` is one `MemoryDecision` per line and `sync.json` is one
5
- * `MemorySyncState`: both are written whole by this store and read back by it, so what
6
- * makes a line a decision — and what makes a file a state — is a schema rather than a
7
- * cast. Keeping the schemas here leaves the store to decide what a refused line *means*
4
+ * `decisions.jsonl` is one `MemoryDecision` per line, `pending-forgets.jsonl` one
5
+ * `MemoryPendingForget` per line, and `sync.json` is one `MemorySyncState`: all three are
6
+ * written whole by this store and read back by it, so what makes a line a decision — or
7
+ * a pending forget, or what makes a file a state — is a schema rather than a cast.
8
+ * Keeping the schemas here leaves the store to decide what a refused line *means*
8
9
  * (skipped with a warning) and this module to decide what counts as one.
9
10
  */
10
11
  import { z } from "zod";
11
- import { MEMORY_DECISION_STATUSES } from "./types.js";
12
+ import { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_SCOPES, MEMORY_SLUG_RE, } from "./types.js";
12
13
  /** A decision as one mirror line; the shape is what makes a line a decision. */
13
14
  const DecisionSchema = z.object({
14
15
  id: z.string().min(1),
@@ -18,6 +19,17 @@ const DecisionSchema = z.object({
18
19
  updatedAt: z.string().min(1),
19
20
  status: z.enum(MEMORY_DECISION_STATUSES),
20
21
  });
22
+ /**
23
+ * A pending forget as one mirror line. The slug and the client id are the two values
24
+ * later code acts on — the slug names the note an approval retires, the client id names
25
+ * the write — so a line carries a note slug and an outbox id, never an arbitrary string.
26
+ */
27
+ const PendingForgetSchema = z.object({
28
+ clientId: z.string().regex(MEMORY_CLIENT_ID_RE),
29
+ scope: z.enum(MEMORY_SCOPES),
30
+ slug: z.string().regex(MEMORY_SLUG_RE),
31
+ reason: z.string(),
32
+ });
21
33
  const SyncStateSchema = z.object({
22
34
  provider: z.string().nullable(),
23
35
  cursor: z.object({ project: z.string().nullable(), user: z.string().nullable() }),
@@ -43,6 +55,37 @@ export function parseDecisionLine(line) {
43
55
  const parsed = DecisionSchema.safeParse(parseJson(line));
44
56
  return parsed.success ? parsed.data : null;
45
57
  }
58
+ /** One mirror line as a pending forget, or `null` when the line is not one. */
59
+ export function parsePendingForgetLine(line) {
60
+ const parsed = PendingForgetSchema.safeParse(parseJson(line));
61
+ return parsed.success ? parsed.data : null;
62
+ }
63
+ /**
64
+ * A JSONL record's text as its entries. A line the decoder refuses is skipped with one
65
+ * report naming its 1-based line number, never fatal: the other lines are still the
66
+ * record. A record file that is not there (`null`) is the empty record, not the absent
67
+ * one — a store that has never recorded anything answers `[]` and creates nothing.
68
+ */
69
+ export function decodeRecordLines(text, decode, warnSkipped) {
70
+ if (text === null)
71
+ return [];
72
+ const entries = [];
73
+ for (const [index, raw] of text.split("\n").entries()) {
74
+ if (raw.trim() === "")
75
+ continue;
76
+ const entry = decode(raw);
77
+ if (entry === null) {
78
+ warnSkipped(index + 1);
79
+ continue;
80
+ }
81
+ entries.push(entry);
82
+ }
83
+ return entries;
84
+ }
85
+ /** One record file's text: one JSON object per line, and nothing at all when empty. */
86
+ export function encodeRecordLines(entries) {
87
+ return entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
88
+ }
46
89
  /** The whole `sync.json` text as a state, or `null` when it does not hold one. */
47
90
  export function parseSyncStateText(text) {
48
91
  const parsed = SyncStateSchema.safeParse(parseJson(text));
@@ -15,18 +15,19 @@
15
15
  * The caller's global cwd is never changed. Post-checks report moved directories;
16
16
  * they are not the mechanism that prevents access to replacements.
17
17
  *
18
- * The store also owns the three record files beside the notes: the read-only
19
- * `decisions.jsonl` mirror (one JSON object per line), `sync.json` (the sync
20
- * state) and each scope's generated `MEMORY.md`. They hold no user-authored
21
- * paths, so they are read and written without a slug to validate; the only
22
- * scope rule is that the user files need a user directory. A record read is
23
- * still confined: it is read under the project directory's pin and never
24
- * follows a link out of the store.
18
+ * The store also owns the record files beside the notes: the `decisions.jsonl`
19
+ * mirror (one JSON object per line), the `pending-forgets.jsonl` record of queued
20
+ * forgets, `sync.json` (the sync state) and each scope's generated `MEMORY.md`.
21
+ * They hold no user-authored paths, so they are read and written without a slug to
22
+ * validate — save a pending forget's own slug, which names a note and must be one.
23
+ * The only scope rule is that the user files need a user directory. A record read is
24
+ * still confined: it is read under the project directory's pin and never follows a
25
+ * link out of the store.
25
26
  */
26
27
  import type { Dirent } from "node:fs";
27
28
  import type { AgentLogger } from "#src/infra/agent-logger";
28
29
  import { withPinnedDirectory } from "#src/internal/pinned-directory";
29
- import { type MemoryDecision, type MemoryNote, type MemoryScope } from "./types.ts";
30
+ import { type MemoryDecision, type MemoryNote, type MemoryPendingForget, type MemoryScope } from "./types.ts";
30
31
  interface Identity {
31
32
  readonly dev: number;
32
33
  readonly ino: number;
@@ -65,6 +66,7 @@ export interface MemoryStorePaths {
65
66
  readonly index: string;
66
67
  readonly notesDir: string;
67
68
  readonly decisions: string;
69
+ readonly pendingForgets: string;
68
70
  readonly syncState: string;
69
71
  readonly lock: string;
70
72
  readonly outboxDir: string;
@@ -90,6 +92,8 @@ export interface MemoryStore {
90
92
  listNotes(scope: MemoryScope): Promise<MemoryNote[]>;
91
93
  readDecisions(): Promise<MemoryDecision[]>;
92
94
  writeDecisions(decisions: readonly MemoryDecision[]): Promise<void>;
95
+ readPendingForgets(): Promise<MemoryPendingForget[]>;
96
+ writePendingForgets(entries: readonly MemoryPendingForget[]): Promise<void>;
93
97
  readSyncState(): Promise<MemorySyncState>;
94
98
  writeSyncState(state: MemorySyncState): Promise<void>;
95
99
  writeIndex(scope: MemoryScope, text: string): Promise<void>;