@nanobpm/agentic 0.1.0 → 0.4.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/README.md +1 -0
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACP transport seam — ADR 0062, slice 2.
|
|
3
|
+
*
|
|
4
|
+
* ACP frames JSON-RPC 2.0 as **newline-delimited JSON over stdio** (one complete
|
|
5
|
+
* message per line, UTF-8, no `Content-Length` headers — that is LSP/MCP framing,
|
|
6
|
+
* not ACP). {@link AcpConnection} speaks only to this narrow {@link AcpTransport}
|
|
7
|
+
* port, so the JSON-RPC peer never knows whether it is wired to a spawned
|
|
8
|
+
* `opencode acp` subprocess ({@link ./spawn.ts}) or, in a test, to an in-memory
|
|
9
|
+
* fake agent ({@link inMemoryTransportPair}). Transport is a seam, never
|
|
10
|
+
* re-implemented per backend (AGENTS.md: no drift surfaces).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Split a byte stream into complete newline-delimited JSON messages. Handles
|
|
14
|
+
* chunk boundaries that fall mid-line (buffering the remainder) and ignores
|
|
15
|
+
* blank lines. Each decoded value is handed to `onMessage`; a line that fails to
|
|
16
|
+
* parse goes to `onError` and does not abort the stream.
|
|
17
|
+
*/
|
|
18
|
+
export class NewlineJsonDecoder {
|
|
19
|
+
#buffer = "";
|
|
20
|
+
#onMessage;
|
|
21
|
+
#onError;
|
|
22
|
+
constructor(onMessage, onError) {
|
|
23
|
+
this.#onMessage = onMessage;
|
|
24
|
+
this.#onError = onError;
|
|
25
|
+
}
|
|
26
|
+
/** Feed a decoded string chunk; emits every complete line it now contains. */
|
|
27
|
+
push(chunk) {
|
|
28
|
+
this.#buffer += chunk;
|
|
29
|
+
let newlineIndex = this.#buffer.indexOf("\n");
|
|
30
|
+
while (newlineIndex !== -1) {
|
|
31
|
+
const line = this.#buffer.slice(0, newlineIndex).trim();
|
|
32
|
+
this.#buffer = this.#buffer.slice(newlineIndex + 1);
|
|
33
|
+
if (line.length > 0)
|
|
34
|
+
this.#deliver(line);
|
|
35
|
+
newlineIndex = this.#buffer.indexOf("\n");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Deliver any final buffered line at stream end (EOF without a trailing
|
|
40
|
+
* newline). A compliant peer newline-terminates every message, but on abrupt
|
|
41
|
+
* process/pipe EOF a complete final message can sit unterminated in the buffer;
|
|
42
|
+
* flushing it surfaces the message (or a parse error) instead of silently
|
|
43
|
+
* dropping it. Idempotent: it clears the buffer, so a second call is a no-op.
|
|
44
|
+
*/
|
|
45
|
+
flush() {
|
|
46
|
+
const line = this.#buffer.trim();
|
|
47
|
+
this.#buffer = "";
|
|
48
|
+
if (line.length > 0)
|
|
49
|
+
this.#deliver(line);
|
|
50
|
+
}
|
|
51
|
+
#deliver(line) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(line);
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
this.#onError(new Error(`ACP transport received a non-JSON line: ${line.slice(0, 200)}`, { cause }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.#onMessage(parsed);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Serialise a JSON-RPC message as a single ACP wire line (JSON + `\n`). */
|
|
64
|
+
export function encodeMessageLine(message) {
|
|
65
|
+
const json = JSON.stringify(message);
|
|
66
|
+
// `JSON.stringify(undefined)` (and other non-serialisable inputs) returns
|
|
67
|
+
// `undefined`, which would emit the literal line "undefined\n" and always fail
|
|
68
|
+
// `JSON.parse` on the peer. Fail loudly at the sender instead of on the wire.
|
|
69
|
+
if (json === undefined) {
|
|
70
|
+
throw new Error("ACP transport cannot encode a non-JSON-serialisable message");
|
|
71
|
+
}
|
|
72
|
+
return `${json}\n`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A pair of transports wired directly to each other in memory: what one `send`s
|
|
76
|
+
* the other receives (after a `queueMicrotask` hop, so delivery is asynchronous
|
|
77
|
+
* like a real pipe and never re-enters the sender synchronously). The reference
|
|
78
|
+
* substrate for driving a fake agent in tests without spawning a process.
|
|
79
|
+
*/
|
|
80
|
+
export function inMemoryTransportPair() {
|
|
81
|
+
const client = new InMemoryTransport();
|
|
82
|
+
const agent = new InMemoryTransport();
|
|
83
|
+
client.connect(agent);
|
|
84
|
+
agent.connect(client);
|
|
85
|
+
return { client, agent };
|
|
86
|
+
}
|
|
87
|
+
class InMemoryTransport {
|
|
88
|
+
#peer;
|
|
89
|
+
#onMessage;
|
|
90
|
+
#onError;
|
|
91
|
+
#closed = false;
|
|
92
|
+
connect(peer) {
|
|
93
|
+
this.#peer = peer;
|
|
94
|
+
}
|
|
95
|
+
send(message) {
|
|
96
|
+
if (this.#closed)
|
|
97
|
+
return;
|
|
98
|
+
// Round-trip through the wire encoding so an in-memory test exercises the same
|
|
99
|
+
// JSON serialisation a real pipe would.
|
|
100
|
+
const line = encodeMessageLine(message);
|
|
101
|
+
const peer = this.#peer;
|
|
102
|
+
queueMicrotask(() => peer?.receive(line));
|
|
103
|
+
}
|
|
104
|
+
receive(line) {
|
|
105
|
+
if (this.#closed)
|
|
106
|
+
return;
|
|
107
|
+
const handler = this.#onMessage;
|
|
108
|
+
if (handler === undefined)
|
|
109
|
+
return;
|
|
110
|
+
const onError = this.#onError ??
|
|
111
|
+
((error) => {
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
const decoder = new NewlineJsonDecoder(handler, onError);
|
|
115
|
+
decoder.push(line);
|
|
116
|
+
}
|
|
117
|
+
onMessage(handler) {
|
|
118
|
+
this.#onMessage = handler;
|
|
119
|
+
}
|
|
120
|
+
onError(handler) {
|
|
121
|
+
this.#onError = handler;
|
|
122
|
+
}
|
|
123
|
+
close() {
|
|
124
|
+
this.#closed = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@nanobpm/agentic/session` adapter contract — ADR 0062, slice 1.
|
|
3
|
+
*
|
|
4
|
+
* The three-method interface every session backend implements. It is the *mind*
|
|
5
|
+
* side of a durable agent activation: the authoritative log of what the agent
|
|
6
|
+
* thought/did ({@link SessionAdapter.emit}, the "mind tap"), the mind/world join
|
|
7
|
+
* points at a push boundary ({@link SessionAdapter.checkpoint}), and the seed a
|
|
8
|
+
* re-leased incarnation replays to resume ({@link SessionAdapter.restore}).
|
|
9
|
+
*
|
|
10
|
+
* Publishing this interface + the {@link SessionEvent} types first is the whole
|
|
11
|
+
* point of slice 1: the ACP client (slice 2), the stream-json/native normalisers
|
|
12
|
+
* (slice 3) and the nano-workforce world-restore (slice 4) all code against this
|
|
13
|
+
* stable contract in parallel. Keep it small and additive.
|
|
14
|
+
*/
|
|
15
|
+
import type { AppendedSessionEvent, SessionEvent } from "./events.ts";
|
|
16
|
+
/**
|
|
17
|
+
* Identifies one **activation** — a single leased run of a BPMN element. This is
|
|
18
|
+
* the log's identity for fencing and replay: every incarnation of the same
|
|
19
|
+
* `(processInstanceKey, elementId)` shares one causal log and one fence, so a
|
|
20
|
+
* re-lease resumes the same session rather than starting a new one.
|
|
21
|
+
*
|
|
22
|
+
* The authoritative log is keyed `(processInstanceKey, elementId)` (ADR 0062):
|
|
23
|
+
* this pair identifies the activation and shares one fence. The current
|
|
24
|
+
* `incarnation` is stored as the fence token, and each appended event carries an
|
|
25
|
+
* `incarnation` stamp recording which generation wrote that row.
|
|
26
|
+
*/
|
|
27
|
+
export interface ActivationKey {
|
|
28
|
+
readonly processInstanceKey: string;
|
|
29
|
+
readonly elementId: string;
|
|
30
|
+
}
|
|
31
|
+
/** Render an {@link ActivationKey} as an opaque, collision-free string key. */
|
|
32
|
+
export declare function activationKeyString(key: ActivationKey): string;
|
|
33
|
+
/**
|
|
34
|
+
* One recorded side effect at a checkpoint's push boundary. The *shape* of an
|
|
35
|
+
* effect (its `kind` vocabulary, how it is replayed/fenced) is owned by slice 4
|
|
36
|
+
* (nano-workforce world-restore); slice 1 stores the ledger opaquely so the
|
|
37
|
+
* contract is stable before that lands. `detail` is an opaque JSON value.
|
|
38
|
+
*/
|
|
39
|
+
export interface EffectEntry {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly kind: string;
|
|
42
|
+
readonly detail?: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The world-side effects committed at a checkpoint. Stored verbatim (JSON) and
|
|
46
|
+
* handed back unchanged at {@link SessionAdapter.restore}; slice 1 never
|
|
47
|
+
* interprets it.
|
|
48
|
+
*/
|
|
49
|
+
export type EffectLedger = readonly EffectEntry[];
|
|
50
|
+
/**
|
|
51
|
+
* A mind/world join recorded at a push boundary: the agent's mind is at log
|
|
52
|
+
* `offset`, the world is at git `commitSha`, and `effectLedger` lists the effects
|
|
53
|
+
* that boundary committed. On re-lease the newest checkpoint's seed is replayed.
|
|
54
|
+
*/
|
|
55
|
+
export interface SessionCheckpoint {
|
|
56
|
+
/** Unique checkpoint id (producer- or backend-assigned). */
|
|
57
|
+
readonly id: string;
|
|
58
|
+
/**
|
|
59
|
+
* The log offset the checkpoint pins: exactly the log's `nextOffset` at the
|
|
60
|
+
* moment it was taken, so the seed is events `[0, offset)`.
|
|
61
|
+
*/
|
|
62
|
+
readonly offset: number;
|
|
63
|
+
/** The world (git) commit the mind is joined to at this boundary. */
|
|
64
|
+
readonly commitSha: string;
|
|
65
|
+
/** The effects committed at this boundary (opaque to slice 1). */
|
|
66
|
+
readonly effectLedger: EffectLedger;
|
|
67
|
+
/** The incarnation that took the checkpoint. */
|
|
68
|
+
readonly incarnation: number;
|
|
69
|
+
/** When the checkpoint was taken, ISO-8601. */
|
|
70
|
+
readonly at: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The mind seed handed back on re-lease: everything a resumed incarnation needs
|
|
74
|
+
* to reconstruct the agent's mind up to a checkpoint and continue from there.
|
|
75
|
+
*/
|
|
76
|
+
export interface SessionSeed {
|
|
77
|
+
/**
|
|
78
|
+
* The checkpoint the seed restores from, or `null` when the session has no
|
|
79
|
+
* checkpoint yet (a fresh start — `events` is empty and `nextOffset` is 0).
|
|
80
|
+
*/
|
|
81
|
+
readonly checkpoint: SessionCheckpoint | null;
|
|
82
|
+
/**
|
|
83
|
+
* The authoritative log replayed up to (and excluding) the checkpoint offset,
|
|
84
|
+
* in offset order — the events the resumed agent replays to rebuild its mind.
|
|
85
|
+
*/
|
|
86
|
+
readonly events: readonly AppendedSessionEvent[];
|
|
87
|
+
/**
|
|
88
|
+
* The offset the resumed incarnation continues appending at — the checkpoint
|
|
89
|
+
* offset (0 when there is no checkpoint). Any events a dead incarnation wrote
|
|
90
|
+
* *past* the checkpoint are uncommitted and are not part of the seed.
|
|
91
|
+
*/
|
|
92
|
+
readonly nextOffset: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The three-method session adapter. An instance is bound to one
|
|
96
|
+
* {@link ActivationKey} and one `incarnation`; a re-lease constructs a new
|
|
97
|
+
* adapter at a higher incarnation over the same authoritative log.
|
|
98
|
+
*/
|
|
99
|
+
export interface SessionAdapter {
|
|
100
|
+
/** The activation this adapter writes to. */
|
|
101
|
+
readonly key: ActivationKey;
|
|
102
|
+
/** The generation of this writer (the fencing token). */
|
|
103
|
+
readonly incarnation: number;
|
|
104
|
+
/**
|
|
105
|
+
* The **mind tap**: append `event` to the authoritative session log at the
|
|
106
|
+
* current offset, returning it stamped with its assigned `offset` and this
|
|
107
|
+
* writer's `incarnation`. Fenced — a call from an incarnation that a newer one
|
|
108
|
+
* has superseded throws {@link StaleIncarnationError} and writes nothing.
|
|
109
|
+
*/
|
|
110
|
+
emit(event: SessionEvent): AppendedSessionEvent;
|
|
111
|
+
/**
|
|
112
|
+
* Mark a mind/world join at a push boundary: record a checkpoint pinning the
|
|
113
|
+
* current log offset to `commitSha` and `effectLedger`. Fenced like
|
|
114
|
+
* {@link emit}. Returns the recorded checkpoint.
|
|
115
|
+
*/
|
|
116
|
+
checkpoint(commitSha: string, effectLedger: EffectLedger): SessionCheckpoint;
|
|
117
|
+
/**
|
|
118
|
+
* Hand back the mind seed to resume from. With no argument (or an unknown id)
|
|
119
|
+
* it resolves the **latest** checkpoint; given a checkpoint id it restores that
|
|
120
|
+
* specific one. Returns a {@link SessionSeed} of the log up to the checkpoint
|
|
121
|
+
* offset. Read-only — it does not mutate the log or the fence.
|
|
122
|
+
*/
|
|
123
|
+
restore(fromCheckpoint?: string): SessionSeed;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Raised when a write (emit/checkpoint) is attempted by an incarnation that a
|
|
127
|
+
* newer one has already superseded — the fence rejected it. The stale writer's
|
|
128
|
+
* call has no effect; the log stays owned by the current incarnation.
|
|
129
|
+
*/
|
|
130
|
+
export declare class StaleIncarnationError extends Error {
|
|
131
|
+
readonly key: ActivationKey;
|
|
132
|
+
readonly incarnation: number;
|
|
133
|
+
readonly current: number;
|
|
134
|
+
constructor(key: ActivationKey, incarnation: number, current: number);
|
|
135
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Render an {@link ActivationKey} as an opaque, collision-free string key. */
|
|
2
|
+
export function activationKeyString(key) {
|
|
3
|
+
// NUL separates the two components so no pair of distinct (pik, elementId)
|
|
4
|
+
// values can collide by concatenation (NUL cannot appear in either).
|
|
5
|
+
return `${key.processInstanceKey}\u0000${key.elementId}`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Raised when a write (emit/checkpoint) is attempted by an incarnation that a
|
|
9
|
+
* newer one has already superseded — the fence rejected it. The stale writer's
|
|
10
|
+
* call has no effect; the log stays owned by the current incarnation.
|
|
11
|
+
*/
|
|
12
|
+
export class StaleIncarnationError extends Error {
|
|
13
|
+
key;
|
|
14
|
+
incarnation;
|
|
15
|
+
current;
|
|
16
|
+
constructor(key, incarnation, current) {
|
|
17
|
+
super(`incarnation ${incarnation} is fenced for activation ` +
|
|
18
|
+
`${key.processInstanceKey}/${key.elementId}: a newer incarnation ${current} has taken over`);
|
|
19
|
+
this.name = "StaleIncarnationError";
|
|
20
|
+
this.key = key;
|
|
21
|
+
this.incarnation = incarnation;
|
|
22
|
+
this.current = current;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ActivationKey, type EffectLedger, type SessionAdapter, type SessionCheckpoint, type SessionSeed } from "./adapter.ts";
|
|
2
|
+
import type { AppendedSessionEvent, SessionEvent } from "./events.ts";
|
|
3
|
+
import { type Clock, InMemorySessionLog, type SessionLog, type SqliteDb, SqliteSessionLog } from "./log.ts";
|
|
4
|
+
export interface SessionBackendOptions {
|
|
5
|
+
/** Injectable clock for deterministic checkpoint timestamps. Default {@link systemClock}. */
|
|
6
|
+
clock?: Clock;
|
|
7
|
+
/** Injectable checkpoint-id generator (deterministic tests). Default `crypto.randomUUID`. */
|
|
8
|
+
newCheckpointId?: () => string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A session adapter bound to one activation and one incarnation. Constructing it
|
|
12
|
+
* takes the lease at `incarnation` (advancing the fence), so a re-lease at a
|
|
13
|
+
* higher incarnation immediately fences every prior one — a subsequent `emit`
|
|
14
|
+
* from an older adapter throws {@link StaleIncarnationError}.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SessionBackend implements SessionAdapter {
|
|
17
|
+
#private;
|
|
18
|
+
readonly key: ActivationKey;
|
|
19
|
+
readonly incarnation: number;
|
|
20
|
+
constructor(log: SessionLog, key: ActivationKey, incarnation: number, options?: SessionBackendOptions);
|
|
21
|
+
/** The offset the next `emit` will assign. */
|
|
22
|
+
get nextOffset(): number;
|
|
23
|
+
emit(event: SessionEvent): AppendedSessionEvent;
|
|
24
|
+
checkpoint(commitSha: string, effectLedger: EffectLedger): SessionCheckpoint;
|
|
25
|
+
restore(fromCheckpoint?: string): SessionSeed;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Open a session adapter over an in-memory reference log. Pass a shared
|
|
29
|
+
* {@link InMemorySessionLog} across incarnations of the same activation so the
|
|
30
|
+
* fence and events persist across a re-lease (the reference resume scenario);
|
|
31
|
+
* omit it for a throwaway single-incarnation log.
|
|
32
|
+
*/
|
|
33
|
+
export declare function openInMemorySession(key: ActivationKey, incarnation: number, options?: SessionBackendOptions & {
|
|
34
|
+
log?: InMemorySessionLog;
|
|
35
|
+
}): {
|
|
36
|
+
backend: SessionBackend;
|
|
37
|
+
log: InMemorySessionLog;
|
|
38
|
+
};
|
|
39
|
+
/** Open a session adapter over the durable SQLite log. Applies the schema idempotently. */
|
|
40
|
+
export declare function openSqliteSession(db: SqliteDb, key: ActivationKey, incarnation: number, options?: SessionBackendOptions): {
|
|
41
|
+
backend: SessionBackend;
|
|
42
|
+
log: SqliteSessionLog;
|
|
43
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link SessionBackend} — the canonical {@link SessionAdapter} implementation,
|
|
3
|
+
* ADR 0062 slice 1.
|
|
4
|
+
*
|
|
5
|
+
* There is exactly **one** adapter implementation (derivation over duplication):
|
|
6
|
+
* it is bound to a {@link SessionLog} port, so the *same* emit/checkpoint/restore
|
|
7
|
+
* semantics run over either the in-memory reference log ({@link InMemorySessionLog}
|
|
8
|
+
* — the stub slices 2–5 code against) or the durable SQLite log
|
|
9
|
+
* ({@link SqliteSessionLog}). The backend owns the append cursor and the fencing
|
|
10
|
+
* token; the log owns storage and the fence high-water.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { InMemorySessionLog, SqliteSessionLog, systemClock } from "./log.js";
|
|
14
|
+
/**
|
|
15
|
+
* A session adapter bound to one activation and one incarnation. Constructing it
|
|
16
|
+
* takes the lease at `incarnation` (advancing the fence), so a re-lease at a
|
|
17
|
+
* higher incarnation immediately fences every prior one — a subsequent `emit`
|
|
18
|
+
* from an older adapter throws {@link StaleIncarnationError}.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionBackend {
|
|
21
|
+
key;
|
|
22
|
+
incarnation;
|
|
23
|
+
#log;
|
|
24
|
+
#clock;
|
|
25
|
+
#newCheckpointId;
|
|
26
|
+
/** The offset the next `emit` writes at. Advanced by `emit`, repositioned by `restore`. */
|
|
27
|
+
#cursor;
|
|
28
|
+
constructor(log, key, incarnation, options = {}) {
|
|
29
|
+
log.lease(key, incarnation);
|
|
30
|
+
this.#log = log;
|
|
31
|
+
this.key = key;
|
|
32
|
+
this.incarnation = incarnation;
|
|
33
|
+
this.#clock = options.clock ?? systemClock;
|
|
34
|
+
this.#newCheckpointId = options.newCheckpointId ?? randomUUID;
|
|
35
|
+
// Default to appending after whatever is already committed; `restore`
|
|
36
|
+
// repositions the cursor to a checkpoint boundary when resuming.
|
|
37
|
+
this.#cursor = log.nextOffset(key);
|
|
38
|
+
}
|
|
39
|
+
/** The offset the next `emit` will assign. */
|
|
40
|
+
get nextOffset() {
|
|
41
|
+
return this.#cursor;
|
|
42
|
+
}
|
|
43
|
+
emit(event) {
|
|
44
|
+
const appended = this.#log.append(this.key, this.incarnation, this.#cursor, event);
|
|
45
|
+
this.#cursor = appended.offset + 1;
|
|
46
|
+
return appended;
|
|
47
|
+
}
|
|
48
|
+
checkpoint(commitSha, effectLedger) {
|
|
49
|
+
const checkpoint = {
|
|
50
|
+
id: this.#newCheckpointId(),
|
|
51
|
+
offset: this.#cursor,
|
|
52
|
+
commitSha,
|
|
53
|
+
effectLedger,
|
|
54
|
+
incarnation: this.incarnation,
|
|
55
|
+
at: new Date(this.#clock.now()).toISOString(),
|
|
56
|
+
};
|
|
57
|
+
return this.#log.putCheckpoint(this.key, this.incarnation, checkpoint);
|
|
58
|
+
}
|
|
59
|
+
restore(fromCheckpoint) {
|
|
60
|
+
// Contract: no argument OR an unknown id resolves the latest checkpoint; a
|
|
61
|
+
// known id restores exactly that checkpoint.
|
|
62
|
+
const checkpoint = fromCheckpoint === undefined
|
|
63
|
+
? this.#log.latestCheckpoint(this.key)
|
|
64
|
+
: (this.#log.getCheckpoint(this.key, fromCheckpoint) ?? this.#log.latestCheckpoint(this.key));
|
|
65
|
+
if (checkpoint === undefined) {
|
|
66
|
+
// No checkpoint to resume from: start the mind fresh at offset 0. The next
|
|
67
|
+
// emit at offset 0 discards any uncommitted events a dead incarnation left.
|
|
68
|
+
this.#cursor = 0;
|
|
69
|
+
return { checkpoint: null, events: [], nextOffset: 0 };
|
|
70
|
+
}
|
|
71
|
+
const events = this.#log.replay(this.key, 0, checkpoint.offset);
|
|
72
|
+
// Reposition the write cursor to the checkpoint boundary: the resumed
|
|
73
|
+
// incarnation continues from there, overwriting any uncommitted tail.
|
|
74
|
+
this.#cursor = checkpoint.offset;
|
|
75
|
+
return { checkpoint, events, nextOffset: checkpoint.offset };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Open a session adapter over an in-memory reference log. Pass a shared
|
|
80
|
+
* {@link InMemorySessionLog} across incarnations of the same activation so the
|
|
81
|
+
* fence and events persist across a re-lease (the reference resume scenario);
|
|
82
|
+
* omit it for a throwaway single-incarnation log.
|
|
83
|
+
*/
|
|
84
|
+
export function openInMemorySession(key, incarnation, options = {}) {
|
|
85
|
+
const log = options.log ?? new InMemorySessionLog();
|
|
86
|
+
const backend = new SessionBackend(log, key, incarnation, options);
|
|
87
|
+
return { backend, log };
|
|
88
|
+
}
|
|
89
|
+
/** Open a session adapter over the durable SQLite log. Applies the schema idempotently. */
|
|
90
|
+
export function openSqliteSession(db, key, incarnation, options = {}) {
|
|
91
|
+
const log = new SqliteSessionLog(db, { clock: options.clock });
|
|
92
|
+
log.ensureSchema();
|
|
93
|
+
const backend = new SessionBackend(log, key, incarnation, options);
|
|
94
|
+
return { backend, log };
|
|
95
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical `SessionEvent` model — ADR 0062, slice 1 (the shared contract).
|
|
3
|
+
*
|
|
4
|
+
* This is **Nano's own** agent-session event model: the single schema every
|
|
5
|
+
* harness dialect (ACP, stream-json, a native normalizer, …) normalizes *into*.
|
|
6
|
+
* We never adopt an external harness schema as ours — those are ingestion
|
|
7
|
+
* details owned by the later slices; this union is the stable interface they all
|
|
8
|
+
* target.
|
|
9
|
+
*
|
|
10
|
+
* ## The causal chain
|
|
11
|
+
*
|
|
12
|
+
* A session is an append-only log of events. Two orthogonal orderings make the
|
|
13
|
+
* log both replayable and mergeable:
|
|
14
|
+
*
|
|
15
|
+
* - a **monotonic, gap-free `offset`** assigned by the authoritative log on
|
|
16
|
+
* append (see {@link AppendedSessionEvent}); it is the resume coordinate —
|
|
17
|
+
* `restore` hands back everything up to a checkpoint offset.
|
|
18
|
+
* - a **causal `parentId`** the producer stamps: the id of the event this one
|
|
19
|
+
* logically follows (`null` for the first event of a session). Offset gives a
|
|
20
|
+
* total order for replay; `parentId` records the *causal* edge, which survives
|
|
21
|
+
* a compaction that rewrites offsets.
|
|
22
|
+
*
|
|
23
|
+
* The producer owns identity (`id`) and causality (`parentId`); the log owns
|
|
24
|
+
* ordering (`offset`) and fencing (`incarnation`). Keeping those responsibilities
|
|
25
|
+
* split is what lets a resumed incarnation continue the same causal chain at a
|
|
26
|
+
* fresh offset without the producer knowing the log's internal cursor.
|
|
27
|
+
*/
|
|
28
|
+
/** Discriminates a {@link SessionEvent}. One member per row in the union below. */
|
|
29
|
+
export type SessionEventType = "system" | "user" | "assistant" | "reasoning" | "tool-call" | "tool-result" | "compaction" | "usage" | "turn-start" | "turn-end";
|
|
30
|
+
/** The set of valid event types, for a runtime membership check at the DB boundary. */
|
|
31
|
+
export declare const SESSION_EVENT_TYPES: readonly SessionEventType[];
|
|
32
|
+
/**
|
|
33
|
+
* The fields every event carries regardless of type. `offset` is deliberately
|
|
34
|
+
* absent — the producer does not assign it; the authoritative log does, yielding
|
|
35
|
+
* an {@link AppendedSessionEvent}.
|
|
36
|
+
*/
|
|
37
|
+
export interface SessionEventEnvelope {
|
|
38
|
+
/** Producer-assigned unique id for this event (the causal-chain node id). */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/** The id of the causal predecessor, or `null` for the first event of a session. */
|
|
41
|
+
readonly parentId: string | null;
|
|
42
|
+
}
|
|
43
|
+
/** A system/instruction message (the harness/system prompt turn). */
|
|
44
|
+
export interface SystemMessageEvent extends SessionEventEnvelope {
|
|
45
|
+
readonly type: "system";
|
|
46
|
+
readonly text: string;
|
|
47
|
+
}
|
|
48
|
+
/** A user message. */
|
|
49
|
+
export interface UserMessageEvent extends SessionEventEnvelope {
|
|
50
|
+
readonly type: "user";
|
|
51
|
+
readonly text: string;
|
|
52
|
+
}
|
|
53
|
+
/** An assistant (model) message — the visible answer text. */
|
|
54
|
+
export interface AssistantMessageEvent extends SessionEventEnvelope {
|
|
55
|
+
readonly type: "assistant";
|
|
56
|
+
readonly text: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Assistant reasoning (chain-of-thought / thinking) for a turn.
|
|
60
|
+
*
|
|
61
|
+
* `text` is the human-readable reasoning summary when the provider exposes one.
|
|
62
|
+
* `providerContinuation` is an **opaque provider reasoning-continuation blob**:
|
|
63
|
+
* some providers (e.g. encrypted reasoning tokens) return a handle that must be
|
|
64
|
+
* fed back verbatim to continue reasoning across a resume. Nano never parses,
|
|
65
|
+
* validates, or transforms it — it stores and replays it as an opaque string so
|
|
66
|
+
* a re-leased incarnation can resume the model's reasoning exactly.
|
|
67
|
+
*/
|
|
68
|
+
export interface ReasoningEvent extends SessionEventEnvelope {
|
|
69
|
+
readonly type: "reasoning";
|
|
70
|
+
readonly text?: string;
|
|
71
|
+
readonly providerContinuation?: string;
|
|
72
|
+
}
|
|
73
|
+
/** A tool/function call the assistant requested. */
|
|
74
|
+
export interface ToolCallEvent extends SessionEventEnvelope {
|
|
75
|
+
readonly type: "tool-call";
|
|
76
|
+
/** Correlates this call with its {@link ToolResultEvent}. */
|
|
77
|
+
readonly callId: string;
|
|
78
|
+
readonly name: string;
|
|
79
|
+
/** The call arguments, as an opaque JSON-serialisable value. */
|
|
80
|
+
readonly args: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** The result of a previously-emitted {@link ToolCallEvent}. */
|
|
83
|
+
export interface ToolResultEvent extends SessionEventEnvelope {
|
|
84
|
+
readonly type: "tool-result";
|
|
85
|
+
/** Matches the originating {@link ToolCallEvent.callId}. */
|
|
86
|
+
readonly callId: string;
|
|
87
|
+
/** `false` when the tool failed; the failure detail lives in `result`. */
|
|
88
|
+
readonly ok: boolean;
|
|
89
|
+
/** The tool output, as an opaque JSON-serialisable value. */
|
|
90
|
+
readonly result: unknown;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A compaction or truncation boundary: the events in the (inclusive-exclusive)
|
|
94
|
+
* offset range `[replacesFrom, replacesTo)` were summarised/dropped to bound
|
|
95
|
+
* context growth. `summary` is the replacement text (present for compaction,
|
|
96
|
+
* typically absent for a hard truncation). The original events keep their
|
|
97
|
+
* offsets in the authoritative log; this marker records that a *replay* should
|
|
98
|
+
* fold that range into the summary rather than replaying it verbatim.
|
|
99
|
+
*/
|
|
100
|
+
export interface CompactionEvent extends SessionEventEnvelope {
|
|
101
|
+
readonly type: "compaction";
|
|
102
|
+
readonly reason: "compaction" | "truncation";
|
|
103
|
+
readonly replacesFrom: number;
|
|
104
|
+
readonly replacesTo: number;
|
|
105
|
+
readonly summary?: string;
|
|
106
|
+
}
|
|
107
|
+
/** A usage/accounting record for a turn (token counts, etc.). */
|
|
108
|
+
export interface UsageEvent extends SessionEventEnvelope {
|
|
109
|
+
readonly type: "usage";
|
|
110
|
+
readonly inputTokens: number;
|
|
111
|
+
readonly outputTokens: number;
|
|
112
|
+
/** Optional provider model identifier the usage is attributed to. */
|
|
113
|
+
readonly model?: string;
|
|
114
|
+
}
|
|
115
|
+
/** The start of a turn (a request/response cycle). `turn` is a monotonic index. */
|
|
116
|
+
export interface TurnStartEvent extends SessionEventEnvelope {
|
|
117
|
+
readonly type: "turn-start";
|
|
118
|
+
readonly turn: number;
|
|
119
|
+
}
|
|
120
|
+
/** The end of a turn matching a prior {@link TurnStartEvent}. */
|
|
121
|
+
export interface TurnEndEvent extends SessionEventEnvelope {
|
|
122
|
+
readonly type: "turn-end";
|
|
123
|
+
readonly turn: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The canonical session event — a discriminated union over {@link SessionEventType}.
|
|
127
|
+
* Every harness dialect normalises into exactly this shape.
|
|
128
|
+
*/
|
|
129
|
+
export type SessionEvent = SystemMessageEvent | UserMessageEvent | AssistantMessageEvent | ReasoningEvent | ToolCallEvent | ToolResultEvent | CompactionEvent | UsageEvent | TurnStartEvent | TurnEndEvent;
|
|
130
|
+
/**
|
|
131
|
+
* A {@link SessionEvent} after the authoritative log has appended it: the same
|
|
132
|
+
* event plus the log-assigned `offset` (its monotonic resume coordinate) and the
|
|
133
|
+
* `incarnation` (the generation of the writer that produced it — the fencing
|
|
134
|
+
* stamp). This is what {@link restore} replays as the mind seed.
|
|
135
|
+
*/
|
|
136
|
+
export type AppendedSessionEvent = SessionEvent & {
|
|
137
|
+
readonly offset: number;
|
|
138
|
+
readonly incarnation: number;
|
|
139
|
+
};
|
|
140
|
+
/** Raised when a value read back from storage is not a well-formed session event. */
|
|
141
|
+
export declare class SessionEventShapeError extends Error {
|
|
142
|
+
constructor(message: string);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse and validate an untyped value (e.g. `JSON.parse` of a stored row) into a
|
|
146
|
+
* {@link SessionEvent}, reconstructing the exact union member for its `type`.
|
|
147
|
+
* Throws {@link SessionEventShapeError} on any malformed field. This is the
|
|
148
|
+
* single trusted boundary between untyped storage and the typed union — it never
|
|
149
|
+
* uses an `as`-cast to fabricate a shape (see AGENTS.md), it *builds* one field
|
|
150
|
+
* by field, so a corrupt row fails loudly instead of masquerading as valid.
|
|
151
|
+
*/
|
|
152
|
+
export declare function parseSessionEvent(value: unknown): SessionEvent;
|