@plurnk/plurnk-service 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.env.example +21 -7
  2. package/SPEC.md +34 -28
  3. package/bin/plurnk-service.ts +50 -7
  4. package/dist/core/ChannelWrite.d.ts.map +1 -1
  5. package/dist/core/ChannelWrite.js +2 -1
  6. package/dist/core/ChannelWrite.js.map +1 -1
  7. package/dist/core/Engine.d.ts.map +1 -1
  8. package/dist/core/Engine.js +89 -64
  9. package/dist/core/Engine.js.map +1 -1
  10. package/dist/core/SchemeRegistry.d.ts +2 -0
  11. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  12. package/dist/core/SchemeRegistry.js +31 -22
  13. package/dist/core/SchemeRegistry.js.map +1 -1
  14. package/dist/core/packet-wire.d.ts +30 -31
  15. package/dist/core/packet-wire.d.ts.map +1 -1
  16. package/dist/core/packet-wire.js +81 -80
  17. package/dist/core/packet-wire.js.map +1 -1
  18. package/dist/core/plurnk-uri.d.ts +3 -0
  19. package/dist/core/plurnk-uri.d.ts.map +1 -0
  20. package/dist/core/plurnk-uri.js +23 -0
  21. package/dist/core/plurnk-uri.js.map +1 -0
  22. package/dist/core/scheme-types.d.ts +4 -0
  23. package/dist/core/scheme-types.d.ts.map +1 -1
  24. package/dist/core/scheme-types.js.map +1 -1
  25. package/dist/schemes/Exec.d.ts +0 -1
  26. package/dist/schemes/Exec.d.ts.map +1 -1
  27. package/dist/schemes/Exec.js +2 -1
  28. package/dist/schemes/Exec.js.map +1 -1
  29. package/dist/schemes/File.d.ts +0 -1
  30. package/dist/schemes/File.d.ts.map +1 -1
  31. package/dist/schemes/File.js +2 -1
  32. package/dist/schemes/File.js.map +1 -1
  33. package/dist/schemes/Known.d.ts +0 -1
  34. package/dist/schemes/Known.d.ts.map +1 -1
  35. package/dist/schemes/Known.js +2 -1
  36. package/dist/schemes/Known.js.map +1 -1
  37. package/dist/schemes/Log.d.ts +0 -1
  38. package/dist/schemes/Log.d.ts.map +1 -1
  39. package/dist/schemes/Log.js +2 -1
  40. package/dist/schemes/Log.js.map +1 -1
  41. package/dist/schemes/Plurnk.d.ts +0 -1
  42. package/dist/schemes/Plurnk.d.ts.map +1 -1
  43. package/dist/schemes/Plurnk.js +9 -4
  44. package/dist/schemes/Plurnk.js.map +1 -1
  45. package/dist/schemes/Run.d.ts +0 -1
  46. package/dist/schemes/Run.d.ts.map +1 -1
  47. package/dist/schemes/Run.js +2 -1
  48. package/dist/schemes/Run.js.map +1 -1
  49. package/dist/schemes/Unknown.d.ts +0 -1
  50. package/dist/schemes/Unknown.d.ts.map +1 -1
  51. package/dist/schemes/Unknown.js +2 -1
  52. package/dist/schemes/Unknown.js.map +1 -1
  53. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  54. package/dist/schemes/_entry-manifest.js +2 -1
  55. package/dist/schemes/_entry-manifest.js.map +1 -1
  56. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  57. package/dist/schemes/_entry-ops.js +6 -1
  58. package/dist/schemes/_entry-ops.js.map +1 -1
  59. package/dist/server/Daemon.d.ts.map +1 -1
  60. package/dist/server/Daemon.js +3 -1
  61. package/dist/server/Daemon.js.map +1 -1
  62. package/dist/server/clientTurn.d.ts.map +1 -1
  63. package/dist/server/clientTurn.js +1 -2
  64. package/dist/server/clientTurn.js.map +1 -1
  65. package/dist/server/envelope.d.ts +1 -0
  66. package/dist/server/envelope.d.ts.map +1 -1
  67. package/dist/server/envelope.js +9 -0
  68. package/dist/server/envelope.js.map +1 -1
  69. package/dist/server/methods/loop_run.d.ts.map +1 -1
  70. package/dist/server/methods/loop_run.js +6 -2
  71. package/dist/server/methods/loop_run.js.map +1 -1
  72. package/dist/server/methods/session_rename.d.ts +5 -0
  73. package/dist/server/methods/session_rename.d.ts.map +1 -0
  74. package/dist/server/methods/session_rename.js +32 -0
  75. package/dist/server/methods/session_rename.js.map +1 -0
  76. package/migrations/0000-00-00.01_schema.sql +3 -5
  77. package/package.json +10 -35
  78. package/requirements.md +3 -8
