@corenel/harness 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/dist/chat/engine.d.ts +47 -0
  2. package/dist/chat/engine.d.ts.map +1 -0
  3. package/dist/chat/engine.js +803 -0
  4. package/dist/chat/engine.js.map +1 -0
  5. package/dist/chat/events.d.ts +5 -0
  6. package/dist/chat/events.d.ts.map +1 -0
  7. package/dist/chat/events.js +100 -0
  8. package/dist/chat/events.js.map +1 -0
  9. package/dist/chat/index.d.ts +9 -0
  10. package/dist/chat/index.d.ts.map +1 -0
  11. package/dist/chat/index.js +9 -0
  12. package/dist/chat/index.js.map +1 -0
  13. package/dist/chat/liveFold.d.ts +10 -0
  14. package/dist/chat/liveFold.d.ts.map +1 -0
  15. package/dist/chat/liveFold.js +31 -0
  16. package/dist/chat/liveFold.js.map +1 -0
  17. package/dist/chat/messages.d.ts +20 -0
  18. package/dist/chat/messages.d.ts.map +1 -0
  19. package/dist/chat/messages.js +56 -0
  20. package/dist/chat/messages.js.map +1 -0
  21. package/dist/chat/persist.d.ts +39 -0
  22. package/dist/chat/persist.d.ts.map +1 -0
  23. package/dist/chat/persist.js +107 -0
  24. package/dist/chat/persist.js.map +1 -0
  25. package/dist/chat/runWatchdog.d.ts +21 -0
  26. package/dist/chat/runWatchdog.d.ts.map +1 -0
  27. package/dist/chat/runWatchdog.js +92 -0
  28. package/dist/chat/runWatchdog.js.map +1 -0
  29. package/dist/chat/stitch.d.ts +26 -0
  30. package/dist/chat/stitch.d.ts.map +1 -0
  31. package/dist/chat/stitch.js +40 -0
  32. package/dist/chat/stitch.js.map +1 -0
  33. package/dist/chat/types.d.ts +163 -0
  34. package/dist/chat/types.d.ts.map +1 -0
  35. package/dist/chat/types.js +2 -0
  36. package/dist/chat/types.js.map +1 -0
  37. package/dist/core/loop.d.ts.map +1 -1
  38. package/dist/core/loop.js +50 -15
  39. package/dist/core/loop.js.map +1 -1
  40. package/dist/core/spawn.d.ts +17 -0
  41. package/dist/core/spawn.d.ts.map +1 -1
  42. package/dist/core/spawn.js +48 -14
  43. package/dist/core/spawn.js.map +1 -1
  44. package/dist/fileSchema.js +1 -1
  45. package/dist/guardrail/builtins.d.ts.map +1 -1
  46. package/dist/guardrail/builtins.js +11 -6
  47. package/dist/guardrail/builtins.js.map +1 -1
  48. package/dist/guardrail/glob.d.ts +1 -2
  49. package/dist/guardrail/glob.d.ts.map +1 -1
  50. package/dist/guardrail/glob.js +4 -29
  51. package/dist/guardrail/glob.js.map +1 -1
  52. package/dist/guardrail/guardrail.d.ts +8 -0
  53. package/dist/guardrail/guardrail.d.ts.map +1 -1
  54. package/dist/guardrail/guardrail.js +12 -0
  55. package/dist/guardrail/guardrail.js.map +1 -1
  56. package/dist/guardrail/index.d.ts +1 -1
  57. package/dist/guardrail/index.d.ts.map +1 -1
  58. package/dist/guardrail/index.js +1 -1
  59. package/dist/guardrail/index.js.map +1 -1
  60. package/dist/guardrail/serialize.d.ts.map +1 -1
  61. package/dist/guardrail/serialize.js +3 -0
  62. package/dist/guardrail/serialize.js.map +1 -1
  63. package/dist/guardrail/validate.d.ts.map +1 -1
  64. package/dist/guardrail/validate.js +4 -0
  65. package/dist/guardrail/validate.js.map +1 -1
  66. package/dist/install/lockfile.d.ts +25 -5
  67. package/dist/install/lockfile.d.ts.map +1 -1
  68. package/dist/install/lockfile.js +60 -4
  69. package/dist/install/lockfile.js.map +1 -1
  70. package/dist/install/nameEntries.d.ts +28 -0
  71. package/dist/install/nameEntries.d.ts.map +1 -0
  72. package/dist/install/nameEntries.js +198 -0
  73. package/dist/install/nameEntries.js.map +1 -0
  74. package/dist/install/pinRefusal.d.ts +4 -0
  75. package/dist/install/pinRefusal.d.ts.map +1 -0
  76. package/dist/install/pinRefusal.js +19 -0
  77. package/dist/install/pinRefusal.js.map +1 -0
  78. package/dist/install/requirements.d.ts +7 -0
  79. package/dist/install/requirements.d.ts.map +1 -0
  80. package/dist/install/requirements.js +23 -0
  81. package/dist/install/requirements.js.map +1 -0
  82. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  83. package/dist/orchestrator/sidecarTools.js +8 -4
  84. package/dist/orchestrator/sidecarTools.js.map +1 -1
  85. package/dist/prompts/fitProject.d.ts +6 -0
  86. package/dist/prompts/fitProject.d.ts.map +1 -0
  87. package/dist/prompts/fitProject.js +6 -0
  88. package/dist/prompts/fitProject.js.map +1 -0
  89. package/dist/prompts/library/defaults.generated.js +7 -7
  90. package/dist/prompts/library/defaults.generated.js.map +1 -1
  91. package/dist/prompts/library/index.d.ts +2 -1
  92. package/dist/prompts/library/index.d.ts.map +1 -1
  93. package/dist/prompts/library/index.js +11 -16
  94. package/dist/prompts/library/index.js.map +1 -1
  95. package/dist/providers/egressGuard.d.ts +17 -0
  96. package/dist/providers/egressGuard.d.ts.map +1 -0
  97. package/dist/providers/egressGuard.js +26 -0
  98. package/dist/providers/egressGuard.js.map +1 -0
  99. package/dist/providers/gateway.d.ts +34 -0
  100. package/dist/providers/gateway.d.ts.map +1 -1
  101. package/dist/providers/gateway.js +43 -2
  102. package/dist/providers/gateway.js.map +1 -1
  103. package/dist/sidecar/askUserBroker.d.ts +40 -0
  104. package/dist/sidecar/askUserBroker.d.ts.map +1 -0
  105. package/dist/sidecar/askUserBroker.js +103 -0
  106. package/dist/sidecar/askUserBroker.js.map +1 -0
  107. package/dist/sidecar/fleetJwe.d.ts +25 -0
  108. package/dist/sidecar/fleetJwe.d.ts.map +1 -0
  109. package/dist/sidecar/fleetJwe.js +115 -0
  110. package/dist/sidecar/fleetJwe.js.map +1 -0
  111. package/dist/sidecar/fleetJws.d.ts +15 -0
  112. package/dist/sidecar/fleetJws.d.ts.map +1 -0
  113. package/dist/sidecar/fleetJws.js +47 -0
  114. package/dist/sidecar/fleetJws.js.map +1 -0
  115. package/dist/sidecar/host.d.ts +81 -1
  116. package/dist/sidecar/host.d.ts.map +1 -1
  117. package/dist/sidecar/host.js +227 -6
  118. package/dist/sidecar/host.js.map +1 -1
  119. package/dist/sidecar/identity.d.ts +9 -0
  120. package/dist/sidecar/identity.d.ts.map +1 -1
  121. package/dist/sidecar/memberGate.d.ts.map +1 -1
  122. package/dist/sidecar/memberGate.js +5 -0
  123. package/dist/sidecar/memberGate.js.map +1 -1
  124. package/dist/sidecar/server.d.ts +61 -10
  125. package/dist/sidecar/server.d.ts.map +1 -1
  126. package/dist/sidecar/server.js +592 -15
  127. package/dist/sidecar/server.js.map +1 -1
  128. package/dist/skills.d.ts +18 -0
  129. package/dist/skills.d.ts.map +1 -1
  130. package/dist/skills.js +93 -12
  131. package/dist/skills.js.map +1 -1
  132. package/dist/systemParts.d.ts +0 -1
  133. package/dist/systemParts.d.ts.map +1 -1
  134. package/dist/systemParts.js +6 -2
  135. package/dist/systemParts.js.map +1 -1
  136. package/dist/todos.d.ts +8 -0
  137. package/dist/todos.d.ts.map +1 -1
  138. package/dist/todos.js +21 -17
  139. package/dist/todos.js.map +1 -1
  140. package/dist/tools/builtins.d.ts +8 -0
  141. package/dist/tools/builtins.d.ts.map +1 -1
  142. package/dist/tools/builtins.js +21 -1
  143. package/dist/tools/builtins.js.map +1 -1
  144. package/dist/tools/namespaces.d.ts +8 -0
  145. package/dist/tools/namespaces.d.ts.map +1 -1
  146. package/dist/tools/namespaces.js +18 -0
  147. package/dist/tools/namespaces.js.map +1 -1
  148. package/dist/tools/skillTool.d.ts.map +1 -1
  149. package/dist/tools/skillTool.js +22 -1
  150. package/dist/tools/skillTool.js.map +1 -1
  151. package/dist/tools/toolFacts.d.ts +9 -0
  152. package/dist/tools/toolFacts.d.ts.map +1 -0
  153. package/dist/tools/toolFacts.js +19 -0
  154. package/dist/tools/toolFacts.js.map +1 -0
  155. package/dist/tools/toolSearch.d.ts +25 -6
  156. package/dist/tools/toolSearch.d.ts.map +1 -1
  157. package/dist/tools/toolSearch.js +62 -14
  158. package/dist/tools/toolSearch.js.map +1 -1
  159. package/dist/tools/workflowTool.d.ts +34 -0
  160. package/dist/tools/workflowTool.d.ts.map +1 -0
  161. package/dist/tools/workflowTool.js +122 -0
  162. package/dist/tools/workflowTool.js.map +1 -0
  163. package/package.json +8 -4
