billion-context-dsh 0.1.9 → 0.2.1

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/dist/index.js CHANGED
@@ -582,9 +582,151 @@ function kernelConfigFor(input) {
582
582
 
583
583
  // src/nudge.ts
584
584
  import {
585
- defaultCountTokens as defaultCountTokens2
585
+ COMPRESS_PHILOSOPHY as COMPRESS_PHILOSOPHY2,
586
+ TIER2_DISTILL_RULES as TIER2_DISTILL_RULES2,
587
+ TIER3_CONDENSE_RULES as TIER3_CONDENSE_RULES2,
588
+ defaultCountTokens as defaultCountTokens2,
589
+ renderNudgeText
586
590
  } from "acp-kernel";
587
591
  import { createUserMessage as createUserMessage2 } from "@deepseek-ai/dsh-llm";
592
+
593
+ // src/prompts.ts
594
+ import { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES, TIER2_DISTILL_RULES, TIER3_CONDENSE_RULES } from "acp-kernel";
595
+ var NUDGE_ALLOWED = {
596
+ normal: /* @__PURE__ */ new Set(["pct", "philosophy"]),
597
+ emergency: /* @__PURE__ */ new Set(["pct", "philosophy"]),
598
+ guidance: /* @__PURE__ */ new Set(),
599
+ tier: /* @__PURE__ */ new Set(["tier", "count", "prevTier", "tokens", "seqs"]),
600
+ breakdown: /* @__PURE__ */ new Set(["system", "tool", "summaries", "code", "text"]),
601
+ growth: /* @__PURE__ */ new Set(["growth"]),
602
+ tip: /* @__PURE__ */ new Set()
603
+ };
604
+ var RANGE_TABLE_ALLOWED = {
605
+ header: /* @__PURE__ */ new Set(["surface"]),
606
+ title: /* @__PURE__ */ new Set(["count"]),
607
+ line: /* @__PURE__ */ new Set(["start", "end", "count", "tokens"]),
608
+ footer: /* @__PURE__ */ new Set()
609
+ };
610
+ var TOOLS_ALLOWED = {
611
+ compress: /* @__PURE__ */ new Set(),
612
+ decompress: /* @__PURE__ */ new Set(),
613
+ searchContext: /* @__PURE__ */ new Set(),
614
+ acpStatus: /* @__PURE__ */ new Set()
615
+ };
616
+ var SYSTEM_ALLOWED = /* @__PURE__ */ new Set(["philosophy", "howToCompressRules", "tier2DistillRules", "tier3CondenseRules"]);
617
+ function validateTemplate(template, allowed, path) {
618
+ const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
619
+ let match;
620
+ while ((match = re.exec(template)) !== null) {
621
+ const name = match[1];
622
+ if (!allowed.has(name)) {
623
+ throw new Error(
624
+ `${path} contains unknown placeholder {${name}} \u2014 allowed: ${[...allowed].join(", ") || "(none)"}`
625
+ );
626
+ }
627
+ }
628
+ return template;
629
+ }
630
+ function renderTemplate(template, vars) {
631
+ return template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
632
+ const value = vars[name];
633
+ if (value === void 0) {
634
+ throw new Error(
635
+ `renderTemplate: missing value for placeholder {${name}} in template "${template.slice(0, 60)}\u2026"`
636
+ );
637
+ }
638
+ return String(value);
639
+ });
640
+ }
641
+ function mergeGroup(defaults, override, allowed, path) {
642
+ if (override == null) return defaults;
643
+ const out = {};
644
+ for (const key of Object.keys(defaults)) {
645
+ const value = override[key];
646
+ out[key] = value === null || value === void 0 ? defaults[key] : validateTemplate(value, allowed[key], `${path}.${String(key)}`);
647
+ }
648
+ return out;
649
+ }
650
+ function resolvePrompts(input) {
651
+ if (input === void 0) return DEFAULT_RESOLVED;
652
+ return {
653
+ nudge: mergeGroup(DEFAULT_PROMPTS.nudge, input.nudge, NUDGE_ALLOWED, "prompts.nudge"),
654
+ rangeTable: mergeGroup(DEFAULT_PROMPTS.rangeTable, input.rangeTable, RANGE_TABLE_ALLOWED, "prompts.rangeTable"),
655
+ tools: mergeGroup(DEFAULT_PROMPTS.tools, input.tools, TOOLS_ALLOWED, "prompts.tools"),
656
+ systemPromptTemplate: input.systemPrompt === null || input.systemPrompt === void 0 ? DEFAULT_PROMPTS.systemPromptTemplate : validateTemplate(input.systemPrompt, SYSTEM_ALLOWED, "prompts.systemPrompt")
657
+ };
658
+ }
659
+ function renderSystemPrompt(prompts) {
660
+ return renderTemplate(prompts.systemPromptTemplate, {
661
+ philosophy: COMPRESS_PHILOSOPHY,
662
+ howToCompressRules: HOW_TO_COMPRESS_RULES,
663
+ tier2DistillRules: TIER2_DISTILL_RULES,
664
+ tier3CondenseRules: TIER3_CONDENSE_RULES
665
+ });
666
+ }
667
+ var DEFAULT_PROMPTS = {
668
+ nudge: {
669
+ // 与 kernel nudge-text.ts EFFICIENCY_NOTE 逐字对齐——不含 "Context usage is at X%"
670
+ // 陈述(usage 只通过 breakdown 传达);{pct} 仍可用作自定义占位符。
671
+ normal: "This is an efficiency nudge to compress early and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.\n\n{philosophy}",
672
+ emergency: "\u26A0\uFE0F Context limit reached \u2014 compress now. Prioritize consumed tool outputs.\n\n{philosophy}",
673
+ guidance: HOW_TO_COMPRESS_RULES,
674
+ tier: "Tier {tier}: {count} tier-{prevTier} block(s) distillable ({tokens} tokens) \u2014 compress their summary node(s) [seqs {seqs}] to reclaim the original messages.",
675
+ breakdown: "Context breakdown: {system}K system | {tool}K tool | {summaries}K summaries | {code}K code | {text}K text",
676
+ growth: "+{growth}K since last nudge",
677
+ tip: "\u{1F4A1} Compress all ranges in one call (pass multiple content entries: `content: [{...}, {...}]`)."
678
+ },
679
+ rangeTable: {
680
+ header: "Surface: {surface}",
681
+ title: "Compressible ranges (suggestions only \u2014 compress any consumed span; refs are surface seqs):",
682
+ line: " - seq {start}..{end} \u2014 {count} messages, ~{tokens} tokens",
683
+ footer: "Compress with: compress({ content: [{ startSeq, endSeq, summary }] }) \u2014 content is an array: batch multiple unrelated segments in one call, each entry its own block. Keep ranges disjoint.\nSnapshot taken at nudge time: the seqs go stale once the surface moves (a later compress shadows them), so re-run acp_status for fresh refs before compressing."
684
+ },
685
+ tools: {
686
+ compress: "Replace older conversation ranges with dense summaries you write. Each message seq is a surface reference. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated ranges in one call (each content entry becomes its own block); keep ranges disjoint. Never compress content the current step is actively using. Seq refs must come from the CURRENT surface (acp_status or the latest nudge): a span whose edges were shadowed by an earlier compress is auto-remapped to its still-live content, a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.",
687
+ decompress: "Recover the original content of a compressed block by its blockId (read-only; does not unshadow the range).",
688
+ searchContext: "Search inside compressed blocks (summaries and original content) for information the model no longer sees in context.",
689
+ acpStatus: "Report the ACP block ledger: compressed blocks, reclaimed tokens, and current context pressure."
690
+ },
691
+ systemPromptTemplate: `Active Context Pruning \u2014 model-driven context management
692
+
693
+ YOU decide whether and when to compress context. The nudge is an efficiency notification: when you see one, consider which ranges you have genuinely consumed and could summarise to keep working context lean.
694
+
695
+ {philosophy}
696
+
697
+ WHEN TO COMPRESS:
698
+ - A sub-agent or delegated task has returned a large result that you have already extracted the key facts from.
699
+ - Verbose command output (build/test logs, git diff, directory listings) where you have already used the information you need.
700
+ - Exploration that led nowhere.
701
+ - Repeated reads of the same file or repeated status checks once the decision is recorded.
702
+ - Resolved discussion threads where a decision has been captured in summary or in code.
703
+ - Intermediate steps of a completed multi-step task, once the final result is recorded.
704
+ - A task phase has ended \u2014 bug hunt complete, root cause found, exploration done, research sprint wrapped.
705
+
706
+ WHEN NOT TO COMPRESS:
707
+ - Content the current step is actively reading or reasoning about.
708
+ - Important user messages \u2014 preserve their exact intent, constraints, and acceptance criteria.
709
+ - Protected tool outputs \u2014 hard-excluded from compression ranges, survive intact in visible context.
710
+
711
+ {howToCompressRules}
712
+
713
+ Compression tools (refs are SURFACE SEQS, not ids):
714
+ - compress: replace one or more seq ranges, each with your own dense summary. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated segments in one call (each entry becomes its own block): compress({ content: [{ startSeq: 1, endSeq: 5, summary: '...' }, { startSeq: 12, endSeq: 18, summary: '...' }] }). Keep ranges disjoint \u2014 overlapping entries in one batch are skipped. Edges are auto-balanced to tool-call/result boundaries; a trailing #callId fragment in a seq is ignored. Seq refs must be on the current surface: seqs from older nudges or earlier compresses go stale as the surface moves, so a stale span is auto-remapped to its still-live remainder (the result reports the adjusted span), a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.
715
+ - decompress: recover a compressed block's original content, read-only. decompress({ blockId }).
716
+ - search_context: find information inside compressed blocks BEFORE decompressing. search_context({ query }).
717
+ - acp_status: current context usage and the live compressible-range list. Run it right before compressing \u2014 the only seqs that never go stale are the ones you just read.
718
+
719
+ Tiered compression: each compressed block appears on the surface as one summary node. Compressing that node again DISTILLS the block (tier 2): the parent summary folds into your new summary and the original messages are freed. Distilling a tier-2 block yields tier 3. Distill when a summary itself is consumed \u2014 decompress on the tier-2 block recovers the full originals.
720
+
721
+ {tier2DistillRules}
722
+
723
+ {tier3CondenseRules}
724
+
725
+ When you write a summary, it becomes the ONLY record of that range: keep file paths, signatures, exact values, decisions, and error strings verbatim so a later reader (or you, after decompress) can continue without the original. Never reuse historical seqs \u2014 the surface moves as messages land and compress; verify with acp_status.`
726
+ };
727
+ var DEFAULT_RESOLVED = DEFAULT_PROMPTS;
728
+
729
+ // src/nudge.ts
588
730
  function resolveTokenCount(agent, coreMessages) {
589
731
  const projections = agent.ctx?.get?.("sessionProjections");
590
732
  const projected = projections?.snapshot?.(agent.session)?.values?.contextPressure?.projectedTokens;
@@ -594,17 +736,24 @@ function resolveTokenCount(agent, coreMessages) {
594
736
  if (typeof surface === "number" && surface > 0) return surface;
595
737
  return coreMessages.reduce((sum, message) => sum + defaultCountTokens2(message.text ?? ""), 0);
596
738
  }
597
- function rangeTable(session) {
739
+ function rangeTable(session, prompts = DEFAULT_RESOLVED) {
598
740
  const ranges = buildCompressibleSeqRanges(session).slice(0, 6);
599
741
  if (ranges.length === 0) return "";
600
- const lines = ranges.map((range) => ` - seq ${range.start}..${range.end} \u2014 ${range.count} messages, ~${range.tokens} tokens`);
742
+ const lines = ranges.map(
743
+ (range) => renderTemplate(prompts.rangeTable.line, {
744
+ start: range.start,
745
+ end: range.end,
746
+ count: range.count,
747
+ tokens: range.tokens
748
+ })
749
+ );
601
750
  return [
751
+ // 前导空串元素产生 nudge 中范围表前的唯一空行(§4:parts 层不再加分隔)。
602
752
  "",
603
- `Surface: ${surfaceSummary(session)}`,
604
- "Compressible ranges (suggestions only \u2014 compress any consumed span; refs are surface seqs):",
753
+ renderTemplate(prompts.rangeTable.header, { surface: surfaceSummary(session) }),
754
+ renderTemplate(prompts.rangeTable.title, { count: ranges.length }),
605
755
  ...lines,
606
- "Compress with: compress({ content: [{ startSeq, endSeq, summary }] }) \u2014 content is an array: batch multiple unrelated segments in one call, each entry its own block. Keep ranges disjoint.",
607
- "Snapshot taken at nudge time: the seqs go stale once the surface moves (a later compress shadows them), so re-run acp_status for fresh refs before compressing."
756
+ prompts.rangeTable.footer
608
757
  ].join("\n");
609
758
  }
610
759
  function measuredTokenCount(agent, coreMessages) {
@@ -626,28 +775,112 @@ function buildNudge(agent, env, lastNudgeTurn) {
626
775
  const alreadyShown = !emergency && lastNudgeTurn.get(session.id) === turnNumber;
627
776
  if (alreadyShown) return null;
628
777
  lastNudgeTurn.set(session.id, turnNumber);
629
- const text = buildNudgeText(nudge, emergency, session);
778
+ const text = buildNudgeText(nudge, emergency, session, env.prompts);
630
779
  const message = createUserMessage2({
631
780
  content: [{ type: "text", text }],
632
781
  source: { kind: "plugin", plugin: "acp-nudge" }
633
782
  });
634
783
  return { message, emergency };
635
784
  }
636
- function buildNudgeText(nudge, emergency, session) {
785
+ function buildNudgeText(nudge, emergency, session, prompts = DEFAULT_RESOLVED) {
786
+ if (prompts.nudge !== DEFAULT_RESOLVED.nudge) {
787
+ return renderNudgeFromTemplates(nudge, emergency, session, prompts);
788
+ }
789
+ const rendered = renderNudgeText(nudge);
790
+ return adaptKernelNudgeToSeq(rendered.text, nudge, session, prompts);
791
+ }
792
+ function adaptKernelNudgeToSeq(text, nudge, session, prompts) {
793
+ let out = text;
794
+ if ((nudge.tier === 2 || nudge.tier === 3) && (nudge.tierTargetBlocks?.length ?? 0) > 0) {
795
+ out = replaceTierTrigger(out, nudge, session, prompts);
796
+ } else if (out.includes('"startId"')) {
797
+ out = replaceEmergencyExample(out);
798
+ }
799
+ const seqTable = rangeTable(session, prompts);
800
+ if (seqTable !== "") out = replaceRangesStr(out, seqTable);
801
+ return out;
802
+ }
803
+ function replaceRangesStr(text, seqTable) {
804
+ const match = text.match(/\n\n(?:Compressible ranges \(|\[No specific ranges detected)/);
805
+ if (!match) return text;
806
+ const start = match.index;
807
+ const rest = text.slice(start + 2);
808
+ const next = rest.match(/\n\n/);
809
+ const end = next !== null ? start + 2 + next.index : text.length;
810
+ const before = text.slice(0, start);
811
+ const after = text.slice(end);
812
+ return before + "\n" + seqTable + after;
813
+ }
814
+ function replaceTierTrigger(text, nudge, session, prompts) {
815
+ const start = text.search(/\n\n(?:\[TIER \d|\[EMERGENCY — TIER \d)/);
816
+ if (start === -1) return text;
817
+ const rest = text.slice(start + 2);
818
+ const next = rest.match(/\n\nHOW TO COMPRESS/);
819
+ const end = next !== null ? start + 2 + next.index : text.length;
820
+ const targets = nudge.tierTargetBlocks;
821
+ const summarySeqs = targets.map((block) => summarySeqOfKernelBlock(session, block.blockId)).filter((seq) => seq !== null);
822
+ const pending = nudge.tier === 2 ? nudge.breakdown?.pendingT2 : nudge.breakdown?.pendingT3;
823
+ const tokens = typeof pending === "number" ? pending : 0;
824
+ const tierValue = nudge.tier === null ? 2 : nudge.tier;
825
+ const tierLine = renderTemplate(prompts.nudge.tier, {
826
+ tier: tierValue,
827
+ count: targets.length,
828
+ prevTier: tierValue - 1,
829
+ tokens,
830
+ seqs: summarySeqs.join(", ")
831
+ });
832
+ return text.slice(0, start) + "\n\n" + tierLine + text.slice(end);
833
+ }
834
+ function replaceEmergencyExample(text) {
835
+ const start = text.search(/\n\n\{ "topic":/);
836
+ if (start === -1) return text;
837
+ const rest = text.slice(start + 2);
838
+ const next = rest.match(/\n\nCompressible ranges |\n\n\[No specific/);
839
+ const end = next !== null ? start + 2 + next.index : text.length;
840
+ return text.slice(0, start) + "\n\ncompress({ content: [{ startSeq, endSeq, summary }] }) \u2014 use the seqs from the range table above." + text.slice(end);
841
+ }
842
+ function renderNudgeFromTemplates(nudge, emergency, session, prompts) {
637
843
  const pct = Math.round(Math.min(nudge.contextUsage, 1) * 100);
638
- const frame = emergency ? `\u26A0\uFE0F Context usage is at ${pct}% of the window \u2014 nearly full. Consider compressing consumed ranges soon so working context stays available; the choice and timing are yours.` : `Context usage is at ${pct}%. This is a suggestion, not a requirement \u2014 you decide whether and when to compress.`;
639
- const guidance = "Compress by need, not by percentage: replace only ranges you have genuinely consumed, with dense self-contained summaries.";
640
- const parts = [frame, "", guidance];
844
+ const frame = renderTemplate(
845
+ emergency ? prompts.nudge.emergency : prompts.nudge.normal,
846
+ { pct, philosophy: COMPRESS_PHILOSOPHY2 }
847
+ );
848
+ const parts = [frame];
849
+ if (nudge.contextBreakdown) {
850
+ const bd = nudge.contextBreakdown;
851
+ const breakdown = renderTemplate(prompts.nudge.breakdown, {
852
+ system: Math.round(bd.system / 1e3),
853
+ tool: Math.round(bd.tool / 1e3),
854
+ summaries: Math.round(bd.summaries / 1e3),
855
+ code: Math.round(bd.code / 1e3),
856
+ text: Math.round(bd.text / 1e3)
857
+ });
858
+ if (breakdown !== "") parts.push("", breakdown);
859
+ if (bd.growth > 0) {
860
+ const growth = renderTemplate(prompts.nudge.growth, { growth: Math.round(bd.growth / 1e3) });
861
+ if (growth !== "") parts.push(growth);
862
+ }
863
+ }
864
+ if (prompts.nudge.guidance !== "") parts.push("", prompts.nudge.guidance);
641
865
  if ((nudge.tier === 2 || nudge.tier === 3) && (nudge.tierTargetBlocks?.length ?? 0) > 0) {
642
866
  const targets = nudge.tierTargetBlocks;
643
867
  const summarySeqs = targets.map((block) => summarySeqOfKernelBlock(session, block.blockId)).filter((seq) => seq !== null);
644
868
  const pending = nudge.tier === 2 ? nudge.breakdown?.pendingT2 : nudge.breakdown?.pendingT3;
645
869
  const tokens = typeof pending === "number" ? pending : 0;
646
- parts.push(
647
- `Tier ${nudge.tier}: ${targets.length} tier-${nudge.tier - 1} block(s) distillable (${tokens} tokens) \u2014 compress their summary node(s) [seqs ${summarySeqs.join(", ")}] to reclaim the original messages.`
648
- );
870
+ const tierLine = renderTemplate(prompts.nudge.tier, {
871
+ tier: nudge.tier,
872
+ count: targets.length,
873
+ prevTier: nudge.tier - 1,
874
+ tokens,
875
+ seqs: summarySeqs.join(", ")
876
+ });
877
+ if (tierLine !== "") parts.push(tierLine);
878
+ const tierRules = nudge.tier === 2 ? TIER2_DISTILL_RULES2 : TIER3_CONDENSE_RULES2;
879
+ parts.push("", tierRules);
880
+ } else {
881
+ parts.push(rangeTable(session, prompts));
649
882
  }
650
- parts.push(rangeTable(session));
883
+ if (prompts.nudge.tip !== "") parts.push("", prompts.nudge.tip);
651
884
  return parts.join("\n");
652
885
  }
653
886
 
@@ -691,11 +924,22 @@ function requireAgent(exec) {
691
924
  return exec.agent;
692
925
  }
693
926
  var compressParameters = {
927
+ // Tolerated wrapped-arguments form: some models emit
928
+ // `{ "arguments": "{\"content\": [...]}" }` (double-nested) or
929
+ // `{ "arguments": { "content": [...] } }` instead of the unwrapped
930
+ // `{ "content": [...] }`. The old DSH validator surfaced this as
931
+ // `invalid arguments: "arguments" must be an object` and the model retried
932
+ // forever. `arguments` is accepted as an optional JSON node so the wrapped
933
+ // shape passes schema validation; `handleCompress` unwraps it and falls back
934
+ // to a clear runtime error when neither form carries content. `content` is
935
+ // intentionally NOT `required: true` — a required property would reject the
936
+ // wrapped shape before `handleCompress` can see it. The tool description
937
+ // still tells the model content is mandatory.
938
+ arguments: { type: "json", description: "Tolerated wrapped-arguments form (model-generated); unwrapped in handleCompress. Prefer passing content directly." },
694
939
  topic: { type: "string", description: "Fallback topic for entries without their own." },
695
940
  content: {
696
941
  type: "array",
697
- required: true,
698
- description: "One or more ranges to compress, each with startSeq/endSeq boundaries (surface seqs) and a dense summary.",
942
+ description: "One or more ranges to compress, each with startSeq/endSeq boundaries (surface seqs) and a dense summary. Required \u2014 pass it directly, not wrapped in an arguments key.",
699
943
  items: {
700
944
  type: "object",
701
945
  properties: {
@@ -726,6 +970,22 @@ function parseSeq(value) {
726
970
  }
727
971
  return seq;
728
972
  }
973
+ function unwrapCompressArgs(args) {
974
+ if (args.content !== void 0) return args;
975
+ if (args.arguments === void 0) return null;
976
+ let inner = args.arguments;
977
+ if (typeof inner === "string") {
978
+ try {
979
+ inner = JSON.parse(inner);
980
+ } catch {
981
+ return null;
982
+ }
983
+ }
984
+ if (typeof inner !== "object" || inner === null || Array.isArray(inner)) return null;
985
+ const content = inner.content;
986
+ if (content === void 0) return null;
987
+ return { ...args, content };
988
+ }
729
989
  async function handleCompress(env, args, exec) {
730
990
  const agent = requireAgent(exec);
731
991
  const session = agent.session;
@@ -737,6 +997,13 @@ async function handleCompress(env, args, exec) {
737
997
  const turn = env.kernel.processTurn({ messages: coreMessages, state, config, tokenCount });
738
998
  env.store.set(session, turn.state);
739
999
  const byRaw = turn.state.messageRefs.byRaw;
1000
+ const unwrapped = unwrapCompressArgs(args);
1001
+ if (unwrapped === null) {
1002
+ return {
1003
+ text: "compress: missing content \u2014 pass the content array directly: compress({ content: [{ startSeq, endSeq, summary }] })"
1004
+ };
1005
+ }
1006
+ args = unwrapped;
740
1007
  const ranges = [];
741
1008
  const alreadyCompressedNotes = [];
742
1009
  for (const range of args.content) {
@@ -935,10 +1202,11 @@ async function handleStatus(env, _args, exec) {
935
1202
  return { text: lines.join("\n") };
936
1203
  }
937
1204
  function makeTools(env) {
1205
+ const prompts = env.prompts ?? DEFAULT_RESOLVED;
938
1206
  return [
939
1207
  defineTool({
940
1208
  name: "compress",
941
- description: "Replace older conversation ranges with dense summaries you write. Each message seq is a surface reference. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated ranges in one call (each content entry becomes its own block); keep ranges disjoint. Never compress content the current step is actively using. Seq refs must come from the CURRENT surface (acp_status or the latest nudge): a span whose edges were shadowed by an earlier compress is auto-remapped to its still-live content, a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.",
1209
+ description: prompts.tools.compress,
942
1210
  parameters: compressParameters,
943
1211
  output: textOutput(),
944
1212
  async execute(args, exec) {
@@ -947,7 +1215,7 @@ function makeTools(env) {
947
1215
  }),
948
1216
  defineTool({
949
1217
  name: "decompress",
950
- description: "Recover the original content of a compressed block by its blockId (read-only; does not unshadow the range).",
1218
+ description: prompts.tools.decompress,
951
1219
  parameters: decompressParameters,
952
1220
  output: textOutput(),
953
1221
  execute(args, exec) {
@@ -956,7 +1224,7 @@ function makeTools(env) {
956
1224
  }),
957
1225
  defineTool({
958
1226
  name: "search_context",
959
- description: "Search inside compressed blocks (summaries and original content) for information the model no longer sees in context.",
1227
+ description: prompts.tools.searchContext,
960
1228
  parameters: searchParameters,
961
1229
  output: textOutput(),
962
1230
  execute(args, exec) {
@@ -965,7 +1233,7 @@ function makeTools(env) {
965
1233
  }),
966
1234
  defineTool({
967
1235
  name: "acp_status",
968
- description: "Report the ACP block ledger: compressed blocks, reclaimed tokens, and current context pressure.",
1236
+ description: prompts.tools.acpStatus,
969
1237
  parameters: statusParameters,
970
1238
  output: textOutput(),
971
1239
  execute(args, exec) {
@@ -1062,22 +1330,7 @@ function acpCommand(env) {
1062
1330
  }
1063
1331
 
1064
1332
  // src/system-prompt.ts
1065
- import { COMPRESS_PHILOSOPHY } from "acp-kernel";
1066
- var ACP_SYSTEM_PROMPT = `Active Context Pruning \u2014 model-driven context management
1067
-
1068
- YOU decide whether and when to compress context. Nothing forces you: the injected "nudge" is a suggestion, not an order, and you may ignore it when compression would not help. Compress only ranges you have genuinely consumed (read tool outputs, finished explorations, superseded steps) that the current work no longer needs verbatim.
1069
-
1070
- ${COMPRESS_PHILOSOPHY}
1071
-
1072
- Compression tools (refs are SURFACE SEQS, not ids):
1073
- - compress: replace one or more seq ranges, each with your own dense summary. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated segments in one call (each entry becomes its own block): compress({ content: [{ startSeq: 1, endSeq: 5, summary: '...' }, { startSeq: 12, endSeq: 18, summary: '...' }] }). Keep ranges disjoint \u2014 overlapping entries in one batch are skipped. Edges are auto-balanced to tool-call/result boundaries; a trailing #callId fragment in a seq is ignored. Seq refs must be on the current surface: seqs from older nudges or earlier compresses go stale as the surface moves, so a stale span is auto-remapped to its still-live remainder (the result reports the adjusted span), a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.
1074
- - decompress: recover a compressed block's original content, read-only. decompress({ blockId }).
1075
- - search_context: find information inside compressed blocks BEFORE decompressing. search_context({ query }).
1076
- - acp_status: current context usage and the live compressible-range list. Run it right before compressing \u2014 the only seqs that never go stale are the ones you just read.
1077
-
1078
- Tiered compression: each compressed block appears on the surface as one summary node. Compressing that node again DISTILLS the block (tier 2): the parent summary folds into your new summary and the original messages are freed. Distilling a tier-2 block yields tier 3. Distill when a summary itself is consumed \u2014 decompress on the tier-2 block recovers the full originals.
1079
-
1080
- When you write a summary, it becomes the ONLY record of that range: keep file paths, signatures, exact values, decisions, and error strings verbatim so a later reader (or you, after decompress) can continue without the original. Never reuse historical seqs \u2014 the surface moves as messages land and compress; verify with acp_status.`;
1333
+ var ACP_SYSTEM_PROMPT = renderSystemPrompt(DEFAULT_PROMPTS);
1081
1334
  var ACP_SYSTEM_PROMPT_ORDER = 150;
1082
1335
 
1083
1336
  // src/index.ts
@@ -1104,12 +1357,15 @@ var AcpCompactionEngine = class extends CompactionEngine {
1104
1357
  store;
1105
1358
  /** Resolved engine configuration. */
1106
1359
  config;
1360
+ /** Resolved prompt templates (validated at construction — fail-fast on template typos). */
1361
+ prompts;
1107
1362
  lastNudgeTurn = /* @__PURE__ */ new Map();
1108
1363
  /** Per provider/model route the resolved window (probe failures cached too). */
1109
1364
  windowCache = /* @__PURE__ */ new Map();
1110
1365
  constructor(ctx, config = {}) {
1111
1366
  super(ctx);
1112
1367
  this.config = resolveAcpConfig(config);
1368
+ this.prompts = resolvePrompts(config.prompts);
1113
1369
  const ports = this.config.countTokens !== void 0 ? { countTokens: this.config.countTokens } : {};
1114
1370
  this.kernel = createCore(ports);
1115
1371
  this.store = new AcpStateStore();
@@ -1122,7 +1378,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
1122
1378
  nudgeMaxContextLimitPct: this.config.nudgeMaxContextLimitPct,
1123
1379
  nudgeEmergencyThresholdPct: this.config.nudgeEmergencyThresholdPct,
1124
1380
  coreOverrides: this.config.coreOverrides,
1125
- windowFor: (agent) => this.windowFor(agent)
1381
+ windowFor: (agent) => this.windowFor(agent),
1382
+ prompts: this.prompts
1126
1383
  };
1127
1384
  const tools = ctx.get("tools");
1128
1385
  if (tools !== void 0) {
@@ -1171,7 +1428,7 @@ var AcpCompactionEngine = class extends CompactionEngine {
1171
1428
  systemPrompt.section({
1172
1429
  name: "billion-context-dsh",
1173
1430
  order: ACP_SYSTEM_PROMPT_ORDER,
1174
- text: ACP_SYSTEM_PROMPT
1431
+ text: renderSystemPrompt(this.prompts)
1175
1432
  });
1176
1433
  } else {
1177
1434
  let done = false;
@@ -1183,7 +1440,7 @@ var AcpCompactionEngine = class extends CompactionEngine {
1183
1440
  registry.section({
1184
1441
  name: "billion-context-dsh",
1185
1442
  order: ACP_SYSTEM_PROMPT_ORDER,
1186
- text: ACP_SYSTEM_PROMPT
1443
+ text: renderSystemPrompt(this.prompts)
1187
1444
  });
1188
1445
  };
1189
1446
  ctx.on("internal/service", (name) => {
@@ -1248,6 +1505,8 @@ export {
1248
1505
  AcpStateStore,
1249
1506
  AlreadyCompressedRangeError,
1250
1507
  DEFAULT_CONTEXT_WINDOW,
1508
+ DEFAULT_PROMPTS,
1509
+ DEFAULT_RESOLVED,
1251
1510
  acpCommand,
1252
1511
  assertNoActiveCompaction,
1253
1512
  blockRefForSummarySeq,
@@ -1264,7 +1523,10 @@ export {
1264
1523
  makeTools,
1265
1524
  projectEvent,
1266
1525
  rebuildBlockLedger,
1526
+ renderSystemPrompt,
1527
+ renderTemplate,
1267
1528
  resolveAcpConfig,
1529
+ resolvePrompts,
1268
1530
  resolveSurfaceRange,
1269
1531
  resolveTokenCount,
1270
1532
  runCompactionTransaction,