@@ -4,6 +4,7 @@ import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
4
4
  import EntryCrud from "../schemes/_entry-crud.js";
5
5
  import EntryManifest from "../schemes/_entry-manifest.js";
6
6
  import GitMembership from "./git-membership.js";
7
+ import { foldAuthorityIntoPath, renderAddress } from "./plurnk-uri.js";
7
8
  import GitState from "./git-state.js";
8
9
  import Fork from "./fork.js";
9
10
  import RunCap from "./run-cap.js";
@@ -538,10 +539,10 @@ class Engine {
538
539
  const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
539
540
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
540
541
  const promptPath = {
541
- kind: "url", raw: `plurnk:///prompt/${loopId}/${seq}`,
542
+ kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
542
543
  scheme: "plurnk", username: null, password: null,
543
- hostname: null, port: null,
544
- pathname: `/prompt/${loopId}/${seq}`, params: {}, fragment: null,
544
+ hostname: "prompt", port: null,
545
+ pathname: `/${loopId}/${seq}`, params: {}, fragment: null,
545
546
  };
546
547
  const promptStmt = {
547
548
  op: "EDIT", suffix: "", signal: null,
@@ -554,9 +555,9 @@ class Engine {
554
555
  sequence: nextActionIndex, origin: "plurnk",
555
556
  onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
556
557
  });
557
- // §prompt-fold (User Note 6): the prompt EDIT duplicates
558
- // packet.user.prompt (its own section), so fold it — logged for
559
- // forensics, collapsed in the model's log, re-OPENable.
558
+ // §prompt-fold (User Note 6): the prompt EDIT duplicates the
559
+ // prompt section, so fold it — logged for forensics, collapsed
560
+ // in the model's log, re-OPENable.
560
561
  if (promptLogId !== undefined)
561
562
  await this.#db.engine_fold_log_entry.run({ id: promptLogId });
562
563
  nextActionIndex++;
@@ -659,7 +660,7 @@ class Engine {
659
660
  // grammar can't bound degeneration *inside* a statement body — this caps the
660
661
  // decode at the free window so a runaway can't reach the context wall.
661
662
  const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
662
- const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.system.tokens - requestPacket.user.tokens);
663
+ const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.tokens);
663
664
  const response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired
664
665
  // Engine splits wire-level response: emission (content, reasoning,
665
666
  // parsed ops) → packet.assistant per Packet.json §assistant;
@@ -849,18 +850,19 @@ class Engine {
849
850
  // the stored packet and the wire payload share one source of truth.
850
851
  async #buildRequestPacket({ initialMessages, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
851
852
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
852
- // plurnk.md (grammar/dialects) THEN the scheme catalogue: grammar 0.49+ is
853
- // scheme-agnostic, so the service teaches what schemes exist + what they do
854
- // at packet-time (grammar#239 item 7). SchemeRegistry.teach() assembles it.
855
- const system_definition = `${byRole("system")}\n\n${this.#schemes.teach()}`;
856
- // user.prompt sources from the loop's most recent prompt entry first
853
+ // plurnk.md (grammar/dialects only grammar 0.49+ is scheme-agnostic). The
854
+ // scheme catalogue is now its OWN section (`schemes`, below tools), so the
855
+ // definition is just the operator's system prompt — tools sits right below it.
856
+ const system_definition = byRole("system");
857
+ // the prompt section sources from the loop's most recent prompt entry first
857
858
  // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
858
859
  // This is what inject + the turn-1 foist write into. Falls back to
859
860
  // the runLoop caller's messages.user for tests that bypass the
860
861
  // foist mechanism entirely.
861
- const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
862
+ const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `/prompt/${loopId}/%` });
863
+ const promptCap = Number.parseInt(process.env.PLURNK_PROMPT_PREVIEW_CHARS ?? "", 10);
862
864
  const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
863
- ? latestPromptRow.content
865
+ ? PacketWire.previewPrompt(latestPromptRow.content, renderAddress("plurnk", latestPromptRow.pathname), Number.isInteger(promptCap) ? promptCap : -1)
864
866
  : byRole("user");