@@ -0,0 +1,803 @@
1
+ /* The conversation engine: one instance holds the state of every conversation
2
+ * it has touched and runs their turns through a host. It never runs the agent
3
+ * loop itself (that is the host's runLoop) and never reaches a store or a
4
+ * screen except through the host's methods. */
5
+ import { HOOK_ASK_TOOL, isCheckpointTool } from '@corenel/protocol';
6
+ import { isFoldedTurn, turnsFromSessionMessages } from '../conversation.js';
7
+ import { CHECKPOINT_MARKER } from '../core/compactHistory.js';
8
+ import { dispatchChatEvent } from './events.js';
9
+ import { foldLive } from './liveFold.js';
10
+ import { buildUserMessage, outgoingMessages, userImages, userText } from './messages.js';
11
+ import { ConversationWriter, flagIfUnsaved } from './persist.js';
12
+ import { createRunWatchdog, WATCHDOG_SOFT_MS } from './runWatchdog.js';
13
+ import { stitchTurn } from './stitch.js';
14
+ const SILENT_MSG = 'No response from the gateway in 45s — aborted. Check that you are signed in and the backend is reachable.';
15
+ const TOOL_CAP_MSG = 'A tool ran for over 10 minutes without completing — aborted. The connection to it may have dropped.';
16
+ function emptyState() {
17
+ return {
18
+ turns: [], live: null, running: false, error: '', lastStopReason: null, injectedIds: [], runId: null,
19
+ loaded: false, loadMissed: false, persistedLen: null, saveBlocked: null, mergeTick: 0,
20
+ pendingCommand: null,
21
+ };
22
+ }
23
+ /** A session allow is keyed by WHO asked and the tool, so allowing a callee's
24
+ * write never carries to another callee, or to the caller. */
25
+ /** Asked every time, never a session allow: a checkpoint, and a System One
26
+ * check or hook ask (its answer is for one tool call only). */
27
+ function askedEveryTime(tool) {
28
+ return isCheckpointTool(tool) || tool === HOOK_ASK_TOOL;
29
+ }
30
+ function allowKey(agent, tool) {
31
+ return JSON.stringify([agent ?? '', tool]);
32
+ }
33
+ const messageOf = (e) => String((e instanceof Error && e.message) || e);
34
+ /** host.turnComplete, contained: a host whose handler throws must not skip the
35
+ * turn's write drain or reject hostTurn. */
36
+ function complete(host, r) {
37
+ try {
38
+ host.turnComplete(r);
39
+ }
40
+ catch { /* the host's own handler failed; the turn still settles */ }
41
+ }
42
+ const noteId = (prefix) => `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
43
+ export function createChat() {
44
+ const slots = new Map();
45
+ /* The write queue is one per engine, so writes to a conversation stay in
46
+ * order whichever call queued them; each write goes to the host last used
47
+ * for that conversation. */
48
+ const stores = new Map();
49
+ const storeFor = (id) => {
50
+ const s = stores.get(id);
51
+ if (!s)
52
+ throw new Error(`no host for conversation ${id}`);
53
+ return s;
54
+ };
55
+ const writer = new ConversationWriter({
56
+ loadConversation: (id) => storeFor(id).loadConversation(id),
57
+ writeConversation: (c, opts) => storeFor(c.id).writeConversation(c, opts),
58
+ });
59
+ const slotFor = (id) => {
60
+ let s = slots.get(id);
61
+ if (!s) {
62
+ s = { state: emptyState(), abort: null, allowed: new Set(), saves: 0, system: null, injecting: Promise.resolve() };
63
+ slots.set(id, s);
64
+ }
65
+ return s;
66
+ };
67
+ /* Forgotten until a load or a turn opens it again: a late write (a command
68
+ * settling after its chat was closed) must not bring it back. */
69
+ const forgotten = new Set();
70
+ // Conversations whose system was reset and not asked again since (R4).
71
+ const systemResets = new Set();
72
+ const reopen = (id) => { forgotten.delete(id); };
73
+ const editable = (id) => (forgotten.has(id) ? null : slotFor(id));
74
+ /** Queues a save of the conversation as it stands when the write runs. */
75
+ function save(id, host) {
76
+ stores.set(id, host);
77
+ return writer.queue(id, async () => {
78
+ const slot = slots.get(id);
79
+ if (!slot)
80
+ return;
81
+ slot.saves += 1;
82
+ const s = slot.state;
83
+ const before = s.saveBlocked;
84
+ const { merged } = await writer.persist(id, s, null, slot.system);
85
+ if (merged || s.saveBlocked !== before)
86
+ host.stateChanged(id, false);
87
+ }).catch(() => { });
88
+ }
89
+ /** A transcript-only turn (never sent), saved through the queue. A no-op
90
+ * once the conversation is forgotten: a late event must not bring it back. */
91
+ function noteTurn(id, host, turn) {
92
+ const s = editable(id)?.state;
93
+ if (!s)
94
+ return turn.id;
95
+ s.turns = [...s.turns, turn];
96
+ host.stateChanged(id, false);
97
+ void save(id, host);
98
+ return turn.id;
99
+ }
100
+ function note(id, host, text, extra) {
101
+ return noteTurn(id, host, { id: noteId('n'), included: false, messages: [{ role: 'assistant', content: text }], ...extra });
102
+ }
103
+ /* The compaction seam: permanent in the transcript, unsent, so it never
104
+ * describes the compaction back to the model on a later turn. */
105
+ function noteCheckpoint(id, host, collapsed, body) {
106
+ const n = collapsed === 1 ? '1 message' : `${collapsed} messages`;
107
+ return noteTurn(id, host, {
108
+ id: noteId('n'), included: false,
109
+ messages: [{ role: 'assistant', content: `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here` }],
110
+ ...(body ? { checkpoint: body } : {}),
111
+ });
112
+ }
113
+ /* An injected message shown as the user's turn; the run's own copy is what
114
+ * gets sent, so this one is not included. */
115
+ function noteUser(id, host, text) {
116
+ return noteTurn(id, host, { id: noteId('u'), included: false, messages: [{ role: 'user', content: text }] });
117
+ }
118
+ /* R4 reset. The mark lives on the engine, not the slot, so it survives a
119
+ * forget: until the next turn asks host.system, a load ignores the stored
120
+ * system. When a store for the id is known, the stored field is removed now
121
+ * through the write queue, so a restart before the next save cannot read it
122
+ * back either. The removal is skipped once a newer ask has frozen a system,
123
+ * and entirely for a host that stores none (`stored: false`). */
124
+ function resetSystem(id, opts) {
125
+ systemResets.add(id);
126
+ const slot = slots.get(id);
127
+ if (slot)
128
+ slot.system = null;
129
+ if (opts?.stored === false)
130
+ return;
131
+ const store = stores.get(id);
132
+ if (!store)
133
+ return;
134
+ void writer.queue(id, async () => {
135
+ if (!systemResets.has(id))
136
+ return;
137
+ const c = await store.loadConversation(id);
138
+ if (!c || c.system === undefined)
139
+ return;
140
+ const { system: _dropped, ...rest } = c;
141
+ await store.writeConversation(rest, { allowTruncate: false });
142
+ }).catch(() => { });
143
+ }
144
+ /** Titles an untitled conversation from its first message; a hand-renamed one is locked. */
145
+ function queueTitle(id, host, text) {
146
+ return writer.queue(id, async () => {
147
+ const c = await host.loadConversation(id);
148
+ if (c && !c.titleLocked) {
149
+ await host.writeConversation({ ...c, title: text.slice(0, 48) }, { allowTruncate: false }).catch(() => { });
150
+ host.stateChanged(id, true);
151
+ }
152
+ }).catch(() => { });
153
+ }
154
+ async function load(id, host) {
155
+ if (!id)
156
+ return;
157
+ reopen(id);
158
+ stores.set(id, host);
159
+ const s = slotFor(id).state;
160
+ if (s.loaded)
161
+ return;
162
+ /* A read that throws is a miss, not an error: the turn still runs,
163
+ * saveBlocked says it is not kept, and retryLoad reads again. */
164
+ let stored;
165
+ try {
166
+ stored = await host.loadConversation(id);
167
+ }
168
+ catch {
169
+ stored = null;
170
+ }
171
+ // A concurrent load landed first and its merge already holds everything.
172
+ if (s.loaded)
173
+ return;
174
+ // Merge, never replace: what this side added before the load comes after it.
175
+ const pre = s.turns;
176
+ if (stored) {
177
+ // A system frozen by an earlier turn is used as stored, without asking the host (R4).
178
+ const slot = slotFor(id);
179
+ // A reset not yet followed by a fresh ask means the stored one is stale.
180
+ if (slot.system === null && !systemResets.has(id) && typeof stored.system === 'string')
181
+ slot.system = stored.system;
182
+ s.turns = [...turnsFromSessionMessages(stored.messages), ...pre];
183
+ s.persistedLen = stored.messages.length;
184
+ // Only on a hit: a miss stays unloaded so the next ask reads again.
185
+ s.loaded = true;
186
+ }
187
+ s.loadMissed = !stored;
188
+ if (stored && !pre.length)
189
+ s.saveBlocked = null;
190
+ host.stateChanged(id, false);
191
+ if (stored && pre.length)
192
+ void save(id, host);
193
+ }
194
+ async function retryLoad(id, host) {
195
+ await load(id, host);
196
+ const s = slots.get(id)?.state;
197
+ if (!s?.loaded)
198
+ return false;
199
+ await save(id, host);
200
+ // Global: the conversation list may have dropped this one while it missed.
201
+ host.stateChanged(id, true);
202
+ return s.saveBlocked === null;
203
+ }
204
+ function inject(id, host, messages, hard) {
205
+ const s = slots.get(id)?.state;
206
+ if (!s || !s.running || s.runId == null)
207
+ return false;
208
+ return guardedInject(id, s.runId, host, messages, hard, undefined);
209
+ }
210
+ /* Accepted now, delivered once the guard allows (R1). Guards run one after
211
+ * another per conversation, so the order the messages were sent holds. A
212
+ * refusal, or a guard that throws, delivers nothing and shows the reason;
213
+ * the host reverts a queued message that onInjected never reports. */
214
+ function guardedInject(id, runId, host, messages, hard, agent) {
215
+ const slot = slotFor(id);
216
+ const deliver = async () => {
217
+ let verdict;
218
+ try {
219
+ verdict = await host.guard({ conversationId: id, ...(agent ? { agent } : {}), kind: 'inject' });
220
+ }
221
+ catch (e) {
222
+ verdict = { allowed: false, guardId: 'guard-error', reason: messageOf(e) };
223
+ }
224
+ const live = slots.get(id) === slot;
225
+ if (verdict.allowed) {
226
+ // The run it was accepted for has ended while the guard waited: dropped, never delivered elsewhere.
227
+ if (live && slot.state.running && slot.state.runId === runId)
228
+ host.injectInto(runId, messages, hard);
229
+ return;
230
+ }
231
+ if (!live)
232
+ return;
233
+ slot.state.error = verdict.reason;
234
+ host.stateChanged(id, false);
235
+ };
236
+ slot.injecting = slot.injecting.then(deliver).catch(() => { });
237
+ return true;
238
+ }
239
+ function run(request, host) {
240
+ return start(request, host, { kind: 'send' });
241
+ }
242
+ function start(request, host, mode) {
243
+ const ac = new AbortController();
244
+ let stopRequested;
245
+ let current = request.conversationId;
246
+ // The run id this turn's own onStart reported: ChatRun.inject addresses only it (R2).
247
+ let ownRunId = null;
248
+ // True once runLoop was called (ChatTurnResult.ran).
249
+ let ran = false;
250
+ // turnComplete is called exactly once per turn, whichever way it ends (R3).
251
+ let completed = false;
252
+ const finish = (r) => {
253
+ if (!completed) {
254
+ completed = true;
255
+ complete(host, r);
256
+ }
257
+ return r;
258
+ };
259
+ const kind = mode.kind;
260
+ let resolveId;
261
+ const conversationId = new Promise((r) => { resolveId = r; });
262
+ async function turn() {
263
+ const text = request.input.text;
264
+ const images = request.input.images ?? [];
265
+ const agentCtx = request.agent ? { agent: request.agent } : {};
266
+ // Refused before anything changed: empty input, or a run already going.
267
+ const refused = (cid) => {
268
+ mode.rollback?.();
269
+ return finish({ conversationId: cid, userTurnId: '', outcome: 'refused', ran: false, kind });
270
+ };
271
+ /* Null when the turn may run. A guard that throws is a failed turn, rolled
272
+ * back exactly like a refusal. */
273
+ const askGuard = (cid) => host.guard({ conversationId: cid, ...agentCtx, kind: mode.kind }).then((v) => (v.allowed ? null : { outcome: 'refused', error: v.reason }), (e) => ({ outcome: 'failed', error: messageOf(e) }));
274
+ let id = request.conversationId;
275
+ if (!text && !images.length)
276
+ return refused(id);
277
+ let created = false;
278
+ if (id == null) {
279
+ /* A new conversation is guarded BEFORE it exists, so a refused first
280
+ * turn creates and writes nothing and its id resolves to null. */
281
+ const denied = await askGuard(null);
282
+ if (denied)
283
+ return finish({ conversationId: null, userTurnId: '', ...denied, ran: false, kind });
284
+ try {
285
+ id = await host.createConversation(text.slice(0, 48));
286
+ }
287
+ catch (e) {
288
+ return finish({ conversationId: null, userTurnId: '', outcome: 'failed', error: messageOf(e), ran: false, kind });
289
+ }
290
+ created = true;
291
+ current = id;
292
+ stores.set(id, host);
293
+ const fresh = slotFor(id).state;
294
+ fresh.loaded = true;
295
+ fresh.persistedLen = 0;
296
+ }
297
+ else {
298
+ reopen(id);
299
+ /* Busy is refused BEFORE the store is rebound: the running turn's later
300
+ * saves must keep going through its own host, not the refused one's. */
301
+ if (slotFor(id).state.running)
302
+ return refused(id);
303
+ stores.set(id, host);
304
+ /* The stored chat first (R35): a turn built on the empty pre-load list
305
+ * would run with no history and be wiped by the load or never saved. */
306
+ if (!slotFor(id).state.loaded) {
307
+ await load(id, host);
308
+ // Another run started while this one waited (R37): nothing happens.
309
+ if (slotFor(id).state.running)
310
+ return refused(id);
311
+ }
312
+ }
313
+ const cid = id;
314
+ const slot = slotFor(cid);
315
+ const s = slot.state;
316
+ // Only a LOADED empty chat is new; one whose load missed is empty because it was never read.
317
+ const isFirst = !created && s.loaded && s.turns.length === 0;
318
+ // A random tail: two turns in one millisecond must not share an id, or a refusal removes both.
319
+ const userTurn = { id: noteId('t'), included: true, messages: [buildUserMessage(text, images)] };
320
+ const nextTurns = [...s.turns, userTurn];
321
+ // Forgotten mid-turn: the turn's late writes must not land on a slot that is gone.
322
+ const attached = () => slots.get(cid) === slot;
323
+ /* Transcript-only turns for messages injected mid-run: removed once the
324
+ * run returns its own copy, promoted if it throws instead. */
325
+ const injectedTurnIds = [];
326
+ // The seams THIS run wrote; the last one takes the run's folded view.
327
+ const seamIds = [];
328
+ const toolMs = {};
329
+ const toolChecks = {};
330
+ const toolHooks = {};
331
+ let verification;
332
+ let auditIncomplete;
333
+ let timedOut = false;
334
+ // Armed once the guard has allowed the turn; a holder, since closures set it.
335
+ const watch = { dog: null };
336
+ // Something waits on a person: the run is waiting, not wedged.
337
+ const paused = async (ask) => {
338
+ watch.dog?.pause();
339
+ try {
340
+ return await ask();
341
+ }
342
+ finally {
343
+ watch.dog?.bump();
344
+ }
345
+ };
346
+ const onEvent = (e) => {
347
+ watch.dog?.onEvent(e);
348
+ if (attached()) {
349
+ let changed = false;
350
+ switch (e.type) {
351
+ case 'tool-result':
352
+ if (typeof e.durationMs === 'number')
353
+ toolMs[e.id] = e.durationMs;
354
+ break;
355
+ case 'tool-check':
356
+ if (e.check.ok)
357
+ toolChecks[e.id] = { score: e.check.score, cut: e.check.cut, ...(e.check.outcome ? { outcome: e.check.outcome } : {}) };
358
+ // Not scored, but it asked: what the ask led to is kept, with no score.
359
+ else if (e.check.outcome)
360
+ toolChecks[e.id] = { failed: true, outcome: e.check.outcome };
361
+ break;
362
+ // The marks only: a blocked result (blockedText) is the host's to keep in memory, never the transcript's.
363
+ case 'tool-hooks':
364
+ toolHooks[e.id] = {
365
+ marks: e.mark.marks.map((m) => ({ name: m.name, action: m.action, outcome: m.outcome })),
366
+ ...(e.mark.off ? { off: true } : {}),
367
+ ...(e.blockedText !== undefined ? { withheld: true } : {}),
368
+ };
369
+ break;
370
+ case 'checkpoint':
371
+ seamIds.push(noteCheckpoint(cid, host, e.collapsed, e.checkpoint));
372
+ break;
373
+ case 'injected':
374
+ injectedTurnIds.push(noteUser(cid, host, e.content));
375
+ break;
376
+ case 'turn-verified':
377
+ verification = e.verification;
378
+ break;
379
+ case 'audit-incomplete':
380
+ auditIncomplete = { lost: e.lost, ...(e.unconfirmed ? { unconfirmed: true } : {}) };
381
+ break;
382
+ case 'error':
383
+ s.error = e.message;
384
+ changed = true;
385
+ break;
386
+ default: break;
387
+ }
388
+ if (s.live) {
389
+ const next = foldLive(s.live, e);
390
+ if (next !== s.live) {
391
+ s.live = next;
392
+ changed = true;
393
+ }
394
+ }
395
+ if (changed)
396
+ host.stateChanged(cid, false);
397
+ }
398
+ dispatchChatEvent(e, host, { conversationId: cid });
399
+ };
400
+ /* Everything from the optimistic patch on runs inside the try below, so
401
+ * the finally clears `running` whatever throws. */
402
+ const body = async () => {
403
+ /* OPTIMISTIC: the turn shows before the guard answers (the guard can be
404
+ * a network call). A refusal puts it back; the finally clears the rest. */
405
+ const seen = slot.saves;
406
+ Object.assign(s, { turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, injectedIds: [], lastStopReason: null });
407
+ // Still not loaded after that retry: this exchange runs but is not kept; say so now.
408
+ flagIfUnsaved(s);
409
+ host.stateChanged(cid, true);
410
+ if (!created) {
411
+ const denied = await askGuard(cid);
412
+ if (denied) {
413
+ s.turns = s.turns.filter((t) => t.id !== userTurn.id);
414
+ if (mode.rollback)
415
+ mode.rollback();
416
+ // A save while the guard waited wrote the turn just removed.
417
+ else if (slot.saves !== seen && attached())
418
+ void save(cid, host);
419
+ s.error = denied.error;
420
+ return { conversationId: cid, userTurnId: userTurn.id, ...denied, ran: false, kind };
421
+ }
422
+ }
423
+ resolveId(cid);
424
+ /* A stop pressed before the loop starts ends the turn here: a loop handed
425
+ * an already-aborted signal would never see the abort. The user turn
426
+ * stays, as for a stopped run with no reply. */
427
+ const stoppedEarly = () => {
428
+ if (!ac.signal.aborted)
429
+ return null;
430
+ if (attached())
431
+ void save(cid, host);
432
+ return {
433
+ conversationId: cid, userTurnId: userTurn.id, outcome: timedOut ? 'failed' : 'stopped',
434
+ ...(stopRequested ? { stopReason: stopRequested } : {}), ...(s.error ? { error: s.error } : {}),
435
+ ran: false, kind,
436
+ };
437
+ };
438
+ const early = stoppedEarly();
439
+ if (early)
440
+ return early;
441
+ if (isFirst && text)
442
+ await queueTitle(cid, host, text);
443
+ const afterTitle = stoppedEarly();
444
+ if (afterTitle)
445
+ return afterTitle;
446
+ const dog = createRunWatchdog((reason) => {
447
+ timedOut = true;
448
+ ac.abort();
449
+ if (!attached())
450
+ return;
451
+ s.error = reason === 'tool-hard-cap' ? TOOL_CAP_MSG : SILENT_MSG;
452
+ host.stateChanged(cid, false);
453
+ }, { softMs: WATCHDOG_SOFT_MS });
454
+ watch.dog = dog;
455
+ dog.bump();
456
+ const ctx = { conversationId: cid, ...agentCtx };
457
+ // Frozen after the first turn (R4): asked only while this conversation holds none.
458
+ const held = slot.system;
459
+ const [system, opts] = await Promise.all([held ?? host.system(ctx), host.runOptions(ctx)]);
460
+ if (held === null && attached() && slot.system === null) {
461
+ slot.system = system;
462
+ systemResets.delete(cid);
463
+ }
464
+ const beforeLoop = stoppedEarly();
465
+ if (beforeLoop)
466
+ return beforeLoop;
467
+ const control = {
468
+ conversationId: cid,
469
+ system,
470
+ signal: ac.signal,
471
+ onEvent,
472
+ // The run's id the instant it exists, so inject() has something live to address.
473
+ onStart: (runId) => { ownRunId = runId; if (attached())
474
+ s.runId = runId; },
475
+ onInjected: (ids) => {
476
+ if (!attached())
477
+ return;
478
+ s.injectedIds = [...s.injectedIds, ...ids];
479
+ host.stateChanged(cid, false);
480
+ },
481
+ askPermission: async (req) => {
482
+ // A checkpoint or a System One ask is asked every time, whatever the session allowed.
483
+ const key = allowKey(req.agent, req.tool);
484
+ if (!askedEveryTime(req.tool) && slot.allowed.has(key))
485
+ return { allow: true, by: 'session' };
486
+ const answer = await paused(() => host.askPermission(cid, req));
487
+ if (answer.allow && answer.always && !askedEveryTime(req.tool))
488
+ slot.allowed.add(key);
489
+ return answer;
490
+ },
491
+ askUser: (questions) => paused(() => host.askUser(cid, questions, ac.signal)),
492
+ reviewEdit: (callId) => paused(() => host.reviewEdit(cid, callId, ac.signal)),
493
+ paused,
494
+ };
495
+ ran = true;
496
+ const res = await host.runLoop(outgoingMessages(nextTurns), opts, control);
497
+ if (attached()) {
498
+ s.turns = stitchTurn({
499
+ turns: s.turns, userTurnId: userTurn.id, result: { ...res, toolMs: { ...toolMs, ...res.toolMs } },
500
+ toolChecks, toolHooks, injectedTurnIds, seamIds, verification, auditIncomplete,
501
+ });
502
+ s.lastStopReason = res.stopReason;
503
+ host.stateChanged(cid, false);
504
+ void save(cid, host);
505
+ }
506
+ const outcome = timedOut ? 'failed' : ac.signal.aborted ? 'stopped' : 'done';
507
+ if (outcome === 'done')
508
+ host.suggestFollowups({ conversationId: cid, messages: res.messages, signal: ac.signal });
509
+ return {
510
+ conversationId: cid, userTurnId: userTurn.id, outcome, stopReason: res.stopReason,
511
+ ...(s.error ? { error: s.error } : {}), ...(res.stats ? { stats: res.stats } : {}),
512
+ ran: true, kind,
513
+ };
514
+ };
515
+ slot.abort = ac;
516
+ let result;
517
+ try {
518
+ result = await body();
519
+ }
520
+ catch (e) {
521
+ /* A throw never returns the run's messages, so its copy of every
522
+ * injected message dies with it. Promote the transcript-only turns to
523
+ * real history, or the correction is silently gone from the next send. */
524
+ const error = messageOf(e);
525
+ if (attached()) {
526
+ if (injectedTurnIds.length)
527
+ s.turns = s.turns.map((t) => (injectedTurnIds.includes(t.id) ? { ...t, included: true } : t));
528
+ s.error = error;
529
+ void save(cid, host);
530
+ }
531
+ result = {
532
+ conversationId: cid, userTurnId: userTurn.id, outcome: timedOut || !ac.signal.aborted ? 'failed' : 'stopped',
533
+ error, ...(stopRequested ? { stopReason: stopRequested } : {}),
534
+ ran, kind,
535
+ };
536
+ }
537
+ finally {
538
+ watch.dog?.clear();
539
+ slot.abort = null;
540
+ s.runId = null;
541
+ s.running = false;
542
+ s.live = null;
543
+ if (attached())
544
+ host.stateChanged(cid, true);
545
+ }
546
+ finish(result);
547
+ // Settles after every write this turn queued, so done means saved (or failed to save).
548
+ await writer.queue(cid, async () => { }).catch(() => { });
549
+ return result;
550
+ }
551
+ const done = turn().then((r) => { resolveId(r.conversationId); return r; }, (e) => {
552
+ resolveId(current);
553
+ const r = { conversationId: current, userTurnId: '', outcome: 'failed', error: messageOf(e), ran, kind };
554
+ finish(r);
555
+ return r;
556
+ });
557
+ return {
558
+ conversationId,
559
+ done,
560
+ stop: (reason) => { stopRequested = reason; ac.abort(); },
561
+ // Only this turn's own run, and only while it is the conversation's current run (R2).
562
+ inject: (messages, hard) => {
563
+ if (current === null || ownRunId === null)
564
+ return false;
565
+ const s = slots.get(current)?.state;
566
+ if (!s || !s.running || s.runId !== ownRunId)
567
+ return false;
568
+ return guardedInject(current, ownRunId, host, messages, hard, request.agent);
569
+ },
570
+ };
571
+ }
572
+ function retry(id, turnId, host) {
573
+ const slot = forgotten.has(id) ? undefined : slots.get(id);
574
+ const s = slot?.state;
575
+ const idx = s ? s.turns.findIndex((t) => t.id === turnId) : -1;
576
+ const first = s && idx >= 0 ? s.turns[idx].messages[0] : undefined;
577
+ const text = first ? userText(first) : '';
578
+ const images = first ? userImages(first) : [];
579
+ if (!slot || !s || s.running || (!text && !images.length)) {
580
+ const r = { conversationId: id, userTurnId: '', outcome: 'refused', ran: false, kind: 'retry' };
581
+ complete(host, r);
582
+ return { conversationId: Promise.resolve(id), done: Promise.resolve(r), stop: () => { }, inject: () => false };
583
+ }
584
+ const dropped = s.turns.slice(idx);
585
+ // By id, not index: a load that lands before the refusal puts stored turns first.
586
+ const anchor = idx > 0 ? s.turns[idx - 1].id : null;
587
+ // A save while the guard waits writes the truncated list, or merges stored turns back in.
588
+ const seen = slot.saves;
589
+ s.turns = s.turns.slice(0, idx);
590
+ host.stateChanged(id, false);
591
+ return start({ conversationId: id, input: { text, images } }, host, {
592
+ kind: 'retry',
593
+ // Back where they were; anything noted since stays after them.
594
+ rollback: () => {
595
+ if (slots.get(id) !== slot)
596
+ return;
597
+ const now = slot.state.turns;
598
+ const have = new Set(now.map((t) => t.id));
599
+ const back = dropped.filter((t) => !have.has(t.id));
600
+ const found = anchor === null ? 0 : now.findIndex((t) => t.id === anchor) + 1;
601
+ const at = found > 0 || anchor === null ? found : now.length;
602
+ slot.state.turns = [...now.slice(0, at), ...back, ...now.slice(at)];
603
+ if (slot.saves !== seen)
604
+ void save(id, host);
605
+ },
606
+ });
607
+ }
608
+ async function hostTurn(id, text, produce, host) {
609
+ // turnComplete exactly once, whichever way this ends (R3); ran means produce was called.
610
+ const refused = () => { complete(host, { conversationId: id, userTurnId: '', outcome: 'refused', ran: false, kind: 'host-turn' }); };
611
+ if (!text) {
612
+ refused();
613
+ return;
614
+ }
615
+ reopen(id);
616
+ stores.set(id, host);
617
+ const slot = slotFor(id);
618
+ const s = slot.state;
619
+ if (s.running) {
620
+ refused();
621
+ return;
622
+ }
623
+ // As in run: never build on a transcript that has not loaded.
624
+ if (!s.loaded) {
625
+ await load(id, host);
626
+ if (s.running) {
627
+ refused();
628
+ return;
629
+ }
630
+ }
631
+ const attached = () => slots.get(id) === slot;
632
+ const isFirst = s.loaded && s.turns.length === 0;
633
+ const userTurn = { id: noteId('t'), included: true, messages: [{ role: 'user', content: text }] };
634
+ const ac = new AbortController();
635
+ slot.abort = ac;
636
+ // Optimistic, as in run: the guard can be a network call.
637
+ Object.assign(s, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true });
638
+ flagIfUnsaved(s);
639
+ host.stateChanged(id, true);
640
+ const denied = await host.guard({ conversationId: id, kind: 'host-turn' }).then((v) => (v.allowed ? null : { outcome: 'refused', error: v.reason }), (e) => ({ outcome: 'failed', error: messageOf(e) }));
641
+ if (denied !== null) {
642
+ slot.abort = null;
643
+ if (attached()) {
644
+ Object.assign(s, { turns: s.turns.filter((t) => t.id !== userTurn.id), running: false, live: null, error: denied.error });
645
+ host.stateChanged(id, true);
646
+ }
647
+ complete(host, { conversationId: id, userTurnId: userTurn.id, ...denied, ran: false, kind: 'host-turn' });
648
+ return;
649
+ }
650
+ let result = { conversationId: id, userTurnId: userTurn.id, outcome: 'done', ran: true, kind: 'host-turn' };
651
+ try {
652
+ const content = await produce(ac.signal);
653
+ if (attached()) {
654
+ const reply = { role: 'assistant', content };
655
+ s.turns = s.turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], reply] } : t));
656
+ host.stateChanged(id, false);
657
+ void save(id, host);
658
+ // Queued behind the save, so the title lands on the stored chat.
659
+ if (isFirst)
660
+ void queueTitle(id, host, text);
661
+ }
662
+ }
663
+ catch (e) {
664
+ const error = messageOf(e);
665
+ if (attached())
666
+ s.error = error;
667
+ result = { ...result, outcome: ac.signal.aborted ? 'stopped' : 'failed', error };
668
+ }
669
+ finally {
670
+ slot.abort = null;
671
+ s.running = false;
672
+ s.live = null;
673
+ if (attached())
674
+ host.stateChanged(id, true);
675
+ }
676
+ complete(host, result);
677
+ // Settles after every write this turn queued, as run's done does.
678
+ await writer.queue(id, async () => { }).catch(() => { });
679
+ }
680
+ function toggleInclude(id, host, turnId) {
681
+ const s = editable(id)?.state;
682
+ if (!s || isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
683
+ return;
684
+ s.turns = s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t));
685
+ host.stateChanged(id, false);
686
+ void save(id, host);
687
+ }
688
+ function clear(id, host) {
689
+ const s = editable(id)?.state;
690
+ if (!s)
691
+ return;
692
+ s.turns = [];
693
+ s.error = '';
694
+ host.stateChanged(id, true);
695
+ void save(id, host);
696
+ }
697
+ function beginCommand(id, command, startedAt) {
698
+ const s = editable(id)?.state;
699
+ if (!s)
700
+ return;
701
+ s.pendingCommand = { command, startedAt };
702
+ stores.get(id)?.stateChanged(id, true);
703
+ }
704
+ function abandonCommand(id) {
705
+ const s = slots.get(id)?.state;
706
+ if (!s?.pendingCommand)
707
+ return;
708
+ s.pendingCommand = null;
709
+ stores.get(id)?.stateChanged(id, true);
710
+ }
711
+ /* Written as the message pair a real tool call produces, so the agent can
712
+ * read it once kept; not included, so it is not paid for on every later
713
+ * turn until someone keeps it. */
714
+ function recordCommand(id, host, command, output, ok) {
715
+ const s = editable(id)?.state;
716
+ if (!s)
717
+ return;
718
+ const callId = `cmd_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
719
+ const turn = {
720
+ id: callId,
721
+ included: false,
722
+ messages: [
723
+ {
724
+ role: 'assistant',
725
+ content: null,
726
+ // `ok` rides in the arguments so a kept command tells the model it failed.
727
+ tool_calls: [{ id: callId, type: 'function', function: { name: 'console_command', arguments: JSON.stringify({ command, ok }) } }],
728
+ },
729
+ { role: 'tool', tool_call_id: callId, content: output || (ok ? '(no output)' : '(failed, no output)') },
730
+ ],
731
+ };
732
+ s.turns = [...s.turns, turn];
733
+ s.pendingCommand = null;
734
+ host.stateChanged(id, true);
735
+ void save(id, host);
736
+ }
737
+ function restoreToolResult(id, host, turnId, blobId, content) {
738
+ const s = editable(id)?.state;
739
+ if (!s)
740
+ return false;
741
+ const marker = `recall_result({ id: "${blobId}" })`;
742
+ const at = s.turns.findIndex((t) => t.id === turnId);
743
+ // A folded turn is stood for by the view: content restored into it would never be sent.
744
+ if (at < 0 || isFoldedTurn(s.turns, at))
745
+ return false;
746
+ const idx = s.turns[at].messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
747
+ if (idx < 0)
748
+ return false;
749
+ s.turns = s.turns.map((t) => {
750
+ if (t.id !== turnId)
751
+ return t;
752
+ const messages = t.messages.slice();
753
+ const m = messages[idx];
754
+ if (m.role === 'tool')
755
+ messages[idx] = { ...m, content };
756
+ return { ...t, messages };
757
+ });
758
+ host.stateChanged(id, false);
759
+ void save(id, host);
760
+ return true;
761
+ }
762
+ /* As run does for a conversation it created: loaded, nothing stored, so
763
+ * its first save writes without reading back a transcript first. */
764
+ function seedEmpty(id, host) {
765
+ reopen(id);
766
+ stores.set(id, host);
767
+ const s = slotFor(id).state;
768
+ s.loaded = true;
769
+ s.persistedLen = 0;
770
+ }
771
+ return {
772
+ run,
773
+ load,
774
+ retryLoad,
775
+ state: (id) => slotFor(id).state,
776
+ stop: (id) => { slots.get(id)?.abort?.abort(); },
777
+ inject,
778
+ retry,
779
+ hostTurn,
780
+ note,
781
+ noteCheckpoint,
782
+ noteUser,
783
+ toggleInclude,
784
+ clear,
785
+ beginCommand,
786
+ abandonCommand,
787
+ recordCommand,
788
+ restoreToolResult,
789
+ seedEmpty,
790
+ save,
791
+ resetSystem,
792
+ isAllowed: (id, agent, tool) => !askedEveryTime(tool) && !!slots.get(id)?.allowed.has(allowKey(agent, tool)),
793
+ allow: (id, agent, tool) => { if (!askedEveryTime(tool))
794
+ slotFor(id).allowed.add(allowKey(agent, tool)); },
795
+ forget: (id) => {
796
+ slots.get(id)?.abort?.abort();
797
+ slots.delete(id);
798
+ stores.delete(id);
799
+ forgotten.add(id);
800
+ },
801
+ };
802
+ }
803
+ //# sourceMappingURL=engine.js.map