@nathapp/nax-agent 0.1.0 → 0.2.0

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 (113) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +47 -2
  3. package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
  4. package/dist/coding-tools/coding-tool-sandbox.js +14 -3
  5. package/dist/coding-tools/coding-tool-support.d.ts +3 -0
  6. package/dist/coding-tools/coding-tool-support.js +2 -0
  7. package/dist/index.d.ts +10 -1
  8. package/dist/index.js +4 -1
  9. package/dist/internal/redact.d.ts +23 -0
  10. package/dist/internal/redact.js +60 -0
  11. package/dist/internal.d.ts +7 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/native/adapter-deps.d.ts +3 -3
  14. package/dist/native/adapter-deps.js +3 -3
  15. package/dist/native/client.d.ts +7 -1
  16. package/dist/native/client.js +1 -1
  17. package/dist/native/credentials/change-guard.d.ts +8 -1
  18. package/dist/native/credentials/change-guard.js +1 -1
  19. package/dist/native/credentials/fingerprint.d.ts +4 -1
  20. package/dist/native/credentials/fingerprint.js +6 -3
  21. package/dist/native/credentials/session-source.d.ts +12 -0
  22. package/dist/native/credentials/session-source.js +33 -0
  23. package/dist/native/index.d.ts +3 -1
  24. package/dist/native/index.js +2 -1
  25. package/dist/native/session/loop-events/types.d.ts +1 -1
  26. package/dist/native/session/loop-handlers.d.ts +3 -0
  27. package/dist/native/session/loop-handlers.js +1 -1
  28. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  29. package/dist/native/session/memory-transcript-store.js +36 -0
  30. package/dist/native/session/session.d.ts +138 -96
  31. package/dist/native/session/session.js +107 -127
  32. package/dist/native/session/transcript-identity.d.ts +39 -0
  33. package/dist/native/session/transcript-identity.js +81 -0
  34. package/dist/native/session/transcript-store.d.ts +22 -21
  35. package/dist/native/session/transcript-store.js +59 -68
  36. package/dist/native/session/transcript-types.d.ts +38 -0
  37. package/dist/native/session/transcript-types.js +7 -0
  38. package/dist/native/session/truncation-handler.d.ts +4 -3
  39. package/dist/native/session/truncation-handler.js +7 -10
  40. package/dist/native/session/turn-complete-step.d.ts +4 -1
  41. package/dist/native/session/turn-complete-step.js +11 -4
  42. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  43. package/dist/native/session/turn-event-emitter.js +134 -0
  44. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  45. package/dist/native/session/turn-loop-round-trip.js +8 -4
  46. package/dist/native/session/turn-loop.js +27 -13
  47. package/dist/native/session/turn-result.d.ts +1 -1
  48. package/dist/native/session/turn-result.js +1 -1
  49. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  50. package/dist/native/session/turn-tool-batch.js +14 -6
  51. package/dist/native/session/turn-types.d.ts +17 -1
  52. package/dist/native/session-adapter.d.ts +22 -4
  53. package/dist/native/session-adapter.js +79 -35
  54. package/dist/native/stream-complete.d.ts +17 -0
  55. package/dist/native/stream-complete.js +40 -0
  56. package/dist/permissions/approvals-link.js +5 -4
  57. package/dist/sandbox/policy-builder.d.ts +6 -1
  58. package/dist/sandbox/policy-builder.js +18 -14
  59. package/dist/session/agent-session-deps.d.ts +12 -0
  60. package/dist/session/agent-session-deps.js +28 -0
  61. package/dist/session/agent-session-errors.d.ts +11 -0
  62. package/dist/session/agent-session-errors.js +12 -0
  63. package/dist/session/agent-session-options.d.ts +16 -0
  64. package/dist/session/agent-session-options.js +137 -0
  65. package/dist/session/agent-session-resume.d.ts +12 -0
  66. package/dist/session/agent-session-resume.js +47 -0
  67. package/dist/session/agent-session-turn.d.ts +45 -0
  68. package/dist/session/agent-session-turn.js +179 -0
  69. package/dist/session/agent-session-types.d.ts +182 -0
  70. package/dist/session/agent-session-types.js +1 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +223 -0
  73. package/dist/session/pending-asks.d.ts +24 -0
  74. package/dist/session/pending-asks.js +77 -0
  75. package/dist/session/session-ask-link.d.ts +26 -0
  76. package/dist/session/session-ask-link.js +52 -0
  77. package/dist/session/session-event-channel.d.ts +17 -0
  78. package/dist/session/session-event-channel.js +132 -0
  79. package/dist/session/session-interaction.d.ts +20 -0
  80. package/dist/session/session-interaction.js +164 -0
  81. package/dist/session/session-tool-support.d.ts +48 -0
  82. package/dist/session/session-tool-support.js +101 -0
  83. package/dist/session/session-types.d.ts +31 -2
  84. package/dist/session/turn-event.d.ts +61 -0
  85. package/dist/session/turn-event.js +1 -0
  86. package/dist/tools/bash-cwd.d.ts +3 -3
  87. package/dist/tools/credential-read-deny.d.ts +22 -0
  88. package/dist/tools/credential-read-deny.js +56 -0
  89. package/dist/tools/glob.js +9 -2
  90. package/dist/tools/grep.js +55 -26
  91. package/dist/tools/owned-paths.d.ts +43 -0
  92. package/dist/tools/owned-paths.js +17 -0
  93. package/dist/tools/package-managers.d.ts +3 -0
  94. package/dist/tools/package-managers.js +7 -3
  95. package/dist/tools/policy-bash-raw.d.ts +3 -0
  96. package/dist/tools/policy-bash-raw.js +49 -62
  97. package/dist/tools/policy-command-branch.d.ts +3 -0
  98. package/dist/tools/policy-command-branch.js +2 -1
  99. package/dist/tools/policy-paths-branch.d.ts +14 -8
  100. package/dist/tools/policy-paths-branch.js +20 -19
  101. package/dist/tools/policy.d.ts +10 -7
  102. package/dist/tools/policy.js +9 -7
  103. package/dist/tools/protected-paths.d.ts +6 -6
  104. package/dist/tools/read.js +38 -20
  105. package/dist/tools/registry.d.ts +3 -0
  106. package/dist/tools/run-command-exec.js +1 -0
  107. package/dist/tools/runtime.d.ts +3 -0
  108. package/dist/tools/runtime.js +1 -0
  109. package/dist/tools/scratchpad.js +2 -1
  110. package/dist/tools/types.d.ts +2 -2
  111. package/package.json +2 -2
  112. package/dist/tools/nax-owned-writes.d.ts +0 -135
  113. package/dist/tools/nax-owned-writes.js +0 -259
