@plurnk/plurnk-service 0.56.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 -6
- package/SPEC.md +38 -22
- package/dist/content/matcher.d.ts +4 -0
- package/dist/content/matcher.d.ts.map +1 -1
- package/dist/content/matcher.js +22 -14
- package/dist/content/matcher.js.map +1 -1
- package/dist/core/ChannelWrite.d.ts +6 -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 +8 -0
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +246 -70
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +4 -0
- 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/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-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +21 -37
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/session-settings.d.ts +1 -1
- package/dist/core/session-settings.d.ts.map +1 -1
- package/dist/core/session-settings.js +4 -4
- package/dist/core/session-settings.js.map +1 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +11 -3
- 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 +4 -5
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Run.d.ts +5 -1
- package/dist/schemes/Run.d.ts.map +1 -1
- package/dist/schemes/Run.js +36 -12
- 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 +67 -47
- package/dist/schemes/_entry-find.js.map +1 -1
- 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.map +1 -1
- package/dist/schemes/_entry-manifest.js +16 -3
- 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/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +7 -2
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +1 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- 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/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/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.js +7 -7
- package/dist/server/methods/session_create.js.map +1 -1
- package/migrations/0000-00-00.01_schema.sql +11 -1
- package/package.json +5 -5
package/dist/core/Engine.js
CHANGED
|
@@ -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,8 @@ 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,
|
|
318
320
|
// #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
|
|
319
321
|
// provider returned no meta. The service forwards it; it never reads a field within.
|
|
320
322
|
meta: JSON.parse(row?.meta ?? "{}"),
|
|
@@ -339,27 +341,6 @@ class Engine {
|
|
|
339
341
|
this.#telemetryBuffer.delete(loopId);
|
|
340
342
|
return buf;
|
|
341
343
|
}
|
|
342
|
-
// Pull a ±windowLines context block around `targetLine` (1-based) from
|
|
343
|
-
// `content`, formatted with N:\t prefixes — same shape the model
|
|
344
|
-
// already knows from READ output and numbered Index entries. Used to
|
|
345
|
-
// give parse_error telemetry concrete locality: the model sees what
|
|
346
|
-
// it wrote on the offending line, not just an abstract error message.
|
|
347
|
-
//
|
|
348
|
-
// Lenient: targetLine ≤ 0 clamps to 1; targetLine beyond content
|
|
349
|
-
// returns whatever overlap exists; empty content returns "".
|
|
350
|
-
#extractSnippet(content, targetLine, windowLines) {
|
|
351
|
-
if (content.length === 0)
|
|
352
|
-
return "";
|
|
353
|
-
const lines = content.split("\n");
|
|
354
|
-
const target = Math.max(1, targetLine);
|
|
355
|
-
const start = Math.max(1, target - windowLines);
|
|
356
|
-
const end = Math.min(lines.length, target + windowLines);
|
|
357
|
-
const slice = [];
|
|
358
|
-
for (let i = start; i <= end; i++) {
|
|
359
|
-
slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
|
|
360
|
-
}
|
|
361
|
-
return slice.join("\n");
|
|
362
|
-
}
|
|
363
344
|
// A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
|
|
364
345
|
// model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
|
|
365
346
|
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
@@ -548,7 +529,14 @@ class Engine {
|
|
|
548
529
|
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
549
530
|
// otherwise.
|
|
550
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 = [];
|
|
551
537
|
if (seq === 1) {
|
|
538
|
+
if (runFirstLoop)
|
|
539
|
+
nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
|
|
552
540
|
// Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
|
|
553
541
|
// as plurnk:///<entry> entries by the plurnk run (loop_run, via the
|
|
554
542
|
// §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
|
|
@@ -627,16 +615,16 @@ class Engine {
|
|
|
627
615
|
const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
|
|
628
616
|
await this.#logFsFictions(sessionId, fsDivergences);
|
|
629
617
|
await EntryManifest.maintainDerivations(systemCtx);
|
|
630
|
-
// Turn-0 catalog preview (
|
|
618
|
+
// Turn-0 catalog preview (PLURNK_FILES_ITEMS, §actor-boundary-manifest-preview):
|
|
631
619
|
// one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
|
|
632
620
|
// model turn so it opens with its catalog (the per-scheme arrays that replaced the
|
|
633
621
|
// single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
|
|
634
622
|
// (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
|
|
635
623
|
if (seq === 1) {
|
|
636
|
-
// #231 — a session's client-chosen
|
|
637
|
-
const {
|
|
638
|
-
const
|
|
639
|
-
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
|
|
640
628
|
// engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
|
|
641
629
|
// one row per scheme that has entries (scheme=null → file). log:// is absent —
|
|
642
630
|
// it lives in log_entries, not the catalog (present-mode, the # Log section).
|
|
@@ -656,9 +644,12 @@ class Engine {
|
|
|
656
644
|
// documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
|
|
657
645
|
// the model orients on IS the docs; doc links are no longer rendered inline (#270).
|
|
658
646
|
const isPlurnk = schemeName === "plurnk";
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
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;
|
|
662
653
|
const catalogFind = {
|
|
663
654
|
op: "FIND", suffix: "", signal: null,
|
|
664
655
|
target: {
|
|
@@ -678,9 +669,12 @@ class Engine {
|
|
|
678
669
|
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
679
670
|
});
|
|
680
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`);
|
|
681
675
|
}
|
|
682
676
|
// §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
|
|
683
|
-
// building run's OWN scratch (run
|
|
677
|
+
// building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
|
|
684
678
|
// manage its private workspace) so it's catalogued in ITS perspective alone; other
|
|
685
679
|
// runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
|
|
686
680
|
const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
|
|
@@ -688,11 +682,12 @@ class Engine {
|
|
|
688
682
|
if (scratch > 0) {
|
|
689
683
|
const runFind = {
|
|
690
684
|
op: "FIND", suffix: "", signal: null,
|
|
691
|
-
target: { kind: "url", raw: "run
|
|
685
|
+
target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
|
|
692
686
|
body: null, lineMarker: null, position: { line: 1, column: 1 },
|
|
693
687
|
};
|
|
694
688
|
await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
695
689
|
nextActionIndex++;
|
|
690
|
+
turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
|
|
696
691
|
}
|
|
697
692
|
}
|
|
698
693
|
// #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
|
|
@@ -716,12 +711,24 @@ class Engine {
|
|
|
716
711
|
});
|
|
717
712
|
nextActionIndex++;
|
|
718
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
|
+
}
|
|
719
722
|
}
|
|
720
723
|
// §environment-observation — pre-seed the run's ambient observations (what changed since
|
|
721
724
|
// it last looked) as foisted rows before the packet composes; advance the action index
|
|
722
725
|
// past them so model ops continue after. Two instances of one machine: env-delta (sibling
|
|
723
726
|
// edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
|
|
724
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);
|
|
725
732
|
nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
726
733
|
nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
727
734
|
// SPEC §telemetry — git working-tree state for the telemetry section, read once
|
|
@@ -751,6 +758,7 @@ class Engine {
|
|
|
751
758
|
await this.#db.engine_close_turn.run({
|
|
752
759
|
id: turnId, status: 413, packet: JSON.stringify(hardPacket),
|
|
753
760
|
usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
|
|
761
|
+
usage_context_size: provider.contextSize, // #274 — the model's window, even on a hard-413 turn
|
|
754
762
|
finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
|
|
755
763
|
});
|
|
756
764
|
return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
|
|
@@ -786,7 +794,7 @@ class Engine {
|
|
|
786
794
|
// In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
|
|
787
795
|
// event instead — recovered on the success path below (response.telemetry), no empty turn.
|
|
788
796
|
if (err instanceof ProviderError) {
|
|
789
|
-
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" });
|
|
790
798
|
if (err.kind !== "grammar_unenforced")
|
|
791
799
|
throw err;
|
|
792
800
|
response = {
|
|
@@ -823,20 +831,23 @@ class Engine {
|
|
|
823
831
|
// providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
|
|
824
832
|
// mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
|
|
825
833
|
// (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
|
|
826
|
-
// the divergence code-point position.
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
|
|
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;
|
|
830
838
|
for (const event of response.telemetry ?? []) {
|
|
831
839
|
const located = typeof event.position === "number"
|
|
832
840
|
? this.#offsetToLineColumn(packetAssistant.content, event.position)
|
|
833
841
|
: null;
|
|
842
|
+
if (located !== null)
|
|
843
|
+
hadContentOffsetNotice = true;
|
|
834
844
|
this.#pushTelemetry(sessionId, loopId, {
|
|
835
845
|
source: event.source,
|
|
836
846
|
kind: event.kind,
|
|
837
847
|
message: event.message ?? "",
|
|
848
|
+
level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
|
|
838
849
|
...(located !== null
|
|
839
|
-
? { position: { type: "content-offset", line: located.line, column: located.column }
|
|
850
|
+
? { position: { type: "content-offset", line: located.line, column: located.column } }
|
|
840
851
|
: {}),
|
|
841
852
|
});
|
|
842
853
|
}
|
|
@@ -867,6 +878,7 @@ class Engine {
|
|
|
867
878
|
source: "engine:rail",
|
|
868
879
|
kind: "premature_terminate",
|
|
869
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",
|
|
870
882
|
});
|
|
871
883
|
}
|
|
872
884
|
}
|
|
@@ -884,6 +896,7 @@ class Engine {
|
|
|
884
896
|
source: "engine:rail",
|
|
885
897
|
kind: "idle_turn",
|
|
886
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",
|
|
887
900
|
});
|
|
888
901
|
}
|
|
889
902
|
// Close the turn with the final packet, status, and usage stats.
|
|
@@ -897,6 +910,7 @@ class Engine {
|
|
|
897
910
|
usage_completion: usage.completion,
|
|
898
911
|
usage_cached: usage.cached,
|
|
899
912
|
usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
|
|
913
|
+
usage_context_size: provider.contextSize, // #274 — the running model's window (gauge denominator)
|
|
900
914
|
finish_reason: finishReason,
|
|
901
915
|
model,
|
|
902
916
|
// #252 — opaque provider→client metadata passthrough (e.g. balancePico the
|
|
@@ -925,13 +939,18 @@ class Engine {
|
|
|
925
939
|
|| realCommands++ < maxCommands);
|
|
926
940
|
const droppedCount = opsCount - opsToDispatch.length;
|
|
927
941
|
const statuses = [];
|
|
928
|
-
|
|
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) {
|
|
929
947
|
const result = await this.dispatch({
|
|
930
948
|
statement, sessionId, runId, loopId, turnId,
|
|
931
|
-
sequence:
|
|
949
|
+
sequence: rowSeq,
|
|
932
950
|
origin, onDispatch,
|
|
933
951
|
});
|
|
934
952
|
statuses.push(result.status);
|
|
953
|
+
rowSeq += result.rowsWritten ?? 1;
|
|
935
954
|
}
|
|
936
955
|
// max_commands_exceeded IS model-facing: dropped ops are things
|
|
937
956
|
// the model emitted that didn't run — it needs to know. Engine
|
|
@@ -943,29 +962,39 @@ class Engine {
|
|
|
943
962
|
kind: "max_commands_exceeded",
|
|
944
963
|
emitted: opsCount,
|
|
945
964
|
dropped: droppedCount,
|
|
965
|
+
level: "error",
|
|
946
966
|
});
|
|
947
967
|
}
|
|
948
968
|
// §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
|
|
949
969
|
// `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
|
|
950
970
|
// sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
|
|
951
971
|
// and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
|
|
952
|
-
let errSeq =
|
|
972
|
+
let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
|
|
953
973
|
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
954
|
-
const snippet = this.#extractSnippet(packetAssistant.content, line, 2);
|
|
955
974
|
await this.#db.engine_insert_log_entry.get({
|
|
956
975
|
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
957
976
|
origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
|
|
958
977
|
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
959
978
|
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
960
979
|
tx: "", mimetype_tx: "text/plain",
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
rx: JSON.stringify({ message, position: { type: "content-offset", line, column },
|
|
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 }),
|
|
965
984
|
mimetype_rx: "application/json",
|
|
966
985
|
status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
|
|
967
986
|
});
|
|
968
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
|
+
}
|
|
969
998
|
// Zero ops is NOT an error to report — the model knows it emitted
|
|
970
999
|
// nothing. Strike accounting (engine-internal) treats it as a
|
|
971
1000
|
// struck turn; the model just sees an empty packet next turn.
|
|
@@ -1251,6 +1280,7 @@ class Engine {
|
|
|
1251
1280
|
source: "engine:rail",
|
|
1252
1281
|
kind: "budget_overflow",
|
|
1253
1282
|
folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
|
|
1283
|
+
level: "warn",
|
|
1254
1284
|
});
|
|
1255
1285
|
}
|
|
1256
1286
|
// Wire projection lives in ./packet-wire.ts so Engine and
|
|
@@ -1291,6 +1321,7 @@ class Engine {
|
|
|
1291
1321
|
const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
|
|
1292
1322
|
return {
|
|
1293
1323
|
kind: "action_failure",
|
|
1324
|
+
level: "error", // the derived error pointer is always an error — clients color off level (#276)
|
|
1294
1325
|
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
1295
1326
|
op: r.op,
|
|
1296
1327
|
target,
|
|
@@ -1378,6 +1409,18 @@ class Engine {
|
|
|
1378
1409
|
}
|
|
1379
1410
|
return written;
|
|
1380
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
|
+
}
|
|
1381
1424
|
// §environment-observation — exec streams as an instance of the ambient-observe machine:
|
|
1382
1425
|
// each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
|
|
1383
1426
|
// while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
|
|
@@ -1446,19 +1489,7 @@ class Engine {
|
|
|
1446
1489
|
}
|
|
1447
1490
|
async dispatch(context) {
|
|
1448
1491
|
const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
|
|
1449
|
-
const schemeCtx = {
|
|
1450
|
-
db: this.#db,
|
|
1451
|
-
sessionId, runId, loopId, turnId,
|
|
1452
|
-
writer: origin,
|
|
1453
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1454
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1455
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1456
|
-
injectRun: this.#injectRun,
|
|
1457
|
-
mimetypes: this.#mimetypes,
|
|
1458
|
-
tokenize: this.#tokenize,
|
|
1459
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1460
|
-
executors: this.#executors,
|
|
1461
|
-
};
|
|
1492
|
+
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
|
|
1462
1493
|
let result;
|
|
1463
1494
|
let denial = this.#checkWritable(statement, origin);
|
|
1464
1495
|
if (denial === null)
|
|
@@ -1466,6 +1497,12 @@ class Engine {
|
|
|
1466
1497
|
if (denial !== null) {
|
|
1467
1498
|
result = denial;
|
|
1468
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
|
+
}
|
|
1469
1506
|
else {
|
|
1470
1507
|
// SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
|
|
1471
1508
|
// exceptions become the action-entry's outcome (status 500), not a
|
|
@@ -1574,6 +1611,38 @@ class Engine {
|
|
|
1574
1611
|
}
|
|
1575
1612
|
return result;
|
|
1576
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
|
+
}
|
|
1577
1646
|
// On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
|
|
1578
1647
|
async #runApplyResolution(statement, originalResult, resolution, ids) {
|
|
1579
1648
|
const { sessionId, runId, loopId, turnId } = ids;
|
|
@@ -1864,17 +1933,19 @@ class Engine {
|
|
|
1864
1933
|
#isRunFork(statement) {
|
|
1865
1934
|
return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
|
|
1866
1935
|
}
|
|
1867
|
-
// COPY(run
|
|
1868
|
-
// run (Fork), then start it with the prompt (ctx.injectRun). Source
|
|
1869
|
-
// 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,
|
|
1870
1939
|
// §machine-processes-fork-copies-the-log
|
|
1871
1940
|
async #handleRunFork(statement, ctx) {
|
|
1872
1941
|
const target = statement.target;
|
|
1873
1942
|
if (target === null)
|
|
1874
1943
|
return { status: 400, error: "run:// fork requires a source run" };
|
|
1875
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)" };
|
|
1876
1947
|
let srcRunId = ctx.runId;
|
|
1877
|
-
if (name !== ""
|
|
1948
|
+
if (name !== "self") {
|
|
1878
1949
|
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
1879
1950
|
if (row === undefined)
|
|
1880
1951
|
return { status: 404, error: `run://${name} not found in this session` };
|
|
@@ -1969,12 +2040,22 @@ class Engine {
|
|
|
1969
2040
|
return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
|
|
1970
2041
|
}
|
|
1971
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
|
+
}
|
|
1972
2051
|
// terminate — abort any run by address; whoever holds it may end it.
|
|
1973
|
-
//
|
|
2052
|
+
// `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
|
|
1974
2053
|
// (its loop closes 499); an idle run is a no-op-200, a missing run 404.
|
|
1975
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>)" };
|
|
1976
2057
|
let runId = ctx.runId;
|
|
1977
|
-
if (name !== ""
|
|
2058
|
+
if (name !== "self") {
|
|
1978
2059
|
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
1979
2060
|
if (row === undefined)
|
|
1980
2061
|
return { status: 404, error: `run://${name} not found in this session` };
|
|
@@ -1991,6 +2072,101 @@ class Engine {
|
|
|
1991
2072
|
const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
|
|
1992
2073
|
return { status: delResult.status };
|
|
1993
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
|
+
}
|
|
1994
2170
|
// PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
|
|
1995
2171
|
// other, logged, and broadcast to the client as a log entry — but a pure no-op for
|
|
1996
2172
|
// state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
|
|
@@ -2224,7 +2400,7 @@ class Engine {
|
|
|
2224
2400
|
// it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
|
|
2225
2401
|
// the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
|
|
2226
2402
|
// namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
|
|
2227
|
-
// run selector (§run-scheme), and run
|
|
2403
|
+
// run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
|
|
2228
2404
|
// folds the owner into the storage path itself, never here.
|
|
2229
2405
|
const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
|
|
2230
2406
|
return {
|