@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.
- package/CHANGELOG.md +22 -0
- package/README.md +47 -2
- package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
- package/dist/coding-tools/coding-tool-sandbox.js +14 -3
- package/dist/coding-tools/coding-tool-support.d.ts +3 -0
- package/dist/coding-tools/coding-tool-support.js +2 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +4 -1
- package/dist/internal/redact.d.ts +23 -0
- package/dist/internal/redact.js +60 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +8 -0
- package/dist/native/adapter-deps.d.ts +3 -3
- package/dist/native/adapter-deps.js +3 -3
- package/dist/native/client.d.ts +7 -1
- package/dist/native/client.js +1 -1
- package/dist/native/credentials/change-guard.d.ts +8 -1
- package/dist/native/credentials/change-guard.js +1 -1
- package/dist/native/credentials/fingerprint.d.ts +4 -1
- package/dist/native/credentials/fingerprint.js +6 -3
- package/dist/native/credentials/session-source.d.ts +12 -0
- package/dist/native/credentials/session-source.js +33 -0
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +2 -1
- package/dist/native/session/loop-events/types.d.ts +1 -1
- package/dist/native/session/loop-handlers.d.ts +3 -0
- package/dist/native/session/loop-handlers.js +1 -1
- package/dist/native/session/memory-transcript-store.d.ts +12 -0
- package/dist/native/session/memory-transcript-store.js +36 -0
- package/dist/native/session/session.d.ts +138 -96
- package/dist/native/session/session.js +107 -127
- package/dist/native/session/transcript-identity.d.ts +39 -0
- package/dist/native/session/transcript-identity.js +81 -0
- package/dist/native/session/transcript-store.d.ts +22 -21
- package/dist/native/session/transcript-store.js +59 -68
- package/dist/native/session/transcript-types.d.ts +38 -0
- package/dist/native/session/transcript-types.js +7 -0
- package/dist/native/session/truncation-handler.d.ts +4 -3
- package/dist/native/session/truncation-handler.js +7 -10
- package/dist/native/session/turn-complete-step.d.ts +4 -1
- package/dist/native/session/turn-complete-step.js +11 -4
- package/dist/native/session/turn-event-emitter.d.ts +48 -0
- package/dist/native/session/turn-event-emitter.js +134 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
- package/dist/native/session/turn-loop-round-trip.js +8 -4
- package/dist/native/session/turn-loop.js +27 -13
- package/dist/native/session/turn-result.d.ts +1 -1
- package/dist/native/session/turn-result.js +1 -1
- package/dist/native/session/turn-tool-batch.d.ts +3 -0
- package/dist/native/session/turn-tool-batch.js +14 -6
- package/dist/native/session/turn-types.d.ts +17 -1
- package/dist/native/session-adapter.d.ts +22 -4
- package/dist/native/session-adapter.js +79 -35
- package/dist/native/stream-complete.d.ts +17 -0
- package/dist/native/stream-complete.js +40 -0
- package/dist/permissions/approvals-link.js +5 -4
- package/dist/sandbox/policy-builder.d.ts +6 -1
- package/dist/sandbox/policy-builder.js +18 -14
- package/dist/session/agent-session-deps.d.ts +12 -0
- package/dist/session/agent-session-deps.js +28 -0
- package/dist/session/agent-session-errors.d.ts +11 -0
- package/dist/session/agent-session-errors.js +12 -0
- package/dist/session/agent-session-options.d.ts +16 -0
- package/dist/session/agent-session-options.js +137 -0
- package/dist/session/agent-session-resume.d.ts +12 -0
- package/dist/session/agent-session-resume.js +47 -0
- package/dist/session/agent-session-turn.d.ts +45 -0
- package/dist/session/agent-session-turn.js +179 -0
- package/dist/session/agent-session-types.d.ts +182 -0
- package/dist/session/agent-session-types.js +1 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +223 -0
- package/dist/session/pending-asks.d.ts +24 -0
- package/dist/session/pending-asks.js +77 -0
- package/dist/session/session-ask-link.d.ts +26 -0
- package/dist/session/session-ask-link.js +52 -0
- package/dist/session/session-event-channel.d.ts +17 -0
- package/dist/session/session-event-channel.js +132 -0
- package/dist/session/session-interaction.d.ts +20 -0
- package/dist/session/session-interaction.js +164 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +101 -0
- package/dist/session/session-types.d.ts +31 -2
- package/dist/session/turn-event.d.ts +61 -0
- package/dist/session/turn-event.js +1 -0
- package/dist/tools/bash-cwd.d.ts +3 -3
- package/dist/tools/credential-read-deny.d.ts +22 -0
- package/dist/tools/credential-read-deny.js +56 -0
- package/dist/tools/glob.js +9 -2
- package/dist/tools/grep.js +55 -26
- package/dist/tools/owned-paths.d.ts +43 -0
- package/dist/tools/owned-paths.js +17 -0
- package/dist/tools/package-managers.d.ts +3 -0
- package/dist/tools/package-managers.js +7 -3
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +49 -62
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +2 -1
- package/dist/tools/policy-paths-branch.d.ts +14 -8
- package/dist/tools/policy-paths-branch.js +20 -19
- package/dist/tools/policy.d.ts +10 -7
- package/dist/tools/policy.js +9 -7
- package/dist/tools/protected-paths.d.ts +6 -6
- package/dist/tools/read.js +38 -20
- package/dist/tools/registry.d.ts +3 -0
- package/dist/tools/run-command-exec.js +1 -0
- package/dist/tools/runtime.d.ts +3 -0
- package/dist/tools/runtime.js +1 -0
- package/dist/tools/scratchpad.js +2 -1
- package/dist/tools/types.d.ts +2 -2
- package/package.json +2 -2
- package/dist/tools/nax-owned-writes.d.ts +0 -135
- 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 {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
state.failed.add(sessionName);
|
|
109
48
|
else
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
102
|
+
state.transcriptOwners.set(name, opts.transcriptOwner);
|
|
125
103
|
else
|
|
126
|
-
|
|
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
|
|
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
|
|
113
|
+
await store.delete(name);
|
|
135
114
|
if (opts.compaction !== undefined)
|
|
136
|
-
|
|
115
|
+
state.compaction.set(name, opts.compaction);
|
|
137
116
|
if (opts.transportRetry !== undefined)
|
|
138
|
-
|
|
117
|
+
state.transportRetry.set(name, opts.transportRetry);
|
|
139
118
|
if (opts.spinBreaker !== undefined)
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
183
|
-
*
|
|
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
|
|
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 ??
|
|
173
|
+
const treatAsFailed = failed ?? state.failed.has(handle.id);
|
|
191
174
|
try {
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
await
|
|
196
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
32
|
+
export declare function createFileTranscriptStore(dir: string): TranscriptStore;
|
|
29
33
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
+
* Missing file means no document. Anything else unreadable is a real failure.
|
|
32
26
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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
|
|
57
|
-
stage: "native-session",
|
|
58
|
-
});
|
|
52
|
+
throw corruptTranscript(sessionName, err instanceof Error ? err.message : String(err));
|
|
59
53
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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.
|