@@ -10,84 +10,23 @@ import { NaxError } from "#src/infra/index";
10
10
  import { createSpinBreaker } from "#src/infra/spin-breaker/index";
11
11
  import { NATIVE_AGENT } from "../models.js";
12
12
  import { nativeSessionId } from "../session-affinity.js";
13
- import { deleteTranscript, pruneRetainedTranscripts, retainTranscript } from "./transcript-store.js";
14
- /**
15
- * Session name -> transcript directory, so sendTurn and close can find it.
16
- *
17
- * Cleared by every close path — `closeNativeSession` (in-process) and the
18
- * adapter's `closePhysicalSession` (run teardown, which only has the name) —
19
- * through the shared `clearNativeSessionState`. A caller that opens a session
20
- * and never closes it (e.g. an early return, or a thrown error between open and
21
- * close) still leaks an entry for the lifetime of the process. Harmless in
22
- * practice (it is a small in-memory map keyed by session name, not a handle
23
- * to a real resource), but worth knowing when debugging a growing map.
24
- */
25
- export const nativeTranscriptDirs = new Map();
26
- /**
27
- * Session name -> the root whose `.nax/scratchpad/` holds this session's
28
- * spill files (US-003).
29
- *
30
- * Recorded from the session's `workdir` at open — the same root the coding
31
- * tools are confined to — so a spilled body is reachable through
32
- * `ScratchpadRead` with the relative path the truncation marker names. Same
33
- * lifecycle as the maps above: set on open, cleared on close.
34
- */
35
- export const nativeSessionScratchpadRoots = new Map();
36
- /**
37
- * Session name -> timeoutSeconds, so sendTurn can bound each `complete()`
38
- * call with a deadline (whole-branch review finding 4). Same lifecycle as
39
- * `nativeTranscriptDirs` — populated on open, cleared on close only.
40
- */
41
- export const nativeSessionTimeouts = new Map();
42
- /**
43
- * Session name -> the runtime hooks handed to openSession. SessionManager
44
- * passes both (manager.ts, openSession) and the native adapter previously
45
- * ignored them, which is why the idle watchdog never covered native. Same
46
- * lifecycle as `nativeTranscriptDirs`: set on open, cleared on close only.
47
- */
48
- export const nativeSessionStreamHooks = new Map();
49
- /**
50
- * Session names whose most recent turn failed.
51
- *
52
- * `AgentAdapter.closeSession(handle)` carries no failure signal, so the close
53
- * cannot tell a finished session from a broken one and passed `failed: false`
54
- * for both -- deleting the transcript of exactly the session whose history the
55
- * retry needs and a human would read (nax#1838). Rather than widen the adapter
56
- * interface for one transport, the turn records what it knows here and the
57
- * close reads it. Same lifecycle as the maps above: written per turn, cleared
58
- * on close.
59
- *
60
- * A later successful turn clears the mark. "Failed" describes the session's
61
- * current state, not whether it ever stumbled -- a session that recovered and
62
- * finished is still cleaned up.
63
- */
64
- export const nativeSessionFailed = new Set();
65
- /**
66
- * Session name -> the identity that owns this session's transcript (nax#1877).
67
- *
68
- * Read by `runNativeTurn` on both the load and the save, so a transcript left
69
- * behind by an abandoned invocation of the same deterministic session name is
70
- * recognised as foreign rather than silently resumed. Same lifecycle as the
71
- * maps above: set on open, cleared on close.
72
- */
73
- export const nativeSessionTranscriptOwners = new Map();
74
- /** Session name -> resolved compaction settings. Same lifecycle as the maps above. */
75
- export const nativeSessionCompaction = new Map();
76
- /**
77
- * Session name -> resolved transport-fault retry settings (nax#1870). Same
78
- * lifecycle as the maps above: set on open, cleared on close.
79
- */
80
- export const nativeSessionTransportRetry = new Map();
81
- /**
82
- * Session name -> live `SpinBreaker` instance (nax#2013, lifetime extended in
83
- * nax#2047). Constructed once at open so the cumulative counter survives
84
- * across `runNativeTurn` calls — a fix round that opens a new turn on the
85
- * same `handle.id` must inherit the prior turn's evidence rather than start
86
- * fresh. Same lifecycle as `nativeSessionTransportRetry`: set on open, cleared
87
- * on close.
88
- */
89
- export const nativeSessionSpinBreaker = new Map();
90
- export const nativeSessionLastUsage = new Map();
13
+ import { createFileTranscriptStore } from "./transcript-store.js";
14
+ export function createNativeSessionState() {
15
+ return {
16
+ transcriptDirs: new Map(),
17
+ transcripts: new Map(),
18
+ scratchpadRoots: new Map(),
19
+ timeouts: new Map(),
20
+ streamHooks: new Map(),
21
+ failed: new Set(),
22
+ transcriptOwners: new Map(),
23
+ compaction: new Map(),
24
+ transportRetry: new Map(),
25
+ spinBreakers: new Map(),
26
+ lastUsage: new Map(),
27
+ systemPrompts: new Map(),
28
+ };
29
+ }
91
30
  /**
92
31
  * The persisted anchor a turn on `model` may use. Prefix stability is a
93
32
  * property of (model, prefix) (P3 spec 3.3): an anchor measured under a
@@ -95,50 +34,90 @@ export const nativeSessionLastUsage = new Map();
95
34
  * so it is dropped here rather than mis-sizing the next compaction decision.
96
35
  * An entry or a turn with no model makes no claim — the store's own rule.
97
36
  */
