@plurnk/plurnk-service 0.55.0 → 0.57.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/.env.example +9 -14
- package/SPEC.md +133 -99
- package/dist/content/matcher.d.ts +4 -0
- package/dist/content/matcher.d.ts.map +1 -1
- package/dist/content/matcher.js +73 -92
- package/dist/content/matcher.js.map +1 -1
- package/dist/core/ChannelWrite.d.ts +7 -1
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js +8 -2
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +12 -2
- package/dist/core/Engine.d.ts +9 -0
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +345 -104
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +44 -2
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +6 -1
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +20 -4
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +29 -0
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +40 -4
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/packet-inject.d.ts +2 -0
- package/dist/core/packet-inject.d.ts.map +1 -1
- package/dist/core/packet-inject.js +28 -1
- package/dist/core/packet-inject.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +32 -28
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/session-settings.d.ts +1 -6
- package/dist/core/session-settings.d.ts.map +1 -1
- package/dist/core/session-settings.js +4 -13
- package/dist/core/session-settings.js.map +1 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +41 -7
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
- package/dist/schemes/ExecOutputScheme.js +2 -1
- package/dist/schemes/ExecOutputScheme.js.map +1 -1
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +9 -6
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +4 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +35 -19
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Log.sql +14 -11
- package/dist/schemes/Run.d.ts +7 -1
- package/dist/schemes/Run.d.ts.map +1 -1
- package/dist/schemes/Run.js +49 -9
- package/dist/schemes/Run.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts +16 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +84 -50
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +23 -0
- package/dist/schemes/_entry-graph.d.ts +6 -1
- package/dist/schemes/_entry-graph.d.ts.map +1 -1
- package/dist/schemes/_entry-graph.js +35 -22
- package/dist/schemes/_entry-graph.js.map +1 -1
- package/dist/schemes/_entry-graph.sql +9 -7
- package/dist/schemes/_entry-manifest.d.ts +1 -1
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +38 -7
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +5 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +14 -0
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-semantic.d.ts +1 -1
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +15 -12
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/schemes/exec-abort.d.ts +4 -0
- package/dist/schemes/exec-abort.d.ts.map +1 -1
- package/dist/schemes/exec-abort.js +6 -0
- package/dist/schemes/exec-abort.js.map +1 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +133 -19
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +2 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/drain.sql +17 -5
- package/dist/server/dsl.d.ts +9 -1
- package/dist/server/dsl.d.ts.map +1 -1
- package/dist/server/dsl.js +31 -4
- package/dist/server/dsl.js.map +1 -1
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +0 -9
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/logEntry.d.ts.map +1 -1
- package/dist/server/logEntry.js +3 -1
- package/dist/server/logEntry.js.map +1 -1
- package/dist/server/methods/log_read.d.ts.map +1 -1
- package/dist/server/methods/log_read.js +7 -1
- package/dist/server/methods/log_read.js.map +1 -1
- package/dist/server/methods/log_read.sql +17 -7
- package/dist/server/methods/op_look.d.ts +5 -0
- package/dist/server/methods/op_look.d.ts.map +1 -0
- package/dist/server/methods/op_look.js +30 -0
- package/dist/server/methods/op_look.js.map +1 -0
- package/dist/server/methods/op_parse.d.ts.map +1 -1
- package/dist/server/methods/op_parse.js +7 -2
- package/dist/server/methods/op_parse.js.map +1 -1
- package/dist/server/methods/session_constraints.js +1 -1
- package/dist/server/methods/session_constraints.js.map +1 -1
- package/dist/server/methods/session_create.d.ts.map +1 -1
- package/dist/server/methods/session_create.js +7 -14
- package/dist/server/methods/session_create.js.map +1 -1
- package/docs/run.md +3 -1
- package/migrations/0000-00-00.01_schema.sql +21 -1
- package/package.json +10 -10
- package/requirements.md +4 -0
package/dist/core/Engine.js
CHANGED
|
@@ -9,7 +9,7 @@ import GitState from "./git-state.js";
|
|
|
9
9
|
import Fork from "./fork.js";
|
|
10
10
|
import RunCap from "./run-cap.js";
|
|
11
11
|
import { teachingLine, docsExcludeSet } from "./teaching.js";
|
|
12
|
-
import { readPacketInject } from "./packet-inject.js";
|
|
12
|
+
import { readPacketInject, readSystemPolicy, readProjectPolicy } from "./packet-inject.js";
|
|
13
13
|
import SessionSettings from "./session-settings.js";
|
|
14
14
|
import { decodePathParens } from "./path-decode.js";
|
|
15
15
|
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
@@ -64,14 +64,14 @@ const readMaxCommands = () => {
|
|
|
64
64
|
return DEFAULT_MAX_COMMANDS;
|
|
65
65
|
return n;
|
|
66
66
|
};
|
|
67
|
-
//
|
|
67
|
+
// PLURNK_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
|
|
68
68
|
// -1 = the full manifest; positive N = the first N items. 0 / unset = off.
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const raw = process.env.
|
|
69
|
+
const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
|
|
70
|
+
const readFilesItems = () => {
|
|
71
|
+
const raw = process.env.PLURNK_FILES_ITEMS;
|
|
72
72
|
if (raw === undefined || raw.length === 0)
|
|
73
73
|
return null;
|
|
74
|
-
return
|
|
74
|
+
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
75
75
|
};
|
|
76
76
|
import { ProviderError } from "@plurnk/plurnk-providers";
|
|
77
77
|
// Resolution timeout — proposed entries auto-cancel if nothing arrives
|
|
@@ -315,6 +315,11 @@ class Engine {
|
|
|
315
315
|
// #263 — the last turn's prompt tokens = current window occupancy (gauge numerator), NOT the
|
|
316
316
|
// summed promptTokens above, which overcounts a context that grows across turns.
|
|
317
317
|
contextTokens: row?.context ?? 0,
|
|
318
|
+
// #274 — the last turn's model window (denominator); null when the provider reports none.
|
|
319
|
+
contextSize: row?.context_size ?? null,
|
|
320
|
+
// #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
|
|
321
|
+
// provider returned no meta. The service forwards it; it never reads a field within.
|
|
322
|
+
meta: JSON.parse(row?.meta ?? "{}"),
|
|
318
323
|
};
|
|
319
324
|
}
|
|
320
325
|
#pushTelemetry(sessionId, loopId, event) {
|
|
@@ -336,26 +341,25 @@ class Engine {
|
|
|
336
341
|
this.#telemetryBuffer.delete(loopId);
|
|
337
342
|
return buf;
|
|
338
343
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
|
|
344
|
+
// A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
|
|
345
|
+
// model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
|
|
346
|
+
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
347
|
+
// clamping out-of-range offsets to the content's end.
|
|
348
|
+
#offsetToLineColumn(content, offset) {
|
|
349
|
+
const cps = Array.from(content);
|
|
350
|
+
const clamped = Math.max(0, Math.min(offset, cps.length));
|
|
351
|
+
let line = 1;
|
|
352
|
+
let column = 0;
|
|
353
|
+
for (let i = 0; i < clamped; i++) {
|
|
354
|
+
if (cps[i] === "\n") {
|
|
355
|
+
line++;
|
|
356
|
+
column = 0;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
column++;
|
|
360
|
+
}
|
|
357
361
|
}
|
|
358
|
-
return
|
|
362
|
+
return { line, column };
|
|
359
363
|
}
|
|
360
364
|
async runLoop({ provider, messages, requirements = "", 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, }) {
|
|
361
365
|
const turnIds = [];
|
|
@@ -525,7 +529,14 @@ class Engine {
|
|
|
525
529
|
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
526
530
|
// otherwise.
|
|
527
531
|
let nextActionIndex = 1;
|
|
532
|
+
// §model-entry — the run's first turn opens with the model's own turn-0, mirrored OPEN: a
|
|
533
|
+
// worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
|
|
534
|
+
// from the real ops below (not a static print — we lean into the genuine echo paradigm) and
|
|
535
|
+
// written at sequence 1, so it reads first as the emission with the foisted results following.
|
|
536
|
+
const turnZeroMoves = [];
|
|
528
537
|
if (seq === 1) {
|
|
538
|
+
if (runFirstLoop)
|
|
539
|
+
nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
|
|
529
540
|
// Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
|
|
530
541
|
// as plurnk:///<entry> entries by the plurnk run (loop_run, via the
|
|
531
542
|
// §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
|
|
@@ -604,16 +615,16 @@ class Engine {
|
|
|
604
615
|
const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
|
|
605
616
|
await this.#logFsFictions(sessionId, fsDivergences);
|
|
606
617
|
await EntryManifest.maintainDerivations(systemCtx);
|
|
607
|
-
// Turn-0 catalog preview (
|
|
618
|
+
// Turn-0 catalog preview (PLURNK_FILES_ITEMS, §actor-boundary-manifest-preview):
|
|
608
619
|
// one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
|
|
609
620
|
// model turn so it opens with its catalog (the per-scheme arrays that replaced the
|
|
610
621
|
// single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
|
|
611
622
|
// (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
|
|
612
623
|
if (seq === 1) {
|
|
613
|
-
// #231 — a session's client-chosen
|
|
614
|
-
const {
|
|
615
|
-
const
|
|
616
|
-
if (
|
|
624
|
+
// #231 — a session's client-chosen filesItems REPLACES the env default outright.
|
|
625
|
+
const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
|
|
626
|
+
const filesItems = sessionMI !== null ? normalizeFilesItems(sessionMI) : readFilesItems();
|
|
627
|
+
if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
|
|
617
628
|
// engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
|
|
618
629
|
// one row per scheme that has entries (scheme=null → file). log:// is absent —
|
|
619
630
|
// it lives in log_entries, not the catalog (present-mode, the # Log section).
|
|
@@ -633,9 +644,12 @@ class Engine {
|
|
|
633
644
|
// documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
|
|
634
645
|
// the model orients on IS the docs; doc links are no longer rendered inline (#270).
|
|
635
646
|
const isPlurnk = schemeName === "plurnk";
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
647
|
+
// Only the FILE list is cappable (PLURNK_FILES_ITEMS first-N): the tracked-file
|
|
648
|
+
// tree is external and arbitrarily large. Every other scheme — known/unknown
|
|
649
|
+
// (memory), run (scratch), plurnk (docs) — foists FULL, never truncated: a partial
|
|
650
|
+
// view of the model's own memory reads as withheld. file at -1, or any non-file
|
|
651
|
+
// scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
|
|
652
|
+
const cap = schemeName === "file" && filesItems > 0 ? Math.min(filesItems, entries) : null;
|
|
639
653
|
const catalogFind = {
|
|
640
654
|
op: "FIND", suffix: "", signal: null,
|
|
641
655
|
target: {
|
|
@@ -655,33 +669,25 @@ class Engine {
|
|
|
655
669
|
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
656
670
|
});
|
|
657
671
|
nextActionIndex++;
|
|
672
|
+
// §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
|
|
673
|
+
// own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
|
|
674
|
+
turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
|
|
658
675
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
continue; // absent / non-git session → not a member → skip
|
|
671
|
-
const agentsRead = {
|
|
672
|
-
op: "READ", suffix: "", signal: null, lineMarker: null,
|
|
673
|
-
target: {
|
|
674
|
-
kind: "url", raw: `file://${pathname}`, scheme: "file",
|
|
675
|
-
username: null, password: null, hostname: null, port: null,
|
|
676
|
-
pathname, params: {}, fragment: null,
|
|
677
|
-
},
|
|
678
|
-
body: null, position: { line: 1, column: 1 },
|
|
676
|
+
// §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
|
|
677
|
+
// building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
|
|
678
|
+
// manage its private workspace) so it's catalogued in ITS perspective alone; other
|
|
679
|
+
// runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
|
|
680
|
+
const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
|
|
681
|
+
const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
|
|
682
|
+
if (scratch > 0) {
|
|
683
|
+
const runFind = {
|
|
684
|
+
op: "FIND", suffix: "", signal: null,
|
|
685
|
+
target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
|
|
686
|
+
body: null, lineMarker: null, position: { line: 1, column: 1 },
|
|
679
687
|
};
|
|
680
|
-
await this.dispatch({
|
|
681
|
-
statement: agentsRead, sessionId, runId, loopId, turnId,
|
|
682
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
683
|
-
});
|
|
688
|
+
await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
684
689
|
nextActionIndex++;
|
|
690
|
+
turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
|
|
685
691
|
}
|
|
686
692
|
}
|
|
687
693
|
// #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
|
|
@@ -705,12 +711,24 @@ class Engine {
|
|
|
705
711
|
});
|
|
706
712
|
nextActionIndex++;
|
|
707
713
|
}
|
|
714
|
+
// §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
|
|
715
|
+
// foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
|
|
716
|
+
// the true survey, never a frozen print — and OPEN: the worked example the model orients on,
|
|
717
|
+
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
718
|
+
if (runFirstLoop) {
|
|
719
|
+
const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
|
|
720
|
+
await this.#writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
721
|
+
}
|
|
708
722
|
}
|
|
709
723
|
// §environment-observation — pre-seed the run's ambient observations (what changed since
|
|
710
724
|
// it last looked) as foisted rows before the packet composes; advance the action index
|
|
711
725
|
// past them so model ops continue after. Two instances of one machine: env-delta (sibling
|
|
712
726
|
// edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
|
|
713
727
|
// terminal delta opens). §env-delta §exec-stream
|
|
728
|
+
// §exec-poll — EXEC `<0>` is turn-scoped: reap the run's open turn-scoped streams (necessarily
|
|
729
|
+
// from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
|
|
730
|
+
// the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
|
|
731
|
+
await this.#reapTurnScopedStreams(runId);
|
|
714
732
|
nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
715
733
|
nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
716
734
|
// SPEC §telemetry — git working-tree state for the telemetry section, read once
|
|
@@ -740,17 +758,19 @@ class Engine {
|
|
|
740
758
|
await this.#db.engine_close_turn.run({
|
|
741
759
|
id: turnId, status: 413, packet: JSON.stringify(hardPacket),
|
|
742
760
|
usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
|
|
743
|
-
|
|
761
|
+
usage_context_size: provider.contextSize, // #274 — the model's window, even on a hard-413 turn
|
|
762
|
+
finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
|
|
744
763
|
});
|
|
745
764
|
return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
|
|
746
765
|
}
|
|
747
766
|
const modelMessages = this.#packetToWireMessages(requestPacket);
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
767
|
+
// No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
|
|
768
|
+
// the input under the ceiling); the model's decode — reasoning + emission — is
|
|
769
|
+
// out of band, owned by the provider's own context window. Deriving a maxTokens
|
|
770
|
+
// from our budget conflated the two and guillotined a reasoning model's
|
|
771
|
+
// out-of-band thinking as the packet filled (`ceiling - packet` → near-zero
|
|
772
|
+
// decode → finish=length mid-reasoning → no emission → strike spiral). The
|
|
773
|
+
// provider enforces its physical wall on its own.
|
|
754
774
|
let response;
|
|
755
775
|
// #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
|
|
756
776
|
// active-plugin set (placeholder); real per-turn grounding is deferred.
|
|
@@ -761,7 +781,7 @@ class Engine {
|
|
|
761
781
|
// #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
|
|
762
782
|
const { client } = await SessionSettings.read(this.#db, sessionId);
|
|
763
783
|
try {
|
|
764
|
-
response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(),
|
|
784
|
+
response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
|
|
765
785
|
}
|
|
766
786
|
catch (err) {
|
|
767
787
|
// Every provider error surfaces as telemetry (the client/model sees the cause). #256:
|
|
@@ -770,8 +790,11 @@ class Engine {
|
|
|
770
790
|
// accepted: fall through as an empty no-op turn so the strike rail retries. Every other
|
|
771
791
|
// kind (rate_limit, network_failure, unauthorized, …) is terminal — telemetry'd, then
|
|
772
792
|
// propagated to end the loop (rather than only the opaque loop.run rejection).
|
|
793
|
+
// NOTE (providers 0.19.0 / #275): only the CONSTRAINED path still throws grammar_unenforced.
|
|
794
|
+
// In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
|
|
795
|
+
// event instead — recovered on the success path below (response.telemetry), no empty turn.
|
|
773
796
|
if (err instanceof ProviderError) {
|
|
774
|
-
this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message });
|
|
797
|
+
this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
|
|
775
798
|
if (err.kind !== "grammar_unenforced")
|
|
776
799
|
throw err;
|
|
777
800
|
response = {
|
|
@@ -801,14 +824,31 @@ class Engine {
|
|
|
801
824
|
// the snippet, the model sees "invalid xpath at 1:0" but can't
|
|
802
825
|
// connect that to what IT wrote — and tends to regenerate the
|
|
803
826
|
// same broken emission. See edit-todo demo for the canonical case.
|
|
804
|
-
|
|
827
|
+
// Parse errors are LOG ITEMS now (§telemetry — one budget surface): each failed-to-parse
|
|
828
|
+
// emission records an actionless `error` row below, after the turn's dispatched ops are
|
|
829
|
+
// sequenced (see the parse-error log write past the dispatch loop). The errors section
|
|
830
|
+
// derives a pointer to it from log≥400, uniform with action_failure.
|
|
831
|
+
// providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
|
|
832
|
+
// mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
|
|
833
|
+
// (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
|
|
834
|
+
// the divergence code-point position. Forward each event with a content-offset `line:col`;
|
|
835
|
+
// the model resolves it against its own emission — the born-OPEN `model` row (§model-entry),
|
|
836
|
+
// not an embedded snippet that would duplicate the emission.
|
|
837
|
+
let hadContentOffsetNotice = false;
|
|
838
|
+
for (const event of response.telemetry ?? []) {
|
|
839
|
+
const located = typeof event.position === "number"
|
|
840
|
+
? this.#offsetToLineColumn(packetAssistant.content, event.position)
|
|
841
|
+
: null;
|
|
842
|
+
if (located !== null)
|
|
843
|
+
hadContentOffsetNotice = true;
|
|
805
844
|
this.#pushTelemetry(sessionId, loopId, {
|
|
806
|
-
source:
|
|
807
|
-
kind:
|
|
808
|
-
message,
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
845
|
+
source: event.source,
|
|
846
|
+
kind: event.kind,
|
|
847
|
+
message: event.message ?? "",
|
|
848
|
+
level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
|
|
849
|
+
...(located !== null
|
|
850
|
+
? { position: { type: "content-offset", line: located.line, column: located.column } }
|
|
851
|
+
: {}),
|
|
812
852
|
});
|
|
813
853
|
}
|
|
814
854
|
const opsCount = packetAssistant.ops.length;
|
|
@@ -822,19 +862,23 @@ class Engine {
|
|
|
822
862
|
// §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
|
|
823
863
|
// count, and a non-resolver spins out to the engine's 500.
|
|
824
864
|
let steerStruck = false;
|
|
825
|
-
// Premature terminate: a SEND[200] while the run still holds a live
|
|
826
|
-
//
|
|
827
|
-
//
|
|
865
|
+
// Premature terminate: a SEND[200] while the run still holds a live thing — an open stream/spawn
|
|
866
|
+
// OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
|
|
867
|
+
// thing a run holds"). The model declared done with work running. Downgrade the 200 to 102 so it
|
|
868
|
+
// dispatches as a continue (its body is preserved, not discarded) and steer; the stream's/child's
|
|
869
|
+
// own conclusion (the wake edge) or a KILL is the exit.
|
|
828
870
|
if (sendOp?.signal === 200) {
|
|
829
871
|
const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
|
|
830
872
|
const execHandler = this.#schemes.get("exec");
|
|
831
|
-
|
|
873
|
+
const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
|
|
874
|
+
if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true || liveChild !== undefined) {
|
|
832
875
|
sendOp.signal = TURN_STATUS_IMPLICIT_CONTINUE; // 102 — downgraded, no longer a terminal
|
|
833
876
|
steerStruck = true;
|
|
834
877
|
this.#pushTelemetry(sessionId, loopId, {
|
|
835
878
|
source: "engine:rail",
|
|
836
879
|
kind: "premature_terminate",
|
|
837
|
-
message: "Attempted termination with active streams. Terminate with 202 to hibernate until
|
|
880
|
+
message: "Attempted termination with active streams or child runs. Terminate with 202 to hibernate until they complete, KILL(path) with 200 again to clean up, or 499 to fail.",
|
|
881
|
+
level: "warn",
|
|
838
882
|
});
|
|
839
883
|
}
|
|
840
884
|
}
|
|
@@ -852,6 +896,7 @@ class Engine {
|
|
|
852
896
|
source: "engine:rail",
|
|
853
897
|
kind: "idle_turn",
|
|
854
898
|
message: "If the turn's work is complete, terminate with 200. If awaiting a stream or run trigger, terminate with 202 to hibernate.",
|
|
899
|
+
level: "warn",
|
|
855
900
|
});
|
|
856
901
|
}
|
|
857
902
|
// Close the turn with the final packet, status, and usage stats.
|
|
@@ -865,8 +910,12 @@ class Engine {
|
|
|
865
910
|
usage_completion: usage.completion,
|
|
866
911
|
usage_cached: usage.cached,
|
|
867
912
|
usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
|
|
913
|
+
usage_context_size: provider.contextSize, // #274 — the running model's window (gauge denominator)
|
|
868
914
|
finish_reason: finishReason,
|
|
869
915
|
model,
|
|
916
|
+
// #252 — opaque provider→client metadata passthrough (e.g. balancePico the
|
|
917
|
+
// provider normalized). Stored verbatim, unenforced; the service never reads a field.
|
|
918
|
+
meta: JSON.stringify(response.meta ?? {}),
|
|
870
919
|
});
|
|
871
920
|
// Dispatch model ops starting at nextActionIndex (continues the
|
|
872
921
|
// turn's running counter after any pre-model writes).
|
|
@@ -890,13 +939,18 @@ class Engine {
|
|
|
890
939
|
|| realCommands++ < maxCommands);
|
|
891
940
|
const droppedCount = opsCount - opsToDispatch.length;
|
|
892
941
|
const statuses = [];
|
|
893
|
-
|
|
942
|
+
// Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
|
|
943
|
+
// so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
|
|
944
|
+
// every op writes one row (the common case).
|
|
945
|
+
let rowSeq = nextActionIndex;
|
|
946
|
+
for (const statement of opsToDispatch) {
|
|
894
947
|
const result = await this.dispatch({
|
|
895
948
|
statement, sessionId, runId, loopId, turnId,
|
|
896
|
-
sequence:
|
|
949
|
+
sequence: rowSeq,
|
|
897
950
|
origin, onDispatch,
|
|
898
951
|
});
|
|
899
952
|
statuses.push(result.status);
|
|
953
|
+
rowSeq += result.rowsWritten ?? 1;
|
|
900
954
|
}
|
|
901
955
|
// max_commands_exceeded IS model-facing: dropped ops are things
|
|
902
956
|
// the model emitted that didn't run — it needs to know. Engine
|
|
@@ -908,8 +962,39 @@ class Engine {
|
|
|
908
962
|
kind: "max_commands_exceeded",
|
|
909
963
|
emitted: opsCount,
|
|
910
964
|
dropped: droppedCount,
|
|
965
|
+
level: "error",
|
|
911
966
|
});
|
|
912
967
|
}
|
|
968
|
+
// §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
|
|
969
|
+
// `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
|
|
970
|
+
// sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
|
|
971
|
+
// and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
|
|
972
|
+
let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
|
|
973
|
+
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
974
|
+
await this.#db.engine_insert_log_entry.get({
|
|
975
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
976
|
+
origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
|
|
977
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
978
|
+
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
979
|
+
tx: "", mimetype_tx: "text/plain",
|
|
980
|
+
// The error carries the parser message + a content-offset `line:col`; the model resolves
|
|
981
|
+
// it against its own born-OPEN emission (the `model` row, §model-entry), so no snippet is
|
|
982
|
+
// embedded. The derived errors-section pointer stays minimal (status + coordinate).
|
|
983
|
+
rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
|
|
984
|
+
mimetype_rx: "application/json",
|
|
985
|
+
status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
// §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
|
|
989
|
+
// packet shows the model exactly what it last produced. Born OPEN when the turn carried an
|
|
990
|
+
// emission-level error the model must resolve against its own bytes — a parse error or a
|
|
991
|
+
// content-offset NOTICE (grammar_unenforced) — both report a line the model reads off this
|
|
992
|
+
// row. Folded otherwise (budget-neutral until the model OPENs it). Empty emissions (a
|
|
993
|
+
// struck/silent turn) write nothing — no prior output to mirror.
|
|
994
|
+
if (packetAssistant.content.trim().length > 0) {
|
|
995
|
+
const hadEmissionError = (parseErrors?.length ?? 0) > 0 || hadContentOffsetNotice;
|
|
996
|
+
await this.#writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
|
|
997
|
+
}
|
|
913
998
|
// Zero ops is NOT an error to report — the model knows it emitted
|
|
914
999
|
// nothing. Strike accounting (engine-internal) treats it as a
|
|
915
1000
|
// struck turn; the model just sees an empty packet next turn.
|
|
@@ -1032,15 +1117,23 @@ class Engine {
|
|
|
1032
1117
|
// peer sections (unbundled). The budget section carries its {{tokensFree}}
|
|
1033
1118
|
// placeholders here; they resolve below once the assembled total is known.
|
|
1034
1119
|
const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
|
|
1120
|
+
const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
|
|
1121
|
+
const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
|
|
1122
|
+
const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
|
|
1035
1123
|
const defaults = [
|
|
1036
1124
|
{ name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
|
|
1037
1125
|
{ name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
|
|
1038
1126
|
{ name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
|
|
1039
1127
|
...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
|
|
1040
|
-
|
|
1128
|
+
// policy: the client's privileged rules — ~/.plurnk/AGENTS.md (system) then <root>/AGENTS.md (project) — below grammar/tools/schemes, above budget-the-law. AGENTS is POLICY here, never a curatable READable entry. Empty content ⇒ section omitted.
|
|
1129
|
+
{ name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
|
|
1130
|
+
{ name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
|
|
1131
|
+
// budget at the BOTTOM of the SYSTEM slot — budget is LAW (the ceiling is a hard constraint the model must obey), so it belongs in the lean privileged zone, not as user-slot status.
|
|
1132
|
+
{ name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
|
|
1041
1133
|
{ name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
|
|
1042
|
-
{ name: "budget", slot: "user", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
|
|
1043
1134
|
{ name: "errors", slot: "user", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
|
|
1135
|
+
// log in the USER slot, low: short-term memory (data, not rules) at the action point, so the model consults its history instead of skimming it. git follows it as workspace status — not law, so it stays in user, never system.
|
|
1136
|
+
{ name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
|
|
1044
1137
|
{ name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
|
|
1045
1138
|
{ name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
|
|
1046
1139
|
];
|
|
@@ -1187,6 +1280,7 @@ class Engine {
|
|
|
1187
1280
|
source: "engine:rail",
|
|
1188
1281
|
kind: "budget_overflow",
|
|
1189
1282
|
folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
|
|
1283
|
+
level: "warn",
|
|
1190
1284
|
});
|
|
1191
1285
|
}
|
|
1192
1286
|
// Wire projection lives in ./packet-wire.ts so Engine and
|
|
@@ -1227,6 +1321,7 @@ class Engine {
|
|
|
1227
1321
|
const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
|
|
1228
1322
|
return {
|
|
1229
1323
|
kind: "action_failure",
|
|
1324
|
+
level: "error", // the derived error pointer is always an error — clients color off level (#276)
|
|
1230
1325
|
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
1231
1326
|
op: r.op,
|
|
1232
1327
|
target,
|
|
@@ -1314,6 +1409,18 @@ class Engine {
|
|
|
1314
1409
|
}
|
|
1315
1410
|
return written;
|
|
1316
1411
|
}
|
|
1412
|
+
// §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
|
|
1413
|
+
// owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
|
|
1414
|
+
// before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
|
|
1415
|
+
// never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
|
|
1416
|
+
// terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
|
|
1417
|
+
async #reapTurnScopedStreams(runId) {
|
|
1418
|
+
const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
|
|
1419
|
+
for (const { id, scheme } of open) {
|
|
1420
|
+
const handler = this.#schemes.get(scheme);
|
|
1421
|
+
handler?.abortSubscription?.(id);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1317
1424
|
// §environment-observation — exec streams as an instance of the ambient-observe machine:
|
|
1318
1425
|
// each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
|
|
1319
1426
|
// while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
|
|
@@ -1382,19 +1489,7 @@ class Engine {
|
|
|
1382
1489
|
}
|
|
1383
1490
|
async dispatch(context) {
|
|
1384
1491
|
const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
|
|
1385
|
-
const schemeCtx = {
|
|
1386
|
-
db: this.#db,
|
|
1387
|
-
sessionId, runId, loopId, turnId,
|
|
1388
|
-
writer: origin,
|
|
1389
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1390
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1391
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1392
|
-
injectRun: this.#injectRun,
|
|
1393
|
-
mimetypes: this.#mimetypes,
|
|
1394
|
-
tokenize: this.#tokenize,
|
|
1395
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1396
|
-
executors: this.#executors,
|
|
1397
|
-
};
|
|
1492
|
+
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
|
|
1398
1493
|
let result;
|
|
1399
1494
|
let denial = this.#checkWritable(statement, origin);
|
|
1400
1495
|
if (denial === null)
|
|
@@ -1402,6 +1497,12 @@ class Engine {
|
|
|
1402
1497
|
if (denial !== null) {
|
|
1403
1498
|
result = denial;
|
|
1404
1499
|
}
|
|
1500
|
+
else if (_a.#readFansOut(statement)) {
|
|
1501
|
+
// READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
|
|
1502
|
+
// (its own writeLogs), returning early. A READ never proposes, so it bypasses the
|
|
1503
|
+
// single-row path below. A bare entry, body-less, falls through to the direct read. #286
|
|
1504
|
+
return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
|
|
1505
|
+
}
|
|
1405
1506
|
else {
|
|
1406
1507
|
// SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
|
|
1407
1508
|
// exceptions become the action-entry's outcome (status 500), not a
|
|
@@ -1510,6 +1611,38 @@ class Engine {
|
|
|
1510
1611
|
}
|
|
1511
1612
|
return result;
|
|
1512
1613
|
}
|
|
1614
|
+
// op.look (#283) — resolve a READ and return its content WITHOUT writing a
|
|
1615
|
+
// log_entries row: the client's off-run inspection primitive (LOOK → READ,
|
|
1616
|
+
// invisible to the model). READ never mutates and never proposes, so this is
|
|
1617
|
+
// dispatch's resolve path minus #writeLog. Runs on the client loop, so the
|
|
1618
|
+
// human's inspection is never constrained by a model loop's flags. {§op-look}
|
|
1619
|
+
async look(context) {
|
|
1620
|
+
const { statement, sessionId, runId, loopId, origin = "client" } = context;
|
|
1621
|
+
if (statement.op !== "READ")
|
|
1622
|
+
throw new Error(`look resolves READ only; got ${statement.op}`);
|
|
1623
|
+
// turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
|
|
1624
|
+
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
|
|
1625
|
+
const denial = await this.#checkFlagsGate(statement, loopId);
|
|
1626
|
+
if (denial !== null)
|
|
1627
|
+
return denial;
|
|
1628
|
+
return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
1629
|
+
}
|
|
1630
|
+
#buildSchemeCtx(ids) {
|
|
1631
|
+
const { sessionId, runId, loopId, turnId, origin } = ids;
|
|
1632
|
+
return {
|
|
1633
|
+
db: this.#db,
|
|
1634
|
+
sessionId, runId, loopId, turnId,
|
|
1635
|
+
writer: origin,
|
|
1636
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1637
|
+
streamEventNotify: this.#streamEventNotify,
|
|
1638
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
1639
|
+
injectRun: this.#injectRun,
|
|
1640
|
+
mimetypes: this.#mimetypes,
|
|
1641
|
+
tokenize: this.#tokenize,
|
|
1642
|
+
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1643
|
+
executors: this.#executors,
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1513
1646
|
// On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
|
|
1514
1647
|
async #runApplyResolution(statement, originalResult, resolution, ids) {
|
|
1515
1648
|
const { sessionId, runId, loopId, turnId } = ids;
|
|
@@ -1800,17 +1933,19 @@ class Engine {
|
|
|
1800
1933
|
#isRunFork(statement) {
|
|
1801
1934
|
return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
|
|
1802
1935
|
}
|
|
1803
|
-
// COPY(run
|
|
1804
|
-
// run (Fork), then start it with the prompt (ctx.injectRun). Source
|
|
1805
|
-
// self (ctx.runId); a name resolves within the session (404 if absent).
|
|
1936
|
+
// COPY(run://<src>):prompt — fork: deep-copy the source run's log into a new
|
|
1937
|
+
// run (Fork), then start it with the prompt (ctx.injectRun). Source `run://self` =
|
|
1938
|
+
// self (ctx.runId); a name resolves within the session (404 if absent). §run-scheme,
|
|
1806
1939
|
// §machine-processes-fork-copies-the-log
|
|
1807
1940
|
async #handleRunFork(statement, ctx) {
|
|
1808
1941
|
const target = statement.target;
|
|
1809
1942
|
if (target === null)
|
|
1810
1943
|
return { status: 400, error: "run:// fork requires a source run" };
|
|
1811
1944
|
const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
|
|
1945
|
+
if (name === "")
|
|
1946
|
+
return { status: 400, error: "run:// fork requires a source run name or 'self' (run://self)" };
|
|
1812
1947
|
let srcRunId = ctx.runId;
|
|
1813
|
-
if (name !== ""
|
|
1948
|
+
if (name !== "self") {
|
|
1814
1949
|
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
1815
1950
|
if (row === undefined)
|
|
1816
1951
|
return { status: 404, error: `run://${name} not found in this session` };
|
|
@@ -1894,8 +2029,9 @@ class Engine {
|
|
|
1894
2029
|
const schemeName = this.#schemeNameOf(path);
|
|
1895
2030
|
if (schemeName === null)
|
|
1896
2031
|
return { status: 400, error: "KILL target must be a URL path with a scheme" };
|
|
1897
|
-
|
|
1898
|
-
|
|
2032
|
+
// KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
|
|
2033
|
+
// (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
|
|
2034
|
+
// "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
|
|
1899
2035
|
// Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
|
|
1900
2036
|
// exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
|
|
1901
2037
|
// addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
|
|
@@ -1904,12 +2040,22 @@ class Engine {
|
|
|
1904
2040
|
return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
|
|
1905
2041
|
}
|
|
1906
2042
|
if (schemeName === "run") {
|
|
2043
|
+
// Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
|
|
2044
|
+
// NOT run cancellation. The authority (hostname) names the owner, the pathname the
|
|
2045
|
+
// entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
|
|
2046
|
+
const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
|
|
2047
|
+
if (entryPath !== "" && entryPath !== "/") {
|
|
2048
|
+
const runHandler = this.#schemes.get("run");
|
|
2049
|
+
return await runHandler.deleteEntry(statement, ctx);
|
|
2050
|
+
}
|
|
1907
2051
|
// terminate — abort any run by address; whoever holds it may end it.
|
|
1908
|
-
//
|
|
2052
|
+
// `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
|
|
1909
2053
|
// (its loop closes 499); an idle run is a no-op-200, a missing run 404.
|
|
1910
2054
|
const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
|
|
2055
|
+
if (name === "")
|
|
2056
|
+
return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
|
|
1911
2057
|
let runId = ctx.runId;
|
|
1912
|
-
if (name !== ""
|
|
2058
|
+
if (name !== "self") {
|
|
1913
2059
|
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
1914
2060
|
if (row === undefined)
|
|
1915
2061
|
return { status: 404, error: `run://${name} not found in this session` };
|
|
@@ -1926,6 +2072,101 @@ class Engine {
|
|
|
1926
2072
|
const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
|
|
1927
2073
|
return { status: delResult.status };
|
|
1928
2074
|
}
|
|
2075
|
+
// Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
|
|
2076
|
+
// READ target resolves to MANY files; READ returns one log row per file that matches, each
|
|
2077
|
+
// holding that file's matching lines. The matched SET is exactly FIND's survey (which files
|
|
2078
|
+
// + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
|
|
2079
|
+
// model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
|
|
2080
|
+
// on its own. The running sequence counter in runTurn advances by rowsWritten.
|
|
2081
|
+
// A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
|
|
2082
|
+
// or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
|
|
2083
|
+
// A bare entry, body-less, is the one direct read. #286
|
|
2084
|
+
static #readFansOut(statement) {
|
|
2085
|
+
if (statement.op !== "READ")
|
|
2086
|
+
return false;
|
|
2087
|
+
if ("body" in statement && statement.body !== null)
|
|
2088
|
+
return true; // a matcher → per-match fan-out
|
|
2089
|
+
const t = statement.target;
|
|
2090
|
+
const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
|
|
2091
|
+
return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
|
|
2092
|
+
}
|
|
2093
|
+
// Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
|
|
2094
|
+
// delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
|
|
2095
|
+
// span (body-less folder/glob fan-out) reads the whole entry. #286
|
|
2096
|
+
static #retargetRead(statement, pathname, span) {
|
|
2097
|
+
const t = statement.target;
|
|
2098
|
+
const target = t !== null && t.kind === "url"
|
|
2099
|
+
? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
|
|
2100
|
+
: { ...t, raw: pathname };
|
|
2101
|
+
const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
|
|
2102
|
+
return { ...statement, target: target, lineMarker, body: null };
|
|
2103
|
+
}
|
|
2104
|
+
async #handleReadFanout(statement, ctx, ids) {
|
|
2105
|
+
const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
|
|
2106
|
+
const schemeName = this.#schemeNameOf(statement.target);
|
|
2107
|
+
const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
|
|
2108
|
+
const matches = found.matches ?? [];
|
|
2109
|
+
// Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
|
|
2110
|
+
// status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
|
|
2111
|
+
if (found.status !== 200 || matches.length === 0) {
|
|
2112
|
+
const result = { status: found.status === 200 ? 204 : found.status };
|
|
2113
|
+
const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
2114
|
+
onDispatch?.(id);
|
|
2115
|
+
return { ...result, rowsWritten: 1 };
|
|
2116
|
+
}
|
|
2117
|
+
// One READ row per MATCH — the span's source lines (or the whole entry for a body-less
|
|
2118
|
+
// folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
|
|
2119
|
+
// scheme's <L> (which is item-index for application/json, structural for xml). Read each
|
|
2120
|
+
// distinct entry's content once, then line-slice per match. #286
|
|
2121
|
+
const wholeByPath = new Map();
|
|
2122
|
+
const fannedStatuses = [];
|
|
2123
|
+
let written = 0;
|
|
2124
|
+
for (const m of matches) {
|
|
2125
|
+
let whole = wholeByPath.get(m.pathname);
|
|
2126
|
+
if (whole === undefined) {
|
|
2127
|
+
whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
|
|
2128
|
+
wholeByPath.set(m.pathname, whole);
|
|
2129
|
+
}
|
|
2130
|
+
const result = _a.#sliceMatch(whole, m.span);
|
|
2131
|
+
const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
|
|
2132
|
+
onDispatch?.(id);
|
|
2133
|
+
fannedStatuses.push(result.status);
|
|
2134
|
+
written++;
|
|
2135
|
+
}
|
|
2136
|
+
return { status: 200, rowsWritten: written, fannedStatuses };
|
|
2137
|
+
}
|
|
2138
|
+
// Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
|
|
2139
|
+
// a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
|
|
2140
|
+
// that is, by construction, source line numbers (#286).
|
|
2141
|
+
static #sliceMatch(whole, span) {
|
|
2142
|
+
if (whole.status !== 200 || span === null)
|
|
2143
|
+
return whole;
|
|
2144
|
+
const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
|
|
2145
|
+
if (sliced.status !== 200)
|
|
2146
|
+
return { status: sliced.status, error: sliced.error };
|
|
2147
|
+
return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
|
|
2148
|
+
}
|
|
2149
|
+
// §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
|
|
2150
|
+
// the model can finally SEE its own prior output (and reason through its own syntax errors).
|
|
2151
|
+
// Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
|
|
2152
|
+
// (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
|
|
2153
|
+
async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
|
|
2154
|
+
const row = await this.#db.engine_insert_log_entry.get({
|
|
2155
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
|
|
2156
|
+
origin, source: null, op: "model", suffix: "", signal: null,
|
|
2157
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
2158
|
+
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
2159
|
+
tx: "", mimetype_tx: "text/vnd.plurnk",
|
|
2160
|
+
rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
|
|
2161
|
+
mimetype_rx: "application/json",
|
|
2162
|
+
status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
|
|
2163
|
+
});
|
|
2164
|
+
if (row === undefined)
|
|
2165
|
+
throw new Error("Engine.#writeModelEntry: insert returned no row");
|
|
2166
|
+
if (folded)
|
|
2167
|
+
await this.#db.engine_fold_log_entry.run({ id: row.id });
|
|
2168
|
+
return row.id;
|
|
2169
|
+
}
|
|
1929
2170
|
// PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
|
|
1930
2171
|
// other, logged, and broadcast to the client as a log entry — but a pure no-op for
|
|
1931
2172
|
// state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
|
|
@@ -2159,7 +2400,7 @@ class Engine {
|
|
|
2159
2400
|
// it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
|
|
2160
2401
|
// the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
|
|
2161
2402
|
// namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
|
|
2162
|
-
// run selector (§run-scheme), and run
|
|
2403
|
+
// run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
|
|
2163
2404
|
// folds the owner into the storage path itself, never here.
|
|
2164
2405
|
const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
|
|
2165
2406
|
return {
|