865
867
  // Requirements is engine-sourced, NOT threaded from callers — that threading is
866
868
  // exactly how it went missing (callers read the sysprompt but never the
@@ -874,65 +876,83 @@ class Engine {
874
876
  const requirementsText = `Syntax: <<OPsuffix[signal]?(target)?<Line/Result>?:body?:OPsuffix\n\n${planDirective}${baseRequirements}`;
875
877
  const log = await this.#buildLog(runId);
876
878
  const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
877
- // Per-section render-cost subtotals via provider's tokenizer.
878
- // Engine approximates each section by tokenizing its serialized
879
- // form — wire-payload tokens may differ slightly because chat-
880
- // template scaffolding adds bytes, but the subtotal tracks "what
881
- // the model has to process" closely enough for budget diagnostics.
882
879
  const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
883
- // Budget readout (SPEC.md §tokenomics). Two-pass: measure the wire-rendered
884
- // index/log sections (budget-independent), install the readout with a
885
- // tokensFree placeholder, measure the assembled total, resolve free,
886
- // substitute. Subtotals come from the real render meta and fences
887
- // included not a serialized approximation. ceiling is the provider's
888
- // window × PLURNK_BUDGET_CEILING (null when no window is reported →
889
- // headline omitted, section lines still shown).
880
+ const tools = this.#collectTools();
881
+ // Budget readout (SPEC.md §tokenomics). Two-pass: render the budget from
882
+ // the structured log's subtotals with a {{tokensFree}} placeholder, build
883
+ // the section list, measure the assembled total, resolve free, substitute.
884
+ // Subtotals come from the real log render meta and fences included — not
885
+ // a serialized approximation. ceiling is the provider's window ×
886
+ // PLURNK_BUDGET_CEILING (null when no window is reported → headline
887
+ // omitted, section lines still shown). §tokenomics-render-weight-budget
890
888
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
891
- const scratch = {
892
- system: { system_definition, log },
893
- user: { prompt, telemetry: { budget: "", errors: telemetryErrors, git: gitStatus }, tools: this.#collectTools(), system_requirements: requirementsText },
894
- };
895
- const sections = PacketWire.measureBudgetSections(scratch, countTokens);
896
- scratch.user.telemetry.budget = this.#renderBudget(sections, ceiling);
897
- const total = countTokens(PacketWire.renderSystemContent(scratch.system)) + countTokens(PacketWire.renderUserContent(scratch.user));
889
+ const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
890
+ // The default packet: an ordered list of sections, each addressable state
891
+ // (§packet-construction). `slot` is the prompt-cache boundary; the STATIC
892
+ // sections (definition, tools, schemes) lead the system slot so they form the
893
+ // cached prefix, with the dynamic log after. In the user slot, requirements renders
894
+ // last (the contract closest to the assistant turn); budget/errors/git are
895
+ // peer sections (unbundled). The budget section carries its {{tokensFree}}
896
+ // placeholders here; they resolve below once the assembled total is known.
897
+ const defaults = [
898
+ { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
899
+ { name: "tools", slot: "system", header: "Plurnk System Tools", content: tools.join("\n"), tokens: 0 },
900
+ { name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
901
+ { name: "log", slot: "system", header: "Plurnk System Log", content: PacketWire.renderLog(log), tokens: 0 },
902
+ { name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
903
+ { name: "budget", slot: "user", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
904
+ { name: "errors", slot: "user", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
905
+ { name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
906
+ { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: requirementsText, tokens: 0 },
907
+ ];
908
+ // Plugin packet control (§packet-construction): trusted schemes rewrite the
909
+ // default list — add, remove, reorder — in-process, before measurement.
910
+ const sections = await this.#schemes.transformSections(defaults);
911
+ // Pass 1: measure the assembled total with the placeholder budget in
912
+ // place, resolve free/percent, substitute into the budget section.
913
+ const total = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
898
914
  const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
899
915
  const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
900
- const budget = tokensFree === null
901
- ? scratch.user.telemetry.budget
902
- : scratch.user.telemetry.budget
903
- .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
904
- .replace(TOKEN_PERCENT_PLACEHOLDER, String(percent))
905
- .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
906
- const system = { tokens: 0, system_definition, log };
907
- const user = { tokens: 0, prompt, telemetry: { budget, errors: telemetryErrors, git: gitStatus }, tools: scratch.user.tools, system_requirements: requirementsText };
908
- system.tokens = countTokens(PacketWire.renderSystemContent(system));
909
- user.tokens = countTokens(PacketWire.renderUserContent(user));
910
- return { system, user };
916
+ if (tokensFree !== null) {
917
+ const budgetSec = sections.find((s) => s.name === "budget"); // a plugin may have removed it
918
+ if (budgetSec) {
919
+ budgetSec.content = budgetSec.content
920
+ .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
921
+ .replace(TOKEN_PERCENT_PLACEHOLDER, percent === 0 && total > 0 ? "<1" : String(percent))
922
+ .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
923
+ }
924
+ }
925
+ // Pass 2: per-section render-weight + the assembled packet total (post
926
+ // substitution the placeholder/number length delta is negligible).
927
+ for (const s of sections)
928
+ s.tokens = countTokens(PacketWire.renderSection(s));
929
+ const packetTokens = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
930
+ return { tokens: packetTokens, sections, telemetryErrors };
911
931
  }
912
932
  // Budget readout body, rendered into the `# Plurnk System Budget` section.
913
933
  // Headline `ceiling/free` only when a ceiling exists; section lines for the
914
934
  // curatable index/log weight the model can FOLD back. tokensFree is a
915
935
  // placeholder here — buildSystem substitutes it after measuring the packet.
916
- #renderBudget(sections, ceiling) {
936
+ #renderBudget(log, ceiling) {
917
937
  const lines = [];
918
938
  if (ceiling !== null)
919
939
  lines.push(`ceiling ${ceiling} · usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · free ${TOKENS_FREE_PLACEHOLDER}`);
920
- if (sections.log.entries > 0) {
921
- lines.push(`Log entries: ${sections.log.entries} entries, ${sections.log.tokens} tokens`);
940
+ if (log.entries > 0) {
941
+ lines.push(`Log entries: ${log.entries} entries, ${log.tokens} tokens`);
922
942
  // Per-turn weight — the grinder's rollback unit, oldest first: the
923
943
  // model sees what's first to go (§tokenomics {§tokenomics-turn-totals}).
924
- if (sections.log.byTurn.length > 0) {
944
+ if (log.byTurn.length > 0) {
925
945
  lines.push("Turns:", "| turn | tokens |", "|---|--:|");
926
- for (const t of sections.log.byTurn)
946
+ for (const t of log.byTurn)
927
947
  lines.push(`| ${t.turn} | ${t.tokens} |`);
928
948
  }
929
949
  // The heaviest individual log items — the FOLD targets behind the weight
930
950
  // (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
931
951
  // lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
932
952
  // is only for entries. Do not attempt to edit log items.").
933
- if (sections.log.largest.length > 0) {
953
+ if (log.largest.length > 0) {
934
954
  lines.push("Heaviest items:", "| item | tokens |", "|---|--:|");
935
- for (const e of sections.log.largest)
955
+ for (const e of log.largest)
936
956
  lines.push(`| ${e.path} | ${e.tokens} |`);
937
957
  }
938
958
  }
@@ -958,16 +978,16 @@ class Engine {
958
978
  const entry = this.#executors.entry(tag);
959
979
  if (entry?.example)
960
980
  tools.push(`* ${entry.example}`);
961
- // #note12 — link the executor's fuller doc (materialized at plurnk:///docs/<tag>);
981
+ // #note12 — link the executor's fuller doc (materialized at plurnk:///docs/<tag>.md);
962
982
  // its token cost rides that manifest entry, so no inline recount here.
963
983
  if (entry?.documentation)
964
- tools.push(`* docs for ${tag}: plurnk:///docs/${tag}`);
984
+ tools.push(`* docs for ${tag}: plurnk://docs/${tag}.md`);
965
985
  }
966
986
  }
967
987
  return tools;
968
988
  }
969
989
  // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
970
- // materialized at plurnk:///docs/<name> by loop_run (like operator docs) so the
990
+ // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs) so the
971
991
  // catalogue's doc-links READ and the manifest carries each doc's token cost.
972
992
  docEntries() {
973
993
  const out = this.#schemes.docs();
@@ -988,7 +1008,7 @@ class Engine {
988
1008
  // §grinder-overflow-only — fires only on actual overflow, never speculatively
989
1009
  async #enforceBudget({ packet, provider, runId, loopId, turnId, sessionId, turnNumber, rebuild }) {
990
1010
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
991
- const measure = (p) => p.system.tokens + p.user.tokens;
1011
+ const measure = (p) => p.tokens;
992
1012
  if (ceiling === null || measure(packet) <= ceiling)
993
1013
  return { packet, fit: true, struck: false };
994
1014
  const folded = new Map();
@@ -1000,7 +1020,7 @@ class Engine {
1000
1020
  note(le.scheme ?? "log");
1001
1021
  if (priorLogs.length > 0)
1002
1022
  await this.#db.engine_grinder_fold_prior_turn_logs.run({ loop_id: loopId, turn_id: turnId });
1003
- const errors = packet.user.telemetry.errors;
1023
+ const errors = packet.telemetryErrors;
1004
1024
  let current = priorLogs.length > 0 ? await rebuild(errors) : packet;
1005
1025
  if (measure(current) <= ceiling) {
1006
1026
  this.#emitBudgetOverflow(sessionId, loopId, folded);
@@ -1037,9 +1057,9 @@ class Engine {
1037
1057
  #completePacket(requestPacket, assistant, assistantRaw, provider) {
1038
1058
  const assistantTokens = provider.countTokens(assistant.content);
1039
1059
  return {
1040
- tokens: requestPacket.system.tokens + requestPacket.user.tokens + assistantTokens,
1041
- system: requestPacket.system,
1042
- user: requestPacket.user,
1060
+ tokens: requestPacket.tokens + assistantTokens,
1061
+ sections: requestPacket.sections,
1062
+ telemetryErrors: requestPacket.telemetryErrors,
1043
1063
  assistant,
1044
1064
  assistantRaw,
1045
1065
  };
@@ -1074,7 +1094,7 @@ class Engine {
1074
1094
  });
1075
1095
  return [...this.#drainTelemetry(loopId), ...actionFailures];
1076
1096
  }
1077
- // SPEC §packet packet.system.log — chronological action-entries for the loop.
1097
+ // SPEC §packet the log section — chronological action-entries for the loop.
1078
1098
  // Snapshot is taken at packet build (pre-dispatch this turn), so it
1079
1099
  // reflects "what has happened before this turn." Each row carries a
1080
1100
  // log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
@@ -1404,8 +1424,8 @@ class Engine {
1404
1424
  return (row?.n ?? 0) > 0;
1405
1425
  }
1406
1426
  // Inject a prompt into the run's currently-executing loop. Writes a
1407
- // plurnk:///prompt/<loop_id>/<next-turn> entry whose body becomes
1408
- // packet.user.prompt at the next turn boundary. Last-wins: if two
1427
+ // plurnk:///prompt/<loop_id>/<next-turn> entry whose body becomes the
1428
+ // prompt section at the next turn boundary. Last-wins: if two
1409
1429
  // injects target the same next-turn slot, the second overwrites the
1410
1430
  // first.
1411
1431
  //
@@ -1425,7 +1445,7 @@ class Engine {
1425
1445
  const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
1426
1446
  if (sessionRow === undefined)
1427
1447
  throw new Error(`Engine.inject: run ${runId} not found`);
1428
- const pathname = `prompt/${loopId}/${turnSeq}`;
1448
+ const pathname = `/prompt/${loopId}/${turnSeq}`; // canonical storage form (leading slash), matching the foist via #pathnameOf
1429
1449
  const ctx = {
1430
1450
  db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
1431
1451
  turnId: 0, // no turn open at inject time; entries don't pin turnId
@@ -1955,9 +1975,14 @@ class Engine {
1955
1975
  if (path.kind === "local")
1956
1976
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
1957
1977
  const scheme = path.scheme === "file" ? null : path.scheme;
1978
+ // plurnk uses its authority as a namespace — fold it into the canonical pathname so the
1979
+ // log keys identically to the entry (/prompt/<loop>, /docs/x.md). A web host (http://) is
1980
+ // NOT a namespace: keep it in hostname.
1981
+ const foldNs = scheme === "plurnk";
1958
1982
  return {
1959
1983
  scheme, username: path.username, password: path.password,
1960
- hostname: path.hostname, port: path.port, pathname: decodePathParens(path.pathname), // #239 item 4
1984
+ hostname: foldNs ? null : path.hostname, port: path.port,
1985
+ pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
1961
1986
  params: JSON.stringify(path.params), fragment: path.fragment,
1962
1987
  };
1963
1988
  }