@stackstackstack/dsh-agent 0.1.5

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.
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /** Package-owned agent lifecycle invariants. @module @stackstackstack/dsh-agent/invariant */
3
+ const PACKAGE_NAME = "@stackstackstack/dsh-agent";
4
+ /** Cordis companion plugin name. */
5
+ const name = "agent-invariant";
6
+ /** Services required before the companion can register. */
7
+ const inject = ["invariants"];
8
+ /** Install the agent contribution into its child registration fiber. */
9
+ const install = (ctx, fail) => {
10
+ const lastStatus = /* @__PURE__ */ new WeakMap();
11
+ ctx.on("agent/status", ({ agent, status }) => {
12
+ if (lastStatus.get(agent) === status) fail(`agent/status repeated ${status} (no-op transition)`);
13
+ lastStatus.set(agent, status);
14
+ }, { global: true });
15
+ };
16
+ /**
17
+ * Register the agent invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How one agent log accounts for the work it consumed.
3
+ *
4
+ * The turn and step vocabulary alone cannot answer this. A turn that stops
5
+ * before its first step leaves a `turn/end` shaped exactly like the balanced
6
+ * no-op turns a rejection or an empty claim produces, so reading turns in
7
+ * isolation either credits cut-short work as finished or convicts every no-op.
8
+ * The missing fact is the inbox's own record: {@link Inbox} logs each mutation
9
+ * with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
10
+ * separates a turn claiming its input from work being dropped unrun.
11
+ *
12
+ * @module @stackstackstack/dsh-agent/consumed-work
13
+ */
14
+ import type { SessionEvent } from '@stackstackstack/dsh-session';
15
+ /** How one agent log accounts for the work it consumed. */
16
+ export interface ConsumedWork {
17
+ /**
18
+ * The latest closed turn that accounts for consumed work: one that entered a
19
+ * model step, or one that claimed inbox input and then failed, was stopped,
20
+ * or was rejected. Absent when no turn closed over any work.
21
+ */
22
+ readonly end?: SessionEvent<'turn/end'>;
23
+ /**
24
+ * Whether accepted work was cancelled out of the inbox, unrun, after that
25
+ * turn. This is the only account of input a cancellation took before any turn
26
+ * could open over it — no `turn/end` describes it.
27
+ */
28
+ readonly droppedUnrun: boolean;
29
+ }
30
+ /**
31
+ * Fold one agent log, or an owned suffix of one, into its account of consumed
32
+ * work. Single pass, and every input is the log itself: no caller has to sample
33
+ * live state before cancelling, so a cancellation issued by anyone — the owner's
34
+ * teardown, an ancestor's interrupt, an unloading plugin — reads the same.
35
+ * @param events - the log, or an owned suffix, to fold.
36
+ * @returns the accounting turn when one closed, and whether work was dropped unrun after it.
37
+ */
38
+ export declare function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork;
39
+ //# sourceMappingURL=consumed-work.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * How one agent log accounts for the work it consumed.
3
+ *
4
+ * The turn and step vocabulary alone cannot answer this. A turn that stops
5
+ * before its first step leaves a `turn/end` shaped exactly like the balanced
6
+ * no-op turns a rejection or an empty claim produces, so reading turns in
7
+ * isolation either credits cut-short work as finished or convicts every no-op.
8
+ * The missing fact is the inbox's own record: {@link Inbox} logs each mutation
9
+ * with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
10
+ * separates a turn claiming its input from work being dropped unrun.
11
+ *
12
+ * @module @stackstackstack/dsh-agent/consumed-work
13
+ */
14
+ /**
15
+ * Whether a turn that consumed input but never reached a step ends in a way
16
+ * that accounts for that input. Only a `completed` end does not: it had
17
+ * nothing left to run once its claim was rewritten away. A `blocked` end is
18
+ * that input's ending too — the pre-step rejection that produced it discarded
19
+ * the claimed messages, so the work it took will never run.
20
+ * @param reason - the turn's recorded ending.
21
+ * @returns whether the ending accounts for the input the turn took.
22
+ */
23
+ function accountsForClaim(reason) {
24
+ switch (reason.kind) {
25
+ case 'completed':
26
+ return false;
27
+ case 'blocked':
28
+ case 'aborted':
29
+ case 'interrupted':
30
+ case 'error':
31
+ return true;
32
+ /* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
33
+ * so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
34
+ * merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
35
+ * consumed input must not read as success. */
36
+ default:
37
+ return true;
38
+ }
39
+ }
40
+ /**
41
+ * Fold one agent log, or an owned suffix of one, into its account of consumed
42
+ * work. Single pass, and every input is the log itself: no caller has to sample
43
+ * live state before cancelling, so a cancellation issued by anyone — the owner's
44
+ * teardown, an ancestor's interrupt, an unloading plugin — reads the same.
45
+ * @param events - the log, or an owned suffix, to fold.
46
+ * @returns the accounting turn when one closed, and whether work was dropped unrun after it.
47
+ */
48
+ export function foldConsumedWork(events) {
49
+ const stepped = new Set();
50
+ const claimed = new Set();
51
+ let open;
52
+ let end;
53
+ let droppedUnrun = false;
54
+ for (const event of events) {
55
+ switch (event.type) {
56
+ case 'turn/start':
57
+ open = event.data.turn;
58
+ break;
59
+ case 'step/start':
60
+ stepped.add(event.data.turn);
61
+ break;
62
+ case 'agent/inbox/spliced': {
63
+ const { removedCount, outcome, inserted } = event.data;
64
+ if (removedCount === undefined)
65
+ break;
66
+ // A replacement keeps the work pending under a new identity, so only a
67
+ // cancellation that leaves nothing behind drops it.
68
+ if (outcome === 'canceled')
69
+ droppedUnrun ||= inserted.length === 0;
70
+ // Claims are the loop's own step-boundary reads, always inside a turn.
71
+ else if (open !== undefined)
72
+ claimed.add(open);
73
+ break;
74
+ }
75
+ case 'turn/end': {
76
+ const { turn, reason } = event.data;
77
+ open = undefined;
78
+ if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
79
+ end = event;
80
+ // Anything dropped before this turn closed is what its own ending
81
+ // reports; only a later drop is still unaccounted for.
82
+ droppedUnrun = false;
83
+ }
84
+ break;
85
+ }
86
+ default:
87
+ break;
88
+ }
89
+ }
90
+ return { ...end === undefined ? {} : { end }, droppedUnrun };
91
+ }
92
+ //# sourceMappingURL=consumed-work.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
3
+ * {@link agentEvents} couples the agent subject to its scope carrier, so the
4
+ * scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
5
+ * loop driver) build it once in the agent's constructor and reuse it.
6
+ * @module @stackstackstack/dsh-agent/dispatch
7
+ */
8
+ import type { Context, Events } from '@deepseek-ai/cordis';
9
+ import type { Scoped } from '@stackstackstack/dsh-scope';
10
+ import type { AssembleContext } from '@stackstackstack/dsh-system-prompt';
11
+ import type { Agent } from './runtime-types.ts';
12
+ /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
13
+ type Params<F> = F extends (...args: infer P) => unknown ? P : never;
14
+ /** Extract the return type from an event handler type. */
15
+ type Return<F> = F extends (...args: never[]) => infer R ? R : never;
16
+ /**
17
+ * The event names whose subject is an agent: the handler's first parameter is
18
+ * a payload object carrying the `agent` subject AND the handler declares a
19
+ * `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
20
+ * accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
21
+ * whose parameter tuple would satisfy a bare rest-tuple check via callability)
22
+ * out of the fused-dispatch surface.
23
+ */
24
+ export type AgentSubjectEvent = {
25
+ [K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown ? P extends [infer Payload, ...unknown[]] ? Payload extends {
26
+ agent: Agent;
27
+ } ? K : never : never : never;
28
+ }[keyof Events];
29
+ /** The full payload object of one agent-subject event. */
30
+ type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never;
31
+ /** The event arguments AFTER the payload: the waterfall `next` when present. */
32
+ type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [unknown, ...infer R] ? R : never;
33
+ /**
34
+ * The payload as emit-side callers pass it: the full payload minus the agent
35
+ * field, which the fused dispatcher injects so subject and scope key cannot
36
+ * diverge.
37
+ */
38
+ type PayloadRest<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>;
39
+ /**
40
+ * The fused dispatcher {@link agentEvents} returns: each method dispatches the
41
+ * named agent-subject event with the agent's scope carrier as `thisArg` and
42
+ * the agent itself injected into the payload.
43
+ */
44
+ export interface AgentEventDispatch {
45
+ /**
46
+ * Fire-and-forget notification in the agent's scope. Every listener is
47
+ * invoked; synchronous throws and returned-promise rejections are logged and
48
+ * contained per listener, so a notification cannot veto lifecycle progress
49
+ * or starve a later observer.
50
+ * @param name - the agent-subject event to emit.
51
+ * @param payload - the event's payload fields; `agent` is injected.
52
+ */
53
+ emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void;
54
+ /**
55
+ * Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
56
+ * @param name - the agent-subject event to dispatch.
57
+ * @param payload - the event's payload fields; `agent` is injected.
58
+ * @returns the serial chain's result (the first bail value, if any).
59
+ */
60
+ serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>;
61
+ /**
62
+ * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
63
+ * declared event parameters already end with the `next` callback, so `rest`
64
+ * is exactly the event's arguments after the payload — the final element
65
+ * being the innermost `next` (the default the listener chain wraps).
66
+ * @param name - the agent-subject event to dispatch.
67
+ * @param payload - the event's payload fields; `agent` is injected.
68
+ * @param rest - the event's arguments after the payload (the `next` callback).
69
+ * @returns the waterfall's composed result.
70
+ */
71
+ waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>;
72
+ }
73
+ /**
74
+ * Build the fused scope carrier for one agent subject.
75
+ *
76
+ * The carrier is a stateless routing object. {@link agentEvents} accepts an
77
+ * existing carrier, so callers that dispatch repeatedly for the same agent
78
+ * (the loop driver) build it once in the agent's constructor and reuse it,
79
+ * keeping hot-path dispatches allocation-free.
80
+ * @param agent - the subject agent and scope key.
81
+ * @returns the carrier passed as the event dispatcher `this` value.
82
+ */
83
+ export declare function agentCarrier(agent: Agent): Scoped<Agent>;
84
+ /**
85
+ * Build a dispatcher that couples the agent subject to its scope carrier.
86
+ * @param ctx - the context to dispatch through (any context of the app).
87
+ * @param agent - the subject agent; also the scope-carrier key.
88
+ * @param carrier - the scope carrier to dispatch through; defaults to
89
+ * {@link agentCarrier} for the agent. Pass a constructor-built carrier to
90
+ * avoid rebuilding it for every dispatch.
91
+ * @returns the fused dispatcher.
92
+ */
93
+ export declare function agentEvents(ctx: Context, agent: Agent, carrier?: Scoped<Agent>): AgentEventDispatch;
94
+ /**
95
+ * Emit one contained agent notification without allocating a retained dispatcher.
96
+ * @param ctx - the context to dispatch through.
97
+ * @param agent - the subject agent and scope key.
98
+ * @param name - the agent-subject event to emit.
99
+ * @param payload - the event's payload fields; `agent` is injected.
100
+ */
101
+ export declare function emitAgentEvent<K extends AgentSubjectEvent>(ctx: Context, agent: Agent, name: K, payload: PayloadRest<K>): void;
102
+ /**
103
+ * Build the prompt assembly context with agent and scope set together, so
104
+ * agent-scoped prompt and tool contributions cannot be silently omitted.
105
+ * @param agent - the agent the assembly is for.
106
+ * @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
107
+ * @returns the context to pass to `assemble()`.
108
+ */
109
+ export declare function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext;
110
+ export {};
111
+ //# sourceMappingURL=dispatch.d.ts.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
3
+ * {@link agentEvents} couples the agent subject to its scope carrier, so the
4
+ * scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
5
+ * loop driver) build it once in the agent's constructor and reuse it.
6
+ * @module @stackstackstack/dsh-agent/dispatch
7
+ */
8
+ import { scopeTarget } from '@stackstackstack/dsh-scope';
9
+ /**
10
+ * Build the fused scope carrier for one agent subject.
11
+ *
12
+ * The carrier is a stateless routing object. {@link agentEvents} accepts an
13
+ * existing carrier, so callers that dispatch repeatedly for the same agent
14
+ * (the loop driver) build it once in the agent's constructor and reuse it,
15
+ * keeping hot-path dispatches allocation-free.
16
+ * @param agent - the subject agent and scope key.
17
+ * @returns the carrier passed as the event dispatcher `this` value.
18
+ */
19
+ export function agentCarrier(agent) {
20
+ return scopeTarget(agent, agent);
21
+ }
22
+ /**
23
+ * Build a dispatcher that couples the agent subject to its scope carrier.
24
+ * @param ctx - the context to dispatch through (any context of the app).
25
+ * @param agent - the subject agent; also the scope-carrier key.
26
+ * @param carrier - the scope carrier to dispatch through; defaults to
27
+ * {@link agentCarrier} for the agent. Pass a constructor-built carrier to
28
+ * avoid rebuilding it for every dispatch.
29
+ * @returns the fused dispatcher.
30
+ */
31
+ export function agentEvents(ctx, agent, carrier = agentCarrier(agent)) {
32
+ // The ordinary dispatch methods forward through Cordis' variadic mixins. The
33
+ // fused (carrier, name, payload, ...rest) tuple is provably a valid argument
34
+ // list for the matching thisArg overload, but TypeScript cannot relate the
35
+ // generic Tail<K> spread back to that overload's conditional parameter
36
+ // tuple — hence one contained, shape-preserving cast per method.
37
+ const fused = (payload) =>
38
+ // The dispatcher owns the subject injection; callers pass PayloadRest, so
39
+ // the fused record is exactly the declared payload. The spread comes
40
+ // first, so a structurally acceptable payload that happens to carry an
41
+ // `agent` field can never override the injected subject.
42
+ ({ ...payload, agent });
43
+ return {
44
+ emit(name, payload) {
45
+ // Cordis emit invokes callbacks through Array.map: one synchronous throw
46
+ // starves later listeners, and returned promises are discarded. Agent
47
+ // notifications are non-vetoing, so resolve the same filtered callback
48
+ // set ourselves and contain both failure modes independently.
49
+ const args = [carrier, name, fused(payload)];
50
+ const callbacks = ctx.events.dispatch('emit', args);
51
+ for (const callback of callbacks) {
52
+ try {
53
+ const returned = callback(...args);
54
+ void Promise.resolve(returned).catch((error) => {
55
+ ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`);
56
+ });
57
+ }
58
+ catch (error) {
59
+ ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`);
60
+ }
61
+ }
62
+ },
63
+ async serial(name, payload) {
64
+ // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
65
+ const serial = ctx.serial;
66
+ return await serial(carrier, name, fused(payload));
67
+ },
68
+ waterfall(name, payload, ...rest) {
69
+ // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
70
+ const waterfall = ctx.waterfall;
71
+ return waterfall(carrier, name, fused(payload), ...rest);
72
+ },
73
+ };
74
+ }
75
+ /**
76
+ * Emit one contained agent notification without allocating a retained dispatcher.
77
+ * @param ctx - the context to dispatch through.
78
+ * @param agent - the subject agent and scope key.
79
+ * @param name - the agent-subject event to emit.
80
+ * @param payload - the event's payload fields; `agent` is injected.
81
+ */
82
+ export function emitAgentEvent(ctx, agent, name, payload) {
83
+ agentEvents(ctx, agent).emit(name, payload);
84
+ }
85
+ /**
86
+ * Build the prompt assembly context with agent and scope set together, so
87
+ * agent-scoped prompt and tool contributions cannot be silently omitted.
88
+ * @param agent - the agent the assembly is for.
89
+ * @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
90
+ * @returns the context to pass to `assemble()`.
91
+ */
92
+ export function assembleContextFor(agent, signal) {
93
+ return { agent, scope: agent, ...signal === undefined ? {} : { signal } };
94
+ }
95
+ //# sourceMappingURL=dispatch.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Incremental projection of durable agent inbox events.
3
+ *
4
+ * @module @stackstackstack/dsh-agent/inbox
5
+ */
6
+ import type { MessageId } from '@stackstackstack/dsh-llm';
7
+ import type { Session, UserMessage } from '@stackstackstack/dsh-session';
8
+ import type { InboxTarget } from './types.ts';
9
+ /** Live notifications committed by inbox mutations. */
10
+ export interface InboxNotifications {
11
+ /** Publish one inserted message. */
12
+ inserted(message: UserMessage): void;
13
+ /** Publish one discarded message. */
14
+ discarded(message: UserMessage): void;
15
+ /** Publish one claimed message inside its owning turn. */
16
+ claimed(message: UserMessage, turn: number): void;
17
+ }
18
+ /** A replay-once projection that incrementally consumes later inbox splices. */
19
+ export declare class Inbox {
20
+ private readonly session;
21
+ private readonly notifications;
22
+ private readonly state;
23
+ constructor(session: Session, notifications: InboxNotifications);
24
+ /** Prompts awaiting individual turns. */
25
+ get nextTurn(): readonly UserMessage[];
26
+ /** Input awaiting the next step boundary. */
27
+ get nextStep(): readonly UserMessage[];
28
+ /** Whether either pending-message list contains work. */
29
+ get hasPending(): boolean;
30
+ /** Durably cancel all pending input, clearing next-step before next-turn. */
31
+ clear(): void;
32
+ /**
33
+ * Remove and return the complete batch proposed for one step, publishing
34
+ * each claimed message. The durable splices are pure deletions.
35
+ * @param target - whether this boundary also consumes one queued turn.
36
+ * @param turn - turn that will own the claimed batch.
37
+ * @returns next-step input followed by the queued turn, when requested.
38
+ * @internal - The agent loop's step-boundary operation, not a plugin extension point.
39
+ */
40
+ claim(target: InboxTarget, turn: number): UserMessage[];
41
+ /**
42
+ * Append one message to a pending list and durably record the insertion.
43
+ * @param target - pending list to extend.
44
+ * @param message - message to append.
45
+ * @throws if the message identity is already pending.
46
+ */
47
+ append(target: InboxTarget, message: UserMessage): void;
48
+ /**
49
+ * Prepend one message to a pending list and durably record the insertion.
50
+ * @param target - pending list to extend.
51
+ * @param message - message to prepend.
52
+ * @throws if the message identity is already pending.
53
+ */
54
+ prepend(target: InboxTarget, message: UserMessage): void;
55
+ /**
56
+ * Replace one pending message in place, possibly changing its identity. A
57
+ * successful replacement publishes the old message as discarded and the new
58
+ * message as inserted.
59
+ * @param messageId - identity of the pending message to replace.
60
+ * @param newMessage - replacement message.
61
+ * @returns whether the message was still pending.
62
+ * @throws if the replacement duplicates another pending message identity.
63
+ */
64
+ replace(messageId: MessageId, newMessage: UserMessage): boolean;
65
+ /**
66
+ * Remove one pending message and durably record its cancellation.
67
+ * @param messageId - identity of the pending message to remove.
68
+ * @returns whether the message was still pending.
69
+ */
70
+ remove(messageId: MessageId): boolean;
71
+ /**
72
+ * Apply standard splice semantics and durably record the normalized result.
73
+ * The durable event commits before the live projection mutates, so synchronous
74
+ * `session/event` observers see the pre-splice lists and can reconstruct the
75
+ * removed messages from the normalized coordinates.
76
+ * @param target - pending list to mutate.
77
+ * @param start - splice position.
78
+ * @param deleteCount - maximum number of messages to remove.
79
+ * @param inserted - messages to insert at the resolved position.
80
+ * @returns messages removed by the splice.
81
+ */
82
+ splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];
83
+ /** Locate one pending identity across both owned lists. */
84
+ private locate;
85
+ /** Commit one normalized mutation and publish its live notifications. */
86
+ private mutate;
87
+ /** Apply one normalized durable splice to the projection. */
88
+ private apply;
89
+ /** Validate one normalized splice against the current projection. */
90
+ private validate;
91
+ }
92
+ //# sourceMappingURL=inbox.d.ts.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Incremental projection of durable agent inbox events.
3
+ *
4
+ * @module @stackstackstack/dsh-agent/inbox
5
+ */
6
+ /** A replay-once projection that incrementally consumes later inbox splices. */
7
+ export class Inbox {
8
+ session;
9
+ notifications;
10
+ state = { 'next-turn': [], 'next-step': [] };
11
+ constructor(session, notifications) {
12
+ this.session = session;
13
+ this.notifications = notifications;
14
+ for (const event of session.events.slice(session.header.seedLength ?? 0)) {
15
+ if (event.type !== 'agent/inbox/spliced')
16
+ continue;
17
+ try {
18
+ this.apply(event.data);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error });
22
+ }
23
+ }
24
+ }
25
+ /** Prompts awaiting individual turns. */
26
+ get nextTurn() {
27
+ return this.state['next-turn'];
28
+ }
29
+ /** Input awaiting the next step boundary. */
30
+ get nextStep() {
31
+ return this.state['next-step'];
32
+ }
33
+ /** Whether either pending-message list contains work. */
34
+ get hasPending() {
35
+ return this.nextTurn.length > 0 || this.nextStep.length > 0;
36
+ }
37
+ /** Durably cancel all pending input, clearing next-step before next-turn. */
38
+ clear() {
39
+ this.splice('next-step', 0, this.nextStep.length, []);
40
+ this.splice('next-turn', 0, this.nextTurn.length, []);
41
+ }
42
+ /**
43
+ * Remove and return the complete batch proposed for one step, publishing
44
+ * each claimed message. The durable splices are pure deletions.
45
+ * @param target - whether this boundary also consumes one queued turn.
46
+ * @param turn - turn that will own the claimed batch.
47
+ * @returns next-step input followed by the queued turn, when requested.
48
+ * @internal - The agent loop's step-boundary operation, not a plugin extension point.
49
+ */
50
+ claim(target, turn) {
51
+ const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false);
52
+ if (target === 'next-turn') {
53
+ claimed.push(...this.mutate('next-turn', 0, 1, [], false));
54
+ }
55
+ for (const message of claimed)
56
+ this.notifications.claimed(message, turn);
57
+ return claimed;
58
+ }
59
+ /**
60
+ * Append one message to a pending list and durably record the insertion.
61
+ * @param target - pending list to extend.
62
+ * @param message - message to append.
63
+ * @throws if the message identity is already pending.
64
+ */
65
+ append(target, message) {
66
+ this.splice(target, this.state[target].length, 0, [message]);
67
+ }
68
+ /**
69
+ * Prepend one message to a pending list and durably record the insertion.
70
+ * @param target - pending list to extend.
71
+ * @param message - message to prepend.
72
+ * @throws if the message identity is already pending.
73
+ */
74
+ prepend(target, message) {
75
+ this.splice(target, 0, 0, [message]);
76
+ }
77
+ /**
78
+ * Replace one pending message in place, possibly changing its identity. A
79
+ * successful replacement publishes the old message as discarded and the new
80
+ * message as inserted.
81
+ * @param messageId - identity of the pending message to replace.
82
+ * @param newMessage - replacement message.
83
+ * @returns whether the message was still pending.
84
+ * @throws if the replacement duplicates another pending message identity.
85
+ */
86
+ replace(messageId, newMessage) {
87
+ const location = this.locate(messageId);
88
+ if (location === undefined)
89
+ return false;
90
+ this.splice(location.target, location.index, 1, [newMessage]);
91
+ return true;
92
+ }
93
+ /**
94
+ * Remove one pending message and durably record its cancellation.
95
+ * @param messageId - identity of the pending message to remove.
96
+ * @returns whether the message was still pending.
97
+ */
98
+ remove(messageId) {
99
+ const location = this.locate(messageId);
100
+ if (location === undefined)
101
+ return false;
102
+ this.splice(location.target, location.index, 1, []);
103
+ return true;
104
+ }
105
+ /**
106
+ * Apply standard splice semantics and durably record the normalized result.
107
+ * The durable event commits before the live projection mutates, so synchronous
108
+ * `session/event` observers see the pre-splice lists and can reconstruct the
109
+ * removed messages from the normalized coordinates.
110
+ * @param target - pending list to mutate.
111
+ * @param start - splice position.
112
+ * @param deleteCount - maximum number of messages to remove.
113
+ * @param inserted - messages to insert at the resolved position.
114
+ * @returns messages removed by the splice.
115
+ */
116
+ splice(target, start, deleteCount, inserted) {
117
+ return this.mutate(target, start, deleteCount, inserted, true);
118
+ }
119
+ /** Locate one pending identity across both owned lists. */
120
+ locate(messageId) {
121
+ for (const target of ['next-turn', 'next-step']) {
122
+ const index = this.state[target].findIndex(message => message.id === messageId);
123
+ if (index >= 0)
124
+ return { target, index };
125
+ }
126
+ return undefined;
127
+ }
128
+ /** Commit one normalized mutation and publish its live notifications. */
129
+ mutate(target, start, deleteCount, inserted, discardRemoved) {
130
+ const inbox = this.state[target];
131
+ const truncatedStart = Math.trunc(start);
132
+ const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart;
133
+ const actualStart = offset < 0
134
+ ? Math.max(inbox.length + offset, 0)
135
+ : Math.min(offset, inbox.length);
136
+ const truncatedDeleteCount = Math.trunc(deleteCount);
137
+ const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart);
138
+ if (actualDeleteCount === 0 && inserted.length === 0)
139
+ return [];
140
+ const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined;
141
+ const splice = {
142
+ target,
143
+ start: actualStart,
144
+ ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
145
+ inserted,
146
+ ...(outcome === undefined ? {} : { outcome }),
147
+ };
148
+ this.validate(splice);
149
+ const event = this.session.append('agent/inbox/spliced', splice);
150
+ const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted);
151
+ if (discardRemoved) {
152
+ for (const message of removed)
153
+ this.notifications.discarded(message);
154
+ }
155
+ for (const message of event.data.inserted)
156
+ this.notifications.inserted(message);
157
+ return removed;
158
+ }
159
+ /** Apply one normalized durable splice to the projection. */
160
+ apply(splice) {
161
+ this.validate(splice);
162
+ const inbox = this.state[splice.target];
163
+ return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted);
164
+ }
165
+ /** Validate one normalized splice against the current projection. */
166
+ validate(splice) {
167
+ const inbox = this.state[splice.target];
168
+ const removedCount = splice.removedCount ?? 0;
169
+ if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
170
+ || !Number.isSafeInteger(removedCount) || removedCount < 0
171
+ || splice.start + removedCount > inbox.length) {
172
+ throw new Error('invalid inbox splice');
173
+ }
174
+ const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted);
175
+ const ids = new Set();
176
+ for (const message of splice.target === 'next-turn'
177
+ ? [...candidate, ...this.nextStep]
178
+ : [...this.nextTurn, ...candidate]) {
179
+ if (ids.has(message.id))
180
+ throw new Error(`message "${message.id}" is already pending`);
181
+ ids.add(message.id);
182
+ }
183
+ }
184
+ }
185
+ //# sourceMappingURL=inbox.js.map