@plurnk/plurnk-service 0.3.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/SPEC.md +35 -26
- package/dist/core/ChannelWrite.d.ts +10 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/Engine.d.ts +9 -1
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +260 -53
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/line-marker.d.ts +23 -0
- package/dist/core/line-marker.d.ts.map +1 -0
- package/dist/core/line-marker.js +321 -0
- package/dist/core/line-marker.js.map +1 -0
- package/dist/core/matcher.d.ts +14 -0
- package/dist/core/matcher.d.ts.map +1 -0
- package/dist/core/matcher.js +195 -0
- package/dist/core/matcher.js.map +1 -0
- package/dist/core/mimetype-binary.d.ts +6 -0
- package/dist/core/mimetype-binary.d.ts.map +1 -0
- package/dist/core/mimetype-binary.js +82 -0
- package/dist/core/mimetype-binary.js.map +1 -0
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +97 -21
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/path-mimetype.d.ts +3 -0
- package/dist/core/path-mimetype.d.ts.map +1 -0
- package/dist/core/path-mimetype.js +47 -0
- package/dist/core/path-mimetype.js.map +1 -0
- package/dist/core/scheme-types.d.ts +5 -0
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/schemes/Exec.d.ts +30 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +229 -6
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts +4 -0
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +107 -23
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +2 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +82 -13
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Plurnk.js +3 -3
- package/dist/schemes/Plurnk.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +2 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +1 -0
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +64 -15
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +3 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +268 -55
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +14 -7
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/server/ClientConnection.d.ts +3 -2
- package/dist/server/ClientConnection.d.ts.map +1 -1
- package/dist/server/ClientConnection.js +4 -1
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +40 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +319 -1
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +29 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/MethodRegistry.js.map +1 -1
- package/dist/server/dsl.d.ts +2 -2
- package/dist/server/dsl.d.ts.map +1 -1
- package/dist/server/dsl.js +10 -10
- package/dist/server/dsl.js.map +1 -1
- package/dist/server/methods/entry_read.js +5 -5
- package/dist/server/methods/entry_read.js.map +1 -1
- package/dist/server/methods/loop_cancel.d.ts +3 -0
- package/dist/server/methods/loop_cancel.d.ts.map +1 -0
- package/dist/server/methods/loop_cancel.js +27 -0
- package/dist/server/methods/loop_cancel.js.map +1 -0
- package/dist/server/methods/loop_run.d.ts.map +1 -1
- package/dist/server/methods/loop_run.js +46 -47
- package/dist/server/methods/loop_run.js.map +1 -1
- package/dist/server/methods/op_edit.js +3 -3
- package/dist/server/methods/op_edit.js.map +1 -1
- package/dist/server/methods/op_hide.js +3 -3
- package/dist/server/methods/op_hide.js.map +1 -1
- package/dist/server/methods/op_read.js +3 -3
- package/dist/server/methods/op_read.js.map +1 -1
- package/dist/server/methods/op_show.js +3 -3
- package/dist/server/methods/op_show.js.map +1 -1
- package/migrations/001_schema.sql +1 -1
- package/package.json +5 -4
package/dist/core/Engine.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { PlurnkParser } from "@plurnk/plurnk-grammar";
|
|
2
2
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
3
|
+
import { writeEntry } from "../schemes/_entry-crud.js";
|
|
3
4
|
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
5
|
+
import { sliceLinesRaw } from "./line-marker.js";
|
|
6
|
+
import { isBinaryMimetype } from "./mimetype-binary.js";
|
|
4
7
|
// Plain JS module shared with bin/digest.js so wire projection and
|
|
5
8
|
// digest projection are structurally one function. tsconfig.build.json
|
|
6
9
|
// has allowJs:true so this gets copied through to dist/.
|
|
@@ -67,17 +70,57 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
67
70
|
return fallback;
|
|
68
71
|
return n;
|
|
69
72
|
};
|
|
70
|
-
// Per-op fingerprint: op verb + target URI
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
73
|
+
// Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
|
|
74
|
+
// where the activity isn't fully captured by target alone:
|
|
75
|
+
// - EDIT/COPY/MOVE: body excluded — re-writing the same target with varied
|
|
76
|
+
// content IS cycling (the model is producing different versions of the
|
|
77
|
+
// same artifact instead of progressing).
|
|
78
|
+
// - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
|
|
79
|
+
// matchers on the same target ARE different activities (the model is
|
|
80
|
+
// exploring different queries, not repeating one).
|
|
74
81
|
const fingerprintOp = (stmt) => {
|
|
75
|
-
const path = stmt.
|
|
76
|
-
|
|
82
|
+
const path = stmt.target;
|
|
83
|
+
const matcherDiscriminator = () => {
|
|
84
|
+
// For matcher-bearing ops, the body's `raw` (matcher source) plus
|
|
85
|
+
// any lineMarker forms the activity discriminator.
|
|
86
|
+
const parts = [];
|
|
87
|
+
const body = stmt.body;
|
|
88
|
+
if (body !== null && typeof body === "object" && typeof body.raw === "string") {
|
|
89
|
+
parts.push(`body:${body.raw.slice(0, 64)}`);
|
|
90
|
+
}
|
|
91
|
+
const lm = stmt.lineMarker;
|
|
92
|
+
if (lm !== null && lm !== undefined)
|
|
93
|
+
parts.push(`L:${lm.first},${lm.last ?? ""}`);
|
|
94
|
+
return parts.length > 0 ? `|${parts.join("|")}` : "";
|
|
95
|
+
};
|
|
96
|
+
if (path === null) {
|
|
97
|
+
// Path-less ops need an activity-defining discriminator other
|
|
98
|
+
// than `target`. Picked per op so the cycle detector reflects
|
|
99
|
+
// intent rather than syntax:
|
|
100
|
+
// - EXEC: the command body IS the activity. Without a body
|
|
101
|
+
// digest, varied shell commands (find / ls / wc) collapse to
|
|
102
|
+
// one fingerprint and the detector mislabels exploration
|
|
103
|
+
// as a loop.
|
|
104
|
+
// - SEND: the status code (signal) IS the activity. Different
|
|
105
|
+
// SEND[X] are different intentions; same SEND[X] with
|
|
106
|
+
// different message bodies is the same termination signal.
|
|
107
|
+
if (stmt.op === "EXEC") {
|
|
108
|
+
const body = typeof stmt.body === "string" ? stmt.body : "";
|
|
109
|
+
return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
|
|
110
|
+
}
|
|
111
|
+
if (stmt.op === "SEND") {
|
|
112
|
+
const signal = typeof stmt.signal === "number" ? stmt.signal : "";
|
|
113
|
+
return `SEND|(no-path)|signal:${signal}`;
|
|
114
|
+
}
|
|
77
115
|
return `${stmt.op}|(no-path)`;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
116
|
+
}
|
|
117
|
+
const base = path.kind === "url"
|
|
118
|
+
? `${stmt.op}|${path.scheme}://${path.pathname}`
|
|
119
|
+
: `${stmt.op}|local:${path.raw}`;
|
|
120
|
+
if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
|
|
121
|
+
return `${base}${matcherDiscriminator()}`;
|
|
122
|
+
}
|
|
123
|
+
return base;
|
|
81
124
|
};
|
|
82
125
|
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
83
126
|
// within a turn doesn't matter — we want the SET of activities.
|
|
@@ -135,9 +178,19 @@ export default class Engine {
|
|
|
135
178
|
// event emitter — no priority, no veto chain at this layer; filter
|
|
136
179
|
// chains come later if a real consumer needs them.
|
|
137
180
|
#proposalPendingListeners = [];
|
|
138
|
-
|
|
181
|
+
// Per-loop AbortController for cancellation propagation into scheme
|
|
182
|
+
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
183
|
+
// cancellation paths (strikes, max_turns, external) abort it.
|
|
184
|
+
// Streaming schemes (exec) chain their per-spawn controllers off
|
|
185
|
+
// ctx.signal so cancelled loops tear down their background spawns.
|
|
186
|
+
#loopAborts = new Map();
|
|
187
|
+
#streamEventNotify;
|
|
188
|
+
#wakeRunNotify;
|
|
189
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify }) {
|
|
139
190
|
this.#db = db;
|
|
140
191
|
this.#schemes = schemes;
|
|
192
|
+
this.#streamEventNotify = streamEventNotify;
|
|
193
|
+
this.#wakeRunNotify = wakeRunNotify;
|
|
141
194
|
// Default to empty discovery — standalone Engine construction (in
|
|
142
195
|
// tests) gets no handlers, and content flows through the framework's
|
|
143
196
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
@@ -164,7 +217,29 @@ export default class Engine {
|
|
|
164
217
|
async runLoop({ provider, messages, persona = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
|
|
165
218
|
const turnIds = [];
|
|
166
219
|
const suddenDeathThreshold = maxTurns - maxStrikes;
|
|
167
|
-
|
|
220
|
+
// Per-loop AbortController for scheme-side cancellation propagation.
|
|
221
|
+
// Chained from the caller's `signal` so an external abort cascades.
|
|
222
|
+
const loopAbort = new AbortController();
|
|
223
|
+
if (signal !== undefined) {
|
|
224
|
+
if (signal.aborted)
|
|
225
|
+
loopAbort.abort(signal.reason);
|
|
226
|
+
else
|
|
227
|
+
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
228
|
+
}
|
|
229
|
+
this.#loopAborts.set(loopId, loopAbort);
|
|
230
|
+
// Cleanup splits by termination kind:
|
|
231
|
+
// - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
|
|
232
|
+
// spawns are ALLOWED to outlive the loop. They complete naturally,
|
|
233
|
+
// write final channel state, and wake-on-completion (E.4) opens a
|
|
234
|
+
// fresh loop in the same run if the model needs to react.
|
|
235
|
+
// - "forceful" (max_turns, strike_threshold, external cancel,
|
|
236
|
+
// non-2xx loop status): fire the loop-level abort so spawns
|
|
237
|
+
// tear down immediately.
|
|
238
|
+
const cleanup = (kind, reason) => {
|
|
239
|
+
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
240
|
+
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
241
|
+
}
|
|
242
|
+
this.#loopAborts.delete(loopId);
|
|
168
243
|
this.#strikeState.delete(loopId);
|
|
169
244
|
this.#telemetryBuffer.delete(loopId);
|
|
170
245
|
};
|
|
@@ -174,12 +249,15 @@ export default class Engine {
|
|
|
174
249
|
if (row === undefined)
|
|
175
250
|
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
176
251
|
if (row.status !== 102) {
|
|
177
|
-
|
|
252
|
+
// Status 2xx = graceful (model said done); 4xx/5xx = forceful
|
|
253
|
+
// (external cancel or upstream failure). The threshold splits
|
|
254
|
+
// at 400 to match HTTP success/error semantics.
|
|
255
|
+
cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
|
|
178
256
|
return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
|
|
179
257
|
}
|
|
180
258
|
if (turnIds.length >= maxTurns) {
|
|
181
259
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
182
|
-
cleanup();
|
|
260
|
+
cleanup("forceful", "max_turns");
|
|
183
261
|
return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
|
|
184
262
|
}
|
|
185
263
|
const turn = await this.runTurn({
|
|
@@ -199,7 +277,7 @@ export default class Engine {
|
|
|
199
277
|
kind: "cycle",
|
|
200
278
|
period: cycle.period,
|
|
201
279
|
cycles: cycle.cycles,
|
|
202
|
-
message:
|
|
280
|
+
message: "Loop detected",
|
|
203
281
|
});
|
|
204
282
|
}
|
|
205
283
|
this.#strikeState.set(loopId, state);
|
|
@@ -214,15 +292,17 @@ export default class Engine {
|
|
|
214
292
|
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
215
293
|
if (struck) {
|
|
216
294
|
state.streak++;
|
|
295
|
+
const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
|
|
217
296
|
this.#pushTelemetry(loopId, {
|
|
218
297
|
kind: "strike",
|
|
219
298
|
streak: state.streak,
|
|
220
299
|
maxStrikes,
|
|
221
|
-
reason
|
|
300
|
+
reason,
|
|
301
|
+
message: `strike ${state.streak}/${maxStrikes} (${reason}); ${maxStrikes - state.streak} before abandonment`,
|
|
222
302
|
});
|
|
223
303
|
if (state.streak >= maxStrikes) {
|
|
224
304
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
225
|
-
cleanup();
|
|
305
|
+
cleanup("forceful", "strike_threshold");
|
|
226
306
|
return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
|
|
227
307
|
}
|
|
228
308
|
}
|
|
@@ -238,7 +318,7 @@ export default class Engine {
|
|
|
238
318
|
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
239
319
|
this.#pushTelemetry(loopId, {
|
|
240
320
|
kind: "sudden_death",
|
|
241
|
-
message:
|
|
321
|
+
message: `${turnIds.length} of ${maxTurns}`,
|
|
242
322
|
remaining: maxTurns - turnIds.length,
|
|
243
323
|
});
|
|
244
324
|
}
|
|
@@ -263,26 +343,30 @@ export default class Engine {
|
|
|
263
343
|
if (openRow === undefined)
|
|
264
344
|
throw new Error("Engine.runTurn: turn open returned no row");
|
|
265
345
|
const turnId = openRow.id;
|
|
266
|
-
// Pre-model writes.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// 1
|
|
346
|
+
// Pre-model writes. Each turn opens with a system-origin EDIT
|
|
347
|
+
// against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
|
|
348
|
+
// for THIS turn the model hasn't seen yet:
|
|
349
|
+
// - Turn 1: loop.prompt is the initial user prompt.
|
|
350
|
+
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
351
|
+
// daemon.inject) wrote a prompt entry for this turn slot
|
|
352
|
+
// between turn N-1 and N. Inject writes directly to entries;
|
|
353
|
+
// we DON'T re-foist here for N>1.
|
|
354
|
+
// The log records the EDIT for forensics. Model ops dispatch
|
|
355
|
+
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
356
|
+
// otherwise.
|
|
273
357
|
let nextActionIndex = 1;
|
|
274
358
|
if (seq === 1) {
|
|
275
359
|
const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
|
|
276
360
|
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
277
361
|
const promptPath = {
|
|
278
|
-
kind: "url", raw: `plurnk://prompt/${loopId}`,
|
|
362
|
+
kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
|
|
279
363
|
scheme: "plurnk", username: null, password: null,
|
|
280
364
|
hostname: null, port: null,
|
|
281
|
-
pathname: `prompt/${loopId}`, params: {}, fragment: null,
|
|
365
|
+
pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
|
|
282
366
|
};
|
|
283
367
|
const promptStmt = {
|
|
284
368
|
op: "EDIT", suffix: "", signal: null,
|
|
285
|
-
|
|
369
|
+
target: promptPath, lineMarker: null,
|
|
286
370
|
body: promptRow.prompt, position: { line: 1, column: 1 },
|
|
287
371
|
};
|
|
288
372
|
await this.dispatch({
|
|
@@ -305,7 +389,15 @@ export default class Engine {
|
|
|
305
389
|
// parsed ops) → packet.assistant per Packet.json §assistant;
|
|
306
390
|
// call-metadata (usage, finishReason, model) → Turn columns per
|
|
307
391
|
// Turn.json. Mixing the two on packet.assistant was the wrong layer.
|
|
308
|
-
const { packetAssistant, callMetadata } = this.#splitResponse(response);
|
|
392
|
+
const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
|
|
393
|
+
// Surface parse errors to the model's NEXT packet so it can self-
|
|
394
|
+
// correct. Without this, malformed emissions (e.g. a READ matcher
|
|
395
|
+
// body starting with `//` being interpreted as xpath) silently
|
|
396
|
+
// drop, the model sees zero ops dispatched, strike-rail fires,
|
|
397
|
+
// model has no feedback on WHY its emission didn't take effect.
|
|
398
|
+
for (const message of parseErrors ?? []) {
|
|
399
|
+
this.#pushTelemetry(loopId, { kind: "parse_error", message });
|
|
400
|
+
}
|
|
309
401
|
const opsCount = packetAssistant.ops.length;
|
|
310
402
|
const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
|
|
311
403
|
// Rail #41 (revised): the per-turn requirement is "emit at least one
|
|
@@ -366,6 +458,7 @@ export default class Engine {
|
|
|
366
458
|
const preParsedOps = assistant.ops;
|
|
367
459
|
const ops = [];
|
|
368
460
|
const textFragments = [];
|
|
461
|
+
const parseErrors = [];
|
|
369
462
|
if (preParsedOps !== undefined) {
|
|
370
463
|
ops.push(...preParsedOps);
|
|
371
464
|
}
|
|
@@ -379,6 +472,11 @@ export default class Engine {
|
|
|
379
472
|
if (trimmed.length > 0)
|
|
380
473
|
textFragments.push(trimmed);
|
|
381
474
|
}
|
|
475
|
+
else if (item.kind === "error") {
|
|
476
|
+
const err = item.error;
|
|
477
|
+
const msg = (err && typeof err.message === "string") ? err.message : "parse error";
|
|
478
|
+
parseErrors.push(msg);
|
|
479
|
+
}
|
|
382
480
|
}
|
|
383
481
|
}
|
|
384
482
|
const wireReasoning = assistant.reasoning ?? "";
|
|
@@ -388,6 +486,7 @@ export default class Engine {
|
|
|
388
486
|
return {
|
|
389
487
|
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
390
488
|
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
489
|
+
parseErrors,
|
|
391
490
|
};
|
|
392
491
|
}
|
|
393
492
|
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
@@ -400,7 +499,15 @@ export default class Engine {
|
|
|
400
499
|
async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
|
|
401
500
|
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
402
501
|
const system_definition = byRole("system");
|
|
403
|
-
|
|
502
|
+
// user.prompt sources from the loop's most recent prompt entry first
|
|
503
|
+
// (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
|
|
504
|
+
// This is what inject + the turn-1 foist write into. Falls back to
|
|
505
|
+
// the runLoop caller's messages.user for tests that bypass the
|
|
506
|
+
// foist mechanism entirely.
|
|
507
|
+
const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
|
|
508
|
+
const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
|
|
509
|
+
? latestPromptRow.content
|
|
510
|
+
: byRole("user");
|
|
404
511
|
// Resolve persona cascade: loops.persona > runs.persona >
|
|
405
512
|
// sessions.persona > caller-supplied default. SQL coalesces in one
|
|
406
513
|
// query; null result means no DB override exists, use the default.
|
|
@@ -549,19 +656,26 @@ export default class Engine {
|
|
|
549
656
|
status: r.status_rx,
|
|
550
657
|
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
551
658
|
mimetype_rx: r.mimetype_rx,
|
|
659
|
+
tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
|
|
660
|
+
mimetype_tx: r.mimetype_tx,
|
|
552
661
|
}));
|
|
553
662
|
}
|
|
554
663
|
async #buildIndex(runId, currentLoopId) {
|
|
555
664
|
const rows = await this.#db.engine_render_index.all({ run_id: runId });
|
|
556
665
|
const tagsStmt = this.#db.engine_entry_tags;
|
|
557
|
-
// Foist the CURRENT loop's prompt
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
666
|
+
// Foist the CURRENT loop's prompt entries out of the index render —
|
|
667
|
+
// their bodies are materialized in packet.user.prompt instead. With
|
|
668
|
+
// multi-turn injection, a loop can have multiple prompt entries at
|
|
669
|
+
// plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
|
|
670
|
+
// only previous loops' prompts (still addressable for HIDE).
|
|
671
|
+
const foistedPrefix = `prompt/${currentLoopId}/`;
|
|
672
|
+
// Backward compat: legacy single-slot path. Tests / older runs may
|
|
673
|
+
// still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
|
|
674
|
+
const foistedExact = `prompt/${currentLoopId}`;
|
|
562
675
|
const entries = new Map();
|
|
563
676
|
for (const row of rows) {
|
|
564
|
-
if (row.scheme === "plurnk"
|
|
677
|
+
if (row.scheme === "plurnk"
|
|
678
|
+
&& (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
|
|
565
679
|
continue;
|
|
566
680
|
let entry = entries.get(row.entry_id);
|
|
567
681
|
if (entry === undefined) {
|
|
@@ -611,7 +725,10 @@ export default class Engine {
|
|
|
611
725
|
db: this.#db,
|
|
612
726
|
sessionId, runId, loopId, turnId,
|
|
613
727
|
writer: origin,
|
|
614
|
-
signal:
|
|
728
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
729
|
+
streamEventNotify: this.#streamEventNotify,
|
|
730
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
731
|
+
mimetypes: this.#mimetypes,
|
|
615
732
|
};
|
|
616
733
|
let result;
|
|
617
734
|
let denial = this.#checkWritable(statement, origin);
|
|
@@ -627,7 +744,7 @@ export default class Engine {
|
|
|
627
744
|
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
628
745
|
// those are system failures.
|
|
629
746
|
try {
|
|
630
|
-
if (statement.op === "SEND" && statement.
|
|
747
|
+
if (statement.op === "SEND" && statement.target === null) {
|
|
631
748
|
result = await this.#handleSendBroadcast(statement, loopId);
|
|
632
749
|
}
|
|
633
750
|
else if (statement.op === "COPY") {
|
|
@@ -636,8 +753,15 @@ export default class Engine {
|
|
|
636
753
|
else if (statement.op === "MOVE") {
|
|
637
754
|
result = await this.#handleMove(statement, schemeCtx);
|
|
638
755
|
}
|
|
756
|
+
else if (statement.op === "EXEC") {
|
|
757
|
+
// EXEC's target slot is `cwd`, not a scheme address.
|
|
758
|
+
// Per plurnk.md the op routes unconditionally to the
|
|
759
|
+
// exec scheme; the scheme handler reads runtime
|
|
760
|
+
// (signal), cwd (target), and command (body).
|
|
761
|
+
result = await this.#run("exec", statement, schemeCtx);
|
|
762
|
+
}
|
|
639
763
|
else {
|
|
640
|
-
result = await this.#run(this.#schemeNameOf(statement.
|
|
764
|
+
result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
641
765
|
}
|
|
642
766
|
}
|
|
643
767
|
catch (err) {
|
|
@@ -666,7 +790,7 @@ export default class Engine {
|
|
|
666
790
|
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
667
791
|
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
668
792
|
// resolve synchronously inside their handlers.
|
|
669
|
-
const target = this.#extractTarget(statement.
|
|
793
|
+
const target = this.#extractTarget(statement.target);
|
|
670
794
|
const flags = await this.#loadLoopFlags(loopId);
|
|
671
795
|
const event = {
|
|
672
796
|
logEntryId, sessionId, runId, loopId, turnId,
|
|
@@ -698,7 +822,10 @@ export default class Engine {
|
|
|
698
822
|
const { sessionId, runId, loopId, turnId } = ids;
|
|
699
823
|
if (resolution.decision !== "accept")
|
|
700
824
|
return resolution;
|
|
701
|
-
|
|
825
|
+
// EXEC routes to the exec scheme regardless of target (cwd, not
|
|
826
|
+
// a scheme address). All other ops resolve their handler from
|
|
827
|
+
// statement.target's scheme.
|
|
828
|
+
const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
|
|
702
829
|
if (schemeName === null)
|
|
703
830
|
return resolution;
|
|
704
831
|
const handler = this.#schemes.get(schemeName);
|
|
@@ -711,7 +838,9 @@ export default class Engine {
|
|
|
711
838
|
// operation's artifact visible in the next packet's index.
|
|
712
839
|
const applyCtx = {
|
|
713
840
|
db: this.#db, sessionId, runId, loopId, turnId,
|
|
714
|
-
writer: "model", signal:
|
|
841
|
+
writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
|
|
842
|
+
streamEventNotify: this.#streamEventNotify,
|
|
843
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
715
844
|
};
|
|
716
845
|
const applyResult = await handler.applyResolution({
|
|
717
846
|
attrs: (originalResult.attrs ?? {}),
|
|
@@ -724,6 +853,14 @@ export default class Engine {
|
|
|
724
853
|
body: applyResult.body,
|
|
725
854
|
};
|
|
726
855
|
}
|
|
856
|
+
// Propagate applyResolution.outcome onto the accepted resolution
|
|
857
|
+
// so the log entry's outcome column reflects operational metadata
|
|
858
|
+
// (e.g. exec's "exit_N"). Without this, only failures get an
|
|
859
|
+
// outcome on the durable record, and "ran cleanly but with a
|
|
860
|
+
// notable detail" has nowhere to land.
|
|
861
|
+
if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
|
|
862
|
+
return { ...resolution, outcome: applyResult.outcome };
|
|
863
|
+
}
|
|
727
864
|
return resolution;
|
|
728
865
|
}
|
|
729
866
|
catch (err) {
|
|
@@ -754,6 +891,52 @@ export default class Engine {
|
|
|
754
891
|
pendingProposalIds() {
|
|
755
892
|
return [...this.#pendingProposals.keys()];
|
|
756
893
|
}
|
|
894
|
+
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
895
|
+
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
896
|
+
// will pick up the channel transition at its next turn boundary. If no,
|
|
897
|
+
// the daemon opens a fresh loop with the wake prompt.
|
|
898
|
+
async hasActiveLoopForRun(runId) {
|
|
899
|
+
const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
|
|
900
|
+
return (row?.n ?? 0) > 0;
|
|
901
|
+
}
|
|
902
|
+
// Inject a prompt into the run's currently-executing loop. Writes a
|
|
903
|
+
// plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
|
|
904
|
+
// packet.user.prompt at the next turn boundary. Last-wins: if two
|
|
905
|
+
// injects target the same next-turn slot, the second overwrites the
|
|
906
|
+
// first.
|
|
907
|
+
//
|
|
908
|
+
// Returns null when no loop in the run is currently active (status=102).
|
|
909
|
+
// The daemon-side inject path then enqueues a fresh loop with this
|
|
910
|
+
// prompt; engine doesn't open loops itself.
|
|
911
|
+
//
|
|
912
|
+
// Rummy parallel: AgentLoop.inject(). The "active drain → write
|
|
913
|
+
// prompt entry, return immediately" branch.
|
|
914
|
+
async inject(runId, prompt) {
|
|
915
|
+
const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
|
|
916
|
+
if (loopRow === undefined)
|
|
917
|
+
return null;
|
|
918
|
+
const loopId = loopRow.id;
|
|
919
|
+
const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
|
|
920
|
+
const turnSeq = turnRow?.next ?? 1;
|
|
921
|
+
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
922
|
+
if (sessionRow === undefined)
|
|
923
|
+
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
924
|
+
const pathname = `prompt/${loopId}/${turnSeq}`;
|
|
925
|
+
const ctx = {
|
|
926
|
+
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
927
|
+
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
928
|
+
writer: "system",
|
|
929
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
930
|
+
streamEventNotify: this.#streamEventNotify,
|
|
931
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
932
|
+
};
|
|
933
|
+
const entry = {
|
|
934
|
+
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
935
|
+
tags: [],
|
|
936
|
+
};
|
|
937
|
+
await writeEntry(pathname, entry, ctx, "plurnk");
|
|
938
|
+
return { loopId, turnSeq };
|
|
939
|
+
}
|
|
757
940
|
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
758
941
|
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
759
942
|
// (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
|
|
@@ -834,15 +1017,20 @@ export default class Engine {
|
|
|
834
1017
|
#checkWritable(statement, origin) {
|
|
835
1018
|
if (!MUTATING_OPS.has(statement.op))
|
|
836
1019
|
return null;
|
|
837
|
-
if (statement.op === "SEND" && statement.
|
|
1020
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
838
1021
|
return null;
|
|
1022
|
+
// EXEC's target slot is `cwd`, not a scheme address. The op's
|
|
1023
|
+
// authority always belongs to the exec scheme regardless of cwd.
|
|
1024
|
+
if (statement.op === "EXEC") {
|
|
1025
|
+
return this.#denyIfDisallowed("exec", origin);
|
|
1026
|
+
}
|
|
839
1027
|
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
840
1028
|
const dstScheme = this.#schemeNameOf(statement.body);
|
|
841
1029
|
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
842
1030
|
if (dstDenial !== null)
|
|
843
1031
|
return dstDenial;
|
|
844
1032
|
if (statement.op === "MOVE") {
|
|
845
|
-
const srcScheme = this.#schemeNameOf(statement.
|
|
1033
|
+
const srcScheme = this.#schemeNameOf(statement.target);
|
|
846
1034
|
if (srcScheme !== dstScheme) {
|
|
847
1035
|
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
848
1036
|
if (srcDenial !== null)
|
|
@@ -851,7 +1039,7 @@ export default class Engine {
|
|
|
851
1039
|
}
|
|
852
1040
|
return null;
|
|
853
1041
|
}
|
|
854
|
-
const target = this.#schemeNameOf(statement.
|
|
1042
|
+
const target = this.#schemeNameOf(statement.target);
|
|
855
1043
|
return this.#denyIfDisallowed(target, origin);
|
|
856
1044
|
}
|
|
857
1045
|
#denyIfDisallowed(schemeName, origin) {
|
|
@@ -874,15 +1062,15 @@ export default class Engine {
|
|
|
874
1062
|
// set returns 403 — action-entry-as-outcome carries the rejection.
|
|
875
1063
|
async #checkFlagsGate(statement, loopId) {
|
|
876
1064
|
// Broadcast SEND has no scheme to gate.
|
|
877
|
-
if (statement.op === "SEND" && statement.
|
|
1065
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
878
1066
|
return null;
|
|
879
1067
|
const flags = await this.#loadLoopFlags(loopId);
|
|
880
1068
|
// Fast path: default flags gate nothing. (yolo never gates.)
|
|
881
1069
|
if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
|
|
882
1070
|
return null;
|
|
883
1071
|
const active = this.#schemes.resolveForLoop(flags);
|
|
884
|
-
const check = (
|
|
885
|
-
const scheme = this.#schemeNameOf(
|
|
1072
|
+
const check = (target) => {
|
|
1073
|
+
const scheme = this.#schemeNameOf(target);
|
|
886
1074
|
if (scheme === null)
|
|
887
1075
|
return null;
|
|
888
1076
|
if (active.has(scheme))
|
|
@@ -890,14 +1078,14 @@ export default class Engine {
|
|
|
890
1078
|
return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
|
|
891
1079
|
};
|
|
892
1080
|
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
893
|
-
return check(statement.
|
|
1081
|
+
return check(statement.target) ?? check(statement.body);
|
|
894
1082
|
}
|
|
895
|
-
return check(statement.
|
|
1083
|
+
return check(statement.target);
|
|
896
1084
|
}
|
|
897
1085
|
async #handleCopy(statement, ctx) {
|
|
898
1086
|
if (statement.op !== "COPY")
|
|
899
1087
|
throw new Error("unreachable");
|
|
900
|
-
const srcPath = statement.
|
|
1088
|
+
const srcPath = statement.target;
|
|
901
1089
|
const dstPath = statement.body;
|
|
902
1090
|
if (srcPath === null)
|
|
903
1091
|
return { status: 400, error: "COPY requires source path" };
|
|
@@ -908,7 +1096,7 @@ export default class Engine {
|
|
|
908
1096
|
async #handleMove(statement, ctx) {
|
|
909
1097
|
if (statement.op !== "MOVE")
|
|
910
1098
|
throw new Error("unreachable");
|
|
911
|
-
const srcPath = statement.
|
|
1099
|
+
const srcPath = statement.target;
|
|
912
1100
|
const dstPath = statement.body;
|
|
913
1101
|
if (srcPath === null)
|
|
914
1102
|
return { status: 400, error: "MOVE requires source path" };
|
|
@@ -966,11 +1154,30 @@ export default class Engine {
|
|
|
966
1154
|
return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
|
|
967
1155
|
}
|
|
968
1156
|
}
|
|
1157
|
+
// `<L>` source range slicing per AGENTS.md "Resolved ambiguities" §4
|
|
1158
|
+
// (symmetric with READ `<L>` — source range, no line-number prefix).
|
|
1159
|
+
// Applied to every channel of the source entry. Binary channels return
|
|
1160
|
+
// 415 since line semantics don't apply.
|
|
1161
|
+
const lineMarker = statement.lineMarker ?? null;
|
|
1162
|
+
let channels = entry.channels;
|
|
1163
|
+
if (lineMarker !== null) {
|
|
1164
|
+
const sliced = {};
|
|
1165
|
+
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
1166
|
+
if (isBinaryMimetype(channelData.mimetype)) {
|
|
1167
|
+
return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
|
|
1168
|
+
}
|
|
1169
|
+
const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
|
|
1170
|
+
if (r.status !== 200)
|
|
1171
|
+
return { status: r.status, error: r.error };
|
|
1172
|
+
sliced[channelName] = { ...channelData, content: r.text ?? "" };
|
|
1173
|
+
}
|
|
1174
|
+
channels = sliced;
|
|
1175
|
+
}
|
|
969
1176
|
// Tag resolution: signal = replace; absent/empty = carry from source
|
|
970
1177
|
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
971
1178
|
? statement.signal
|
|
972
1179
|
: entry.tags;
|
|
973
|
-
const writeResult = await dstHandler.writeEntry(dstPathname, { channels
|
|
1180
|
+
const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
|
|
974
1181
|
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
975
1182
|
}
|
|
976
1183
|
async #handleSendBroadcast(statement, loopId) {
|
|
@@ -1007,7 +1214,7 @@ export default class Engine {
|
|
|
1007
1214
|
return "file"; // local (bare) → file
|
|
1008
1215
|
}
|
|
1009
1216
|
async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
|
|
1010
|
-
const target = this.#extractTarget(statement.
|
|
1217
|
+
const target = this.#extractTarget(statement.target);
|
|
1011
1218
|
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
1012
1219
|
? JSON.stringify(statement.lineMarker)
|
|
1013
1220
|
: null;
|