98
- export function sessionAnchorFor(sessionName, model) {
99
- const entry = nativeSessionLastUsage.get(sessionName);
37
+ export function sessionAnchorFor(state, sessionName, model) {
38
+ const entry = state.lastUsage.get(sessionName);
100
39
  if (entry?.model === undefined || model === undefined || entry.model === model)
101
40
  return entry;
102
- nativeSessionLastUsage.delete(sessionName);
41
+ state.lastUsage.delete(sessionName);
103
42
  return undefined;
104
43
  }
105
44
  /** Records how the session's latest turn ended, for `closeNativeSession`. */
106
- export function markNativeTurnOutcome(sessionName, failed) {
45
+ export function markNativeTurnOutcome(state, sessionName, failed) {
107
46
  if (failed)
108
- nativeSessionFailed.add(sessionName);
47
+ state.failed.add(sessionName);
109
48
  else
110
- nativeSessionFailed.delete(sessionName);
49
+ state.failed.delete(sessionName);
50
+ }
51
+ /**
52
+ * The session's transcript. A state seeded with only a transcript directory
53
+ * (tests that drive `runNativeTurn` without opening) resolves to the file store
54
+ * over it; `openNativeSession` always records an entry, so production does not
55
+ * take that path.
56
+ */
57
+ export function sessionTranscriptFor(state, sessionName) {
58
+ const opened = state.transcripts.get(sessionName);
59
+ if (opened !== undefined)
60
+ return opened;
61
+ const dir = state.transcriptDirs.get(sessionName);
62
+ return dir === undefined ? undefined : { store: createFileTranscriptStore(dir), retainOnClose: false };
111
63
  }
112
- export async function openNativeSession(name, opts) {
64
+ function openTranscriptStore(name, opts) {
65
+ if (opts.transcriptDir && opts.transcriptStore !== undefined) {
66
+ throw new NaxError(`native session "${name}" opened with both a transcriptDir and a transcriptStore`, "NATIVE_TRANSCRIPT_SOURCE_CONFLICT", { stage: "native-session" });
67
+ }
68
+ if (opts.transcriptStore !== undefined)
69
+ return opts.transcriptStore;
113
70
  // Never defaulted. An adapter that picks its own path writes a transcript
114
71
  // somewhere nobody looks, which is #1794's empty-packageDir bug one layer up.
115
72
  if (!opts.transcriptDir) {
116
- throw new NaxError(`native session "${name}" opened without a transcriptDir`, "NATIVE_TRANSCRIPT_DIR_MISSING", {
117
- stage: "native-session",
118
- });
73
+ throw new NaxError(`native session "${name}" opened without a transcriptDir or transcriptStore`, "NATIVE_TRANSCRIPT_DIR_MISSING", { stage: "native-session" });
119
74
  }
120
- nativeTranscriptDirs.set(name, opts.transcriptDir);
121
- nativeSessionTimeouts.set(name, opts.timeoutSeconds);
122
- nativeSessionScratchpadRoots.set(name, opts.workdir);
75
+ return createFileTranscriptStore(opts.transcriptDir);
76
+ }
77
+ /**
78
+ * Records, or clears, the session's system prompt at open. A helper so that
79
+ * openNativeSession gains no branch.
80
+ */
81
+ function recordSystemPrompt(state, name, systemPrompt) {
82
+ if (systemPrompt === undefined)
83
+ state.systemPrompts.delete(name);
84
+ else
85
+ state.systemPrompts.set(name, systemPrompt);
86
+ }
87
+ /** The request field for the session's system prompt: `{ system }`, or `{}` when it has none. */
88
+ export function systemFieldFor(state, sessionName) {
89
+ const system = state.systemPrompts.get(sessionName);
90
+ return system === undefined ? {} : { system };
91
+ }
92
+ export async function openNativeSession(state, name, opts) {
93
+ const store = openTranscriptStore(name, opts);
94
+ if (opts.transcriptDir)
95
+ state.transcriptDirs.set(name, opts.transcriptDir);
96
+ else
97
+ state.transcriptDirs.delete(name);
98
+ state.transcripts.set(name, { store, retainOnClose: opts.retainOnClose === true });
99
+ state.timeouts.set(name, opts.timeoutSeconds);
100
+ state.scratchpadRoots.set(name, opts.workdir);
123
101
  if (opts.transcriptOwner !== undefined)
124
- nativeSessionTranscriptOwners.set(name, opts.transcriptOwner);
102
+ state.transcriptOwners.set(name, opts.transcriptOwner);
125
103
  else
126
- nativeSessionTranscriptOwners.delete(name);
104
+ state.transcriptOwners.delete(name);
105
+ recordSystemPrompt(state, name, opts.systemPrompt);
127
106
  // `resume` is SessionManager's "this name already has a descriptor in this
128
107
  // process" signal, and it had no consumer on this transport (nax#1877) — a
129
108
  // native session resumed whatever transcript happened to be on disk. Honouring
130
- // it here is the flag's documented contract; the owner check in loadTranscript
109
+ // it here is the flag's documented contract; the owner check in historyFromTranscript
131
110
  // is what covers the cases `resume` cannot see (a same-process re-entry into a
132
111
  // stage, and a process that died without closing anything).
133
112
  if (opts.resume !== true)
134
- await deleteTranscript(opts.transcriptDir, name);
113
+ await store.delete(name);
135
114
  if (opts.compaction !== undefined)
136
- nativeSessionCompaction.set(name, opts.compaction);
115
+ state.compaction.set(name, opts.compaction);
137
116
  if (opts.transportRetry !== undefined)
138
- nativeSessionTransportRetry.set(name, opts.transportRetry);
117
+ state.transportRetry.set(name, opts.transportRetry);
139
118
  if (opts.spinBreaker !== undefined)
140
- nativeSessionSpinBreaker.set(name, createSpinBreaker(opts.spinBreaker));
141
- nativeSessionStreamHooks.set(name, {
119
+ state.spinBreakers.set(name, createSpinBreaker(opts.spinBreaker));
120
+ state.streamHooks.set(name, {
142
121
  ...(opts.onStreamActivity !== undefined ? { onStreamActivity: opts.onStreamActivity } : {}),
143
122
  ...(opts.onActiveCall !== undefined ? { onActiveCall: opts.onActiveCall } : {}),
144
123
  });
@@ -156,7 +135,7 @@ export async function openNativeSession(name, opts) {
156
135
  };
157
136
  }
158
137
  /**
159
- * Clear every module-level entry for one session name.
138
+ * Clear every state entry for one session name.
160
139
  *
161
140
  * Extracted so both close paths share one source of truth. `closeNativeSession`
162
141
  * has a `SessionHandle`; the adapter's `closePhysicalSession` gets only the
@@ -165,44 +144,45 @@ export async function openNativeSession(name, opts) {
165
144
  * is that name — but the signatures are not interchangeable, so this helper is
166
145
  * string-keyed rather than reconstructed into a synthetic handle.
167
146
  */
168
- export function clearNativeSessionState(sessionName) {
169
- nativeTranscriptDirs.delete(sessionName);
170
- nativeSessionTimeouts.delete(sessionName);
171
- nativeSessionScratchpadRoots.delete(sessionName);
172
- nativeSessionTranscriptOwners.delete(sessionName);
173
- nativeSessionStreamHooks.delete(sessionName);
174
- nativeSessionFailed.delete(sessionName);
175
- nativeSessionCompaction.delete(sessionName);
176
- nativeSessionTransportRetry.delete(sessionName);
177
- nativeSessionSpinBreaker.delete(sessionName);
178
- nativeSessionLastUsage.delete(sessionName);
147
+ export function clearNativeSessionState(state, sessionName) {
148
+ state.transcriptDirs.delete(sessionName);
149
+ state.transcripts.delete(sessionName);
150
+ state.timeouts.delete(sessionName);
151
+ state.scratchpadRoots.delete(sessionName);
152
+ state.transcriptOwners.delete(sessionName);
153
+ state.streamHooks.delete(sessionName);
154
+ state.failed.delete(sessionName);
155
+ state.compaction.delete(sessionName);
156
+ state.transportRetry.delete(sessionName);
157
+ state.spinBreakers.delete(sessionName);
158
+ state.lastUsage.delete(sessionName);
159
+ state.systemPrompts.delete(sessionName);
179
160
  }
180
161
  /**
181
162
  * Kept on failure, deleted on success. Every Phase B op is lifetime "fresh", so
182
- * the transcript survives exactly when it is worth reading. The kept-on-failure
183
- * set is otherwise unbounded, so a failed close also prunes the feature's
184
- * `sessions/` directory down to `MAX_RETAINED_TRANSCRIPTS` (ADR-028 section 3).
163
+ * the transcript survives exactly when it is worth reading. Keeping is the
164
+ * store's `retainFailed`; the file store's also prunes the feature's
165
+ * `sessions/` directory down to `MAX_RETAINED_TRANSCRIPTS` (ADR-028 section 3),
166
+ * since the kept-on-failure set is otherwise unbounded. A session opened with
167
+ * `retainOnClose` skips both: its live document stays resumable.
185
168
  */
186
- export async function closeNativeSession(handle, failed) {
187
- const dir = nativeTranscriptDirs.get(handle.id);
169
+ export async function closeNativeSession(state, handle, failed) {
170
+ const transcript = sessionTranscriptFor(state, handle.id);
188
171
  // An explicit argument wins; otherwise the last turn's own verdict decides.
189
172
  // The adapter passes nothing, because its interface has no failure signal.
190
- const treatAsFailed = failed ?? nativeSessionFailed.has(handle.id);
173
+ const treatAsFailed = failed ?? state.failed.has(handle.id);
191
174
  try {
192
- if (dir !== undefined) {
193
- if (treatAsFailed) {
194
- // Retain for a human, out of reach of the next session of this name.
195
- await retainTranscript(dir, handle.id);
196
- await pruneRetainedTranscripts(dir);
197
- }
198
- else {
199
- await deleteTranscript(dir, handle.id);
200
- }
175
+ if (transcript !== undefined && !transcript.retainOnClose) {
176
+ // Retain for a human, out of reach of the next session of this name.
177
+ if (treatAsFailed)
178
+ await transcript.store.retainFailed(handle.id);
179
+ else
180
+ await transcript.store.delete(handle.id);
201
181
  }
202
182
  }
203
183
  finally {
204
184
  // The deletes must run even when the transcript I/O throws: a failed
205
185
  // cleanup step is no reason to strand the session's other state.
206
- clearNativeSessionState(handle.id);
186
+ clearNativeSessionState(state, handle.id);
207
187
  }
208
188
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Who may resume a transcript, applied by the loop to whatever document any
3
+ * `TranscriptStore` returns (S3 spec 5.5), so the identity rules hold for every
4
+ * store rather than only the file store.
5
+ */
6
+ import type { ConversationMessage } from "@nathapp/nax-ai";
7
+ import type { TranscriptDoc } from "./transcript-types.ts";
8
+ /**
9
+ * Who may resume a transcript. `owner` is the op invocation (nax#1877); `model`
10
+ * is the native model that wrote it (nax#2150, P3 spec 8.3). A field the caller
11
+ * leaves undefined makes no claim, so non-op callers and tests read whatever is
12
+ * there.
13
+ */
14
+ export interface TranscriptIdentity {
15
+ readonly owner?: string;
16
+ readonly model?: string;
17
+ }
18
+ /**
19
+ * The model half of `TranscriptIdentity`: the native `provider/model` id with
20
+ * the reasoning-effort suffix stripped. A thinking signature binds to the
21
+ * model, not to the effort — pi-ai's own `isSameModel` compares
22
+ * provider/api/model for the same reason. `parseModelSpec`, not
23
+ * `parseNativeModel`: this must never throw.
24
+ */
25
+ export declare function transcriptModelIdentity(rawModel: string | undefined): string | undefined;
26
+ /**
27
+ * The history a session may resume from a loaded document (nax#1877, nax#2150).
28
+ * An unknown `schemaVersion` fails the turn for the same reason
29
+ * `TRANSCRIPT_CORRUPT` does: silently starting over would drop history the
30
+ * conversation depends on. Both checks run before the owner/model check, so a
31
+ * document this build cannot read fails even when it would be foreign: the
32
+ * reader cannot trust the identity fields of a shape it does not know.
33
+ */
34
+ export declare function historyFromTranscript(doc: TranscriptDoc | null, identity: TranscriptIdentity, sessionName: string): readonly ConversationMessage[];
35
+ /**
36
+ * The document the loop saves. Key order is owner, model, savedAt, messages:
37
+ * the file store writes it as-is, and nax's transcript bytes depend on that order.
38
+ */
39
+ export declare function transcriptDocFor(messages: readonly ConversationMessage[], identity: TranscriptIdentity): TranscriptDoc;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Who may resume a transcript, applied by the loop to whatever document any
3
+ * `TranscriptStore` returns (S3 spec 5.5), so the identity rules hold for every
4
+ * store rather than only the file store.
5
+ */
6
+ import { getLogger, NaxError } from "#src/infra/index";
7
+ import { parseModelSpec } from "../models.js";
8
+ /**
9
+ * The model half of `TranscriptIdentity`: the native `provider/model` id with
10
+ * the reasoning-effort suffix stripped. A thinking signature binds to the
11
+ * model, not to the effort — pi-ai's own `isSameModel` compares
12
+ * provider/api/model for the same reason. `parseModelSpec`, not
13
+ * `parseNativeModel`: this must never throw.
14
+ */
15
+ export function transcriptModelIdentity(rawModel) {
16
+ return rawModel === undefined ? undefined : parseModelSpec(rawModel).model;
17
+ }
18
+ /**
19
+ * Another invocation's history (nax#1877) or another model's (nax#2150, P3
20
+ * spec 8.3(c)) reads as a new conversation. An ABSENT document model reads,
21
+ * unlike an absent owner: every native production turn records one (the
22
+ * adapter parses the model before the loop runs), so an absent field is a
23
+ * pre-upgrade file — which the owner check already keeps out of new processes.
24
+ * An owner-less document (including a pre-#1877 bare-array file) is foreign to
25
+ * a reader that has an owner: the cost is one re-exploration, where inheriting
26
+ * it silently bills a conversation this session never had.
27
+ */
28
+ function isForeignTranscript(doc, identity, sessionName) {
29
+ const { owner, model } = identity;
30
+ if (owner !== undefined && doc.owner !== owner) {
31
+ getLogger().debug("native-session", "Ignoring a transcript owned by another invocation", {
32
+ sessionName,
33
+ storedOwner: doc.owner,
34
+ owner,
35
+ });
36
+ return true;
37
+ }
38
+ if (model !== undefined && doc.model !== undefined && doc.model !== model) {
39
+ getLogger().debug("native-session", "Ignoring a transcript written by another model", {
40
+ sessionName,
41
+ storedModel: doc.model,
42
+ model,
43
+ });
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ /**
49
+ * The history a session may resume from a loaded document (nax#1877, nax#2150).
50
+ * An unknown `schemaVersion` fails the turn for the same reason
51
+ * `TRANSCRIPT_CORRUPT` does: silently starting over would drop history the
52
+ * conversation depends on. Both checks run before the owner/model check, so a
53
+ * document this build cannot read fails even when it would be foreign: the
54
+ * reader cannot trust the identity fields of a shape it does not know.
55
+ */
56
+ export function historyFromTranscript(doc, identity, sessionName) {
57
+ if (doc === null)
58
+ return [];
59
+ const version = doc.schemaVersion;
60
+ if (version !== undefined && version !== 1) {
61
+ throw new NaxError(`transcript for session "${sessionName}" has unsupported schemaVersion ${String(version)}`, "TRANSCRIPT_SCHEMA_UNSUPPORTED", { stage: "native-session" });
62
+ }
63
+ if (!Array.isArray(doc.messages)) {
64
+ throw new NaxError(`transcript for session "${sessionName}" is unreadable: messages is not an array`, "TRANSCRIPT_CORRUPT", { stage: "native-session" });
65
+ }
66
+ if (isForeignTranscript(doc, identity, sessionName))
67
+ return [];
68
+ return doc.messages;
69
+ }
70
+ /**
71
+ * The document the loop saves. Key order is owner, model, savedAt, messages:
72
+ * the file store writes it as-is, and nax's transcript bytes depend on that order.
73
+ */
74
+ export function transcriptDocFor(messages, identity) {
75
+ return {
76
+ ...(identity.owner !== undefined ? { owner: identity.owner } : {}),
77
+ ...(identity.model !== undefined ? { model: identity.model } : {}),
78
+ savedAt: new Date().toISOString(),
79
+ messages: [...messages],
80
+ };
81
+ }
@@ -7,35 +7,36 @@
7
7
  * none. See ADR-028 sections 2 and 3.
8
8
  */
9
9
  import type { ConversationMessage } from "@nathapp/nax-ai";
10
+ import { type TranscriptIdentity } from "./transcript-identity.ts";
11
+ import type { TranscriptDoc, TranscriptStore } from "./transcript-types.ts";
12
+ export { type TranscriptIdentity, transcriptModelIdentity } from "./transcript-identity.ts";
10
13
  export declare function transcriptPath(dir: string, sessionName: string): string;
11
14
  /**
12
- * Who may resume a transcript. `owner` is the op invocation (nax#1877); `model`
13
- * is the native model that wrote it (nax#2150, P3 spec 8.3). A field the caller
14
- * leaves undefined makes no claim, so non-op callers and tests read whatever is
15
- * there.
15
+ * Missing file means no document. Anything else unreadable is a real failure.
16
+ *
17
+ * The file wraps the messages rather than storing a bare array, which is what
18
+ * makes nax#1877 unrepresentable: it records WHO it belongs to (`owner`, the op
19
+ * invocation's `scopeId ?? callId`) and which model wrote it (`model`, nax#2150),
20
+ * so the next session of the same deterministic name can tell its own history
21
+ * from an abandoned invocation's or another model's. A bare array is a pre-#1877
22
+ * transcript: it loads as an owner-less document, which `historyFromTranscript`
23
+ * then drops for any reader that has an owner.
16
24
  */
17
- export interface TranscriptIdentity {
18
- readonly owner?: string;
19
- readonly model?: string;
20
- }
25
+ export declare function readTranscriptDoc(dir: string, sessionName: string): Promise<TranscriptDoc | null>;
21
26
  /**
22
- * The model half of `TranscriptIdentity`: the native `provider/model` id with
23
- * the reasoning-effort suffix stripped. A thinking signature binds to the
24
- * model, not to the effort — pi-ai's own `isSameModel` compares
25
- * provider/api/model for the same reason. `parseModelSpec`, not
26
- * `parseNativeModel`: this must never throw.
27
+ * The file store: `<dir>/<name>.transcript.json`, written as given (the loop's
28
+ * documents carry no schemaVersion or turn, so nax's bytes are unchanged).
29
+ * `retainFailed` is the kept-on-failure rename plus the prune to
30
+ * `MAX_RETAINED_TRANSCRIPTS` that `closeNativeSession` used to run inline.
27
31
  */
28
- export declare function transcriptModelIdentity(rawModel: string | undefined): string | undefined;
32
+ export declare function createFileTranscriptStore(dir: string): TranscriptStore;
29
33
  /**
30
- * Missing file means a new conversation. Anything else is a real failure.
31
- *
32
- * Returns `[]` — a new conversation — when the transcript on disk belongs to a
33
- * different `owner` or a different recorded `model` than the caller (nax#1877,
34
- * nax#2150). A reader that declares no identity is not making a claim and
35
- * reads whatever is there, which keeps non-op callers and unit tests working
36
- * unchanged.
34
+ * File-level convenience over the file store: the history `sessionName` may
35
+ * resume from `dir`. `[]` — a new conversation — when there is no file or it
36
+ * belongs to a different `owner` or recorded `model` (nax#1877, nax#2150).
37
37
  */
38
38
  export declare function loadTranscript(dir: string, sessionName: string, identity?: TranscriptIdentity): Promise<ConversationMessage[]>;
39
+ /** File-level convenience over the file store: save a loop document. */
39
40
  export declare function saveTranscript(dir: string, sessionName: string, messages: readonly ConversationMessage[], identity?: TranscriptIdentity): Promise<void>;
40
41
  /**
41
42
  * Move a failed session's transcript off the path the next session reads.
@@ -9,41 +9,37 @@
9
9
  import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { getLogger, NaxError } from "#src/infra/index";
12
- import { parseModelSpec } from "../models.js";
12
+ import { historyFromTranscript, transcriptDocFor } from "./transcript-identity.js";
13
+ export { transcriptModelIdentity } from "./transcript-identity.js";
13
14
  export function transcriptPath(dir, sessionName) {
14
15
  return join(dir, `${sessionName}.transcript.json`);
15
16
  }
16
- /**
17
- * The model half of `TranscriptIdentity`: the native `provider/model` id with
18
- * the reasoning-effort suffix stripped. A thinking signature binds to the
19
- * model, not to the effort — pi-ai's own `isSameModel` compares
20
- * provider/api/model for the same reason. `parseModelSpec`, not
21
- * `parseNativeModel`: this must never throw.
22
- */
23
- export function transcriptModelIdentity(rawModel) {
24
- return rawModel === undefined ? undefined : parseModelSpec(rawModel).model;
25
- }
26
- /** A bare array is a pre-#1877 transcript: real messages, no recorded owner. */
27
- function isLegacyTranscript(parsed) {
28
- return Array.isArray(parsed);
17
+ /** `savedAt` for a pre-#1877 bare-array transcript, which recorded none. */
18
+ const LEGACY_SAVED_AT = new Date(0).toISOString();
19
+ function corruptTranscript(sessionName, reason) {
20
+ return new NaxError(`transcript for session "${sessionName}" is unreadable: ${reason}`, "TRANSCRIPT_CORRUPT", {
21
+ stage: "native-session",
22
+ });
29
23
  }
30
24
  /**
31
- * Missing file means a new conversation. Anything else is a real failure.
25
+ * Missing file means no document. Anything else unreadable is a real failure.
32
26
  *
33
- * Returns `[]` — a new conversation — when the transcript on disk belongs to a
34
- * different `owner` or a different recorded `model` than the caller (nax#1877,
35
- * nax#2150). A reader that declares no identity is not making a claim and
36
- * reads whatever is there, which keeps non-op callers and unit tests working
37
- * unchanged.
27
+ * The file wraps the messages rather than storing a bare array, which is what
28
+ * makes nax#1877 unrepresentable: it records WHO it belongs to (`owner`, the op
29
+ * invocation's `scopeId ?? callId`) and which model wrote it (`model`, nax#2150),
30
+ * so the next session of the same deterministic name can tell its own history
31
+ * from an abandoned invocation's or another model's. A bare array is a pre-#1877
32
+ * transcript: it loads as an owner-less document, which `historyFromTranscript`
33
+ * then drops for any reader that has an owner.
38
34
  */
39
- export async function loadTranscript(dir, sessionName, identity = {}) {
35
+ export async function readTranscriptDoc(dir, sessionName) {
40
36
  let raw;
41
37
  try {
42
38
  raw = await readFile(transcriptPath(dir, sessionName), "utf8");
43
39
  }
44
40
  catch (err) {
45
41
  if (err.code === "ENOENT")
46
- return [];
42
+ return null;
47
43
  throw err;
48
44
  }
49
45
  let parsed;
@@ -51,59 +47,54 @@ export async function loadTranscript(dir, sessionName, identity = {}) {
51
47
  parsed = JSON.parse(raw);
52
48
  }
53
49
  catch (err) {
54
- // Deliberately not [] — silently restarting a conversation would drop the
50
+ // Deliberately not null: silently restarting a conversation would drop the
55
51
  // history the model is mid-way through and look like a fresh session.
56
- throw new NaxError(`transcript for session "${sessionName}" is unreadable: ${err instanceof Error ? err.message : String(err)}`, "TRANSCRIPT_CORRUPT", {
57
- stage: "native-session",
58
- });
52
+ throw corruptTranscript(sessionName, err instanceof Error ? err.message : String(err));
59
53
  }
60
- if (isLegacyTranscript(parsed)) {
61
- // Unowned history is foreign history to a reader that has an identity.
62
- // Dropping it is the safe direction: the cost is one re-exploration, where
63
- // inheriting it silently bills a conversation this session never had.
64
- return identity.owner === undefined ? parsed : [];
65
- }
66
- const file = parsed;
67
- if (isForeignTranscript(file, identity, sessionName))
68
- return [];
69
- return file.messages ?? [];
54
+ if (Array.isArray(parsed))
55
+ return { savedAt: LEGACY_SAVED_AT, messages: parsed };
56
+ if (typeof parsed !== "object" || parsed === null)
57
+ throw corruptTranscript(sessionName, "not a transcript document");
58
+ const doc = parsed;
59
+ return { ...doc, messages: doc.messages ?? [] };
60
+ }
61
+ async function writeTranscriptDoc(dir, sessionName, doc) {
62
+ await mkdir(dir, { recursive: true });
63
+ await writeFile(transcriptPath(dir, sessionName), JSON.stringify(doc, null, 2), "utf8");
70
64
  }
71
65
  /**
72
- * Another invocation's history (nax#1877) or another model's (nax#2150, P3
73
- * spec 8.3(c)) reads as a new conversation. An ABSENT file model reads, unlike
74
- * an absent owner: every native production turn records one (the adapter parses
75
- * the model before the loop runs), so an absent field is a pre-upgrade file —
76
- * which the owner check already keeps out of new processes.
66
+ * The file store: `<dir>/<name>.transcript.json`, written as given (the loop's
67
+ * documents carry no schemaVersion or turn, so nax's bytes are unchanged).
68
+ * `retainFailed` is the kept-on-failure rename plus the prune to
69
+ * `MAX_RETAINED_TRANSCRIPTS` that `closeNativeSession` used to run inline.
77
70
  */
78
- function isForeignTranscript(file, identity, sessionName) {
79
- const { owner, model } = identity;
80
- if (owner !== undefined && file.owner !== owner) {
81
- getLogger().debug("native-session", "Ignoring a transcript owned by another invocation", {
82
- sessionName,
83
- storedOwner: file.owner,
84
- owner,
85
- });
86
- return true;
87
- }
88
- if (model !== undefined && file.model !== undefined && file.model !== model) {
89
- getLogger().debug("native-session", "Ignoring a transcript written by another model", {
90
- sessionName,
91
- storedModel: file.model,
92
- model,
93
- });
94
- return true;
95
- }
96
- return false;
71
+ export function createFileTranscriptStore(dir) {
72
+ return {
73
+ load: (sessionId) => readTranscriptDoc(dir, sessionId),
74
+ save: (sessionId, doc) => writeTranscriptDoc(dir, sessionId, doc),
75
+ retainFailed: async (sessionId) => {
76
+ await retainTranscript(dir, sessionId);
77
+ await pruneRetainedTranscripts(dir);
78
+ },
79
+ delete: (sessionId) => deleteTranscript(dir, sessionId),
80
+ markTurn: async (sessionId, marker) => {
81
+ const existing = await readTranscriptDoc(dir, sessionId);
82
+ const base = existing ?? { savedAt: new Date().toISOString(), messages: [] };
83
+ await writeTranscriptDoc(dir, sessionId, { ...base, turn: marker });
84
+ },
85
+ };
97
86
  }
87
+ /**
88
+ * File-level convenience over the file store: the history `sessionName` may
89
+ * resume from `dir`. `[]` — a new conversation — when there is no file or it
90
+ * belongs to a different `owner` or recorded `model` (nax#1877, nax#2150).
91
+ */
92
+ export async function loadTranscript(dir, sessionName, identity = {}) {
93
+ return [...historyFromTranscript(await readTranscriptDoc(dir, sessionName), identity, sessionName)];
94
+ }
95
+ /** File-level convenience over the file store: save a loop document. */
98
96
  export async function saveTranscript(dir, sessionName, messages, identity = {}) {
99
- await mkdir(dir, { recursive: true });
100
- const file = {
101
- ...(identity.owner !== undefined ? { owner: identity.owner } : {}),
102
- ...(identity.model !== undefined ? { model: identity.model } : {}),
103
- savedAt: new Date().toISOString(),
104
- messages: [...messages],
105
- };
106
- await writeFile(transcriptPath(dir, sessionName), JSON.stringify(file, null, 2), "utf8");
97
+ await writeTranscriptDoc(dir, sessionName, transcriptDocFor(messages, identity));
107
98
  }
108
99
  /**
109
100
  * Move a failed session's transcript off the path the next session reads.