pi-smart-compact 10.0.0 → 10.1.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 (54) hide show
  1. package/ARCHITECTURE.md +71 -29
  2. package/CHANGELOG.md +90 -4
  3. package/README.md +12 -9
  4. package/assets/skills/context-management/SKILL.md +5 -5
  5. package/dist/app/background-preparation.d.ts +13 -0
  6. package/dist/app/background-preparation.d.ts.map +1 -1
  7. package/dist/app/compaction-commit-store.d.ts +1 -1
  8. package/dist/app/compaction-commit-store.d.ts.map +1 -1
  9. package/dist/app/context-operations.d.ts +8 -2
  10. package/dist/app/context-operations.d.ts.map +1 -1
  11. package/dist/app/effective-state.d.ts.map +1 -1
  12. package/dist/app/hindsight-memory.d.ts.map +1 -1
  13. package/dist/app/lazy-tools.d.ts.map +1 -1
  14. package/dist/app/pending-slot.d.ts +2 -2
  15. package/dist/app/pending-slot.d.ts.map +1 -1
  16. package/dist/app/register-context-tools.d.ts.map +1 -1
  17. package/dist/app/register-navigation.d.ts +3 -1
  18. package/dist/app/register-navigation.d.ts.map +1 -1
  19. package/dist/app/register-smart-compact-command.d.ts.map +1 -1
  20. package/dist/app/register-smart-compact-tool.d.ts.map +1 -1
  21. package/dist/app/register-smart-context-tool.d.ts +1 -0
  22. package/dist/app/register-smart-context-tool.d.ts.map +1 -1
  23. package/dist/app/settled-auto-trigger.d.ts.map +1 -1
  24. package/dist/app/stage-auth.d.ts.map +1 -1
  25. package/dist/app/steps/synthesize.d.ts.map +1 -1
  26. package/dist/constants.d.ts +4 -3
  27. package/dist/constants.d.ts.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1342 -418
  30. package/dist/phases/explore.d.ts.map +1 -1
  31. package/dist/phases/synthesize.d.ts +1 -1
  32. package/dist/phases/synthesize.d.ts.map +1 -1
  33. package/dist/phases/verify.d.ts +12 -4
  34. package/dist/phases/verify.d.ts.map +1 -1
  35. package/dist/rtk.js +6 -5
  36. package/dist/types.d.ts +5 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/ui/profiles.d.ts +1 -1
  39. package/dist/ui/profiles.d.ts.map +1 -1
  40. package/dist/ui/settings-overlay.d.ts.map +1 -1
  41. package/dist/ui/tool-rows.d.ts +76 -0
  42. package/dist/ui/tool-rows.d.ts.map +1 -0
  43. package/dist/utils/cache.d.ts.map +1 -1
  44. package/dist/utils/config.d.ts.map +1 -1
  45. package/dist/utils/extraction.d.ts +21 -0
  46. package/dist/utils/extraction.d.ts.map +1 -1
  47. package/dist/utils/state.d.ts.map +1 -1
  48. package/docs/RELEASE.md +1 -2
  49. package/docs/configuration.md +95 -32
  50. package/docs/evaluation.md +15 -3
  51. package/docs/guide.md +65 -58
  52. package/package.json +1 -1
  53. package/dist/app/extension-conflicts.d.ts +0 -15
  54. package/dist/app/extension-conflicts.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  // src/constants.ts
8
- var VERSION = "10.0.0";
8
+ var VERSION = "10.1.0";
9
9
  var OPTIONAL_COMPONENTS = {
10
10
  mnemopi: { name: "@oh-my-pi/pi-mnemopi", version: "18.3.1", enables: "Mnemopi memory store" },
11
11
  bun: { name: "bun", version: "1.4.2", enables: "Mnemopi worker runtime when no Bun 1.3.14+ is on PATH" },
@@ -96,14 +96,14 @@ var DEFAULT_CONFIG = {
96
96
  summaryThinkingLevel: "minimal",
97
97
  segmentationThinkingLevel: "minimal",
98
98
  agentToolAccess: "inherit",
99
- toolLoading: "lazy",
99
+ toolLoading: "eager",
100
100
  autoTrigger: true,
101
101
  showStatus: true,
102
- autoTriggerStrategy: "native-hook",
102
+ autoTriggerStrategy: "settled",
103
103
  autoTriggerTimeoutMs: 120000,
104
104
  backupEnabled: true,
105
105
  backupDir: "",
106
- minContextPercent: 60,
106
+ minContextPercent: 80,
107
107
  prepareContextPercent: null,
108
108
  maxContextTokens: 0,
109
109
  requireApproval: true,
@@ -127,7 +127,8 @@ var DEFAULT_CONFIG = {
127
127
  mnemopiDataDir: null,
128
128
  visualArchiveEnabled: false,
129
129
  artifactOffloadEnabled: false,
130
- contextHygieneEnabled: false,
130
+ contextHygieneEnabled: true,
131
+ contextPressureOnly: true,
131
132
  contextNavigationEnabled: true,
132
133
  contextRecallEnabled: true,
133
134
  contextPivotEnabled: true,
@@ -1011,6 +1012,7 @@ var BOOLEAN_KEYS = [
1011
1012
  "visualArchiveEnabled",
1012
1013
  "artifactOffloadEnabled",
1013
1014
  "contextHygieneEnabled",
1015
+ "contextPressureOnly",
1014
1016
  "contextNavigationEnabled",
1015
1017
  "contextRecallEnabled",
1016
1018
  "contextPivotEnabled",
@@ -3310,6 +3312,15 @@ function isTransientToolDiagnostic(text) {
3310
3312
  const candidate = text.trim();
3311
3313
  return /\bBrave Search API error\s*\(429\)/i.test(candidate) || /\bnpm error code ENOLOCK\b/i.test(candidate) && /(?:audit|existing lockfile|loadVirtual)/i.test(candidate) || /^Found \d+ occurrences? of edits\[\d+\](?!\w)/i.test(candidate) || /^Unknown JSON field:/i.test(candidate) && /Available fields:/i.test(candidate);
3312
3314
  }
3315
+ function resolvesErrorResult(result, retry, tcIdx) {
3316
+ if (!result || result.role !== "toolResult" || result.isError)
3317
+ return false;
3318
+ const resultCall = tcIdx.get(result.toolCallId ?? "");
3319
+ const matches = retry.id == null ? Boolean(resultCall && sameToolOperation(retry, resultCall)) : result.toolCallId === retry.id;
3320
+ if (!matches)
3321
+ return false;
3322
+ return !(classifyToolOperation(retry.arguments, retry.name) === "execute" && hasCommandFailureSignal(extractText(result.content)));
3323
+ }
3313
3324
  function catalogErrors(msgs, _tcIdx) {
3314
3325
  const tcIdx = _tcIdx ?? buildToolCallIndex(msgs);
3315
3326
  const errors = [];
@@ -3353,28 +3364,45 @@ function catalogErrors(msgs, _tcIdx) {
3353
3364
  if (msgs[j]?.role !== "assistant")
3354
3365
  continue;
3355
3366
  const blocks = Array.isArray(msgs[j]?.content) ? msgs[j].content : [];
3356
- const retryTool = blocks.flatMap((block) => flattenToolCallBlock(block)).find((candidate) => sameToolOperation(failedCall, candidate));
3357
- if (!retryTool)
3367
+ const retryTools = blocks.flatMap((block) => flattenToolCallBlock(block)).filter((candidate) => sameToolOperation(failedCall, candidate));
3368
+ if (!retryTools.length)
3358
3369
  continue;
3359
3370
  err.retryAttempted = true;
3360
- for (let k = j + 1;k < Math.min(msgs.length, j + ERROR_RESOLVE_WINDOW); k++) {
3361
- const result = msgs[k];
3362
- if (result?.role !== "toolResult" || result.isError)
3363
- continue;
3364
- const resolved = retryTool.id == null ? (() => {
3365
- const resultCall = tcIdx.get(result.toolCallId ?? "");
3366
- return Boolean(resultCall && sameToolOperation(retryTool, resultCall));
3367
- })() : result.toolCallId === retryTool.id;
3368
- if (resolved) {
3369
- err.resolved = true;
3370
- break;
3371
+ for (const retryTool of retryTools) {
3372
+ for (let k = j + 1;k < Math.min(msgs.length, j + ERROR_RESOLVE_WINDOW); k++) {
3373
+ if (resolvesErrorResult(msgs[k], retryTool, tcIdx)) {
3374
+ err.resolved = true;
3375
+ break;
3376
+ }
3371
3377
  }
3378
+ if (err.resolved)
3379
+ break;
3372
3380
  }
3373
- break;
3381
+ if (err.resolved)
3382
+ break;
3374
3383
  }
3375
3384
  }
3376
3385
  return errors;
3377
3386
  }
3387
+ function collapseDecisionsByQuestion(items) {
3388
+ const order = [];
3389
+ const byQuestion = new Map;
3390
+ for (const item of items) {
3391
+ const key = normalizeFactKey(item.summary);
3392
+ if (!key)
3393
+ continue;
3394
+ const existing = byQuestion.get(key);
3395
+ if (!existing) {
3396
+ order.push(key);
3397
+ byQuestion.set(key, item);
3398
+ continue;
3399
+ }
3400
+ if (item.userResponse && item.userResponse.trim()) {
3401
+ byQuestion.set(key, item);
3402
+ }
3403
+ }
3404
+ return order.map((key) => byQuestion.get(key));
3405
+ }
3378
3406
  function extractDecisions(msgs, _tcIdx) {
3379
3407
  const tcIdx = _tcIdx ?? buildToolCallIndex(msgs);
3380
3408
  const decisions = [];
@@ -3385,7 +3413,7 @@ function extractDecisions(msgs, _tcIdx) {
3385
3413
  const question = typeof args === "string" ? args : args?.question ?? args?.prompt ?? "";
3386
3414
  if (!question)
3387
3415
  continue;
3388
- for (let i = tc.msgIndex + 1;i < Math.min(msgs.length, tc.msgIndex + 4); i++) {
3416
+ for (let i = tc.msgIndex + 1;i < msgs.length; i++) {
3389
3417
  if (msgs[i]?.role === "toolResult" && msgs[i]?.toolCallId === id) {
3390
3418
  decisions.push({
3391
3419
  index: tc.msgIndex,
@@ -3447,11 +3475,12 @@ function isDiagnosticConstraintText(text) {
3447
3475
  const candidate = text.replace(/^\s*[-*]\s+/, "").trim();
3448
3476
  return /^(?:\[[^\]]+\]\s*)?(?:npm\s+(?:error|warn|notice|audit|verbose|info)\b|(?:rg|grep):|command exited\b)/i.test(candidate);
3449
3477
  }
3450
- var OWN_OUTPUT_RE = /(?:verification stopped apply|yield check stopped apply|smart compact failed|do not bypass verification|conversation unchanged|review \/smart-compact metrics|restart pi with debug=smart-compact|smart compact: )/i;
3478
+ var OWN_OUTPUT_CONTEXT_RE = /(?:verification stopped apply|yield check stopped apply|smart compact failed|conversation unchanged|review \/smart-compact metrics|restart pi with debug=smart-compact|smart compact: )/i;
3479
+ var OWN_OUTPUT_PHRASE_RE = /do not bypass verification/i;
3451
3480
  var COMPLETED_CHECKLIST_RE = /^\[[xX\u2713\u2714]\]\s*\S/;
3452
3481
  function isNonLiveConstraintText(text) {
3453
3482
  const candidate = text.replace(/^\s*[-*]\s+/, "").trim();
3454
- return isDiagnosticConstraintText(candidate) || isCompactionStatusText(candidate) || OWN_OUTPUT_RE.test(candidate) || COMPLETED_CHECKLIST_RE.test(candidate);
3483
+ return isDiagnosticConstraintText(candidate) || isCompactionStatusText(candidate) || OWN_OUTPUT_CONTEXT_RE.test(candidate) || COMPLETED_CHECKLIST_RE.test(candidate);
3455
3484
  }
3456
3485
  function mineConstraints(msgs) {
3457
3486
  const constraints = [];
@@ -3462,10 +3491,13 @@ function mineConstraints(msgs) {
3462
3491
  const text = extractText(msgs[i].content);
3463
3492
  if (text.length < 10 || text.startsWith("/"))
3464
3493
  continue;
3494
+ const ownOutputMessage = OWN_OUTPUT_CONTEXT_RE.test(text);
3465
3495
  for (const raw of text.split(/\n+/)) {
3466
3496
  const candidate = raw.replace(/^\s*[-*]\s+/, "").trim();
3467
3497
  if (candidate.length < 10 || isNonLiveConstraintText(candidate))
3468
3498
  continue;
3499
+ if (ownOutputMessage && OWN_OUTPUT_PHRASE_RE.test(candidate))
3500
+ continue;
3469
3501
  for (const { re, cat, conf } of CONSTRAINT_PATTERNS) {
3470
3502
  if (!re.test(candidate))
3471
3503
  continue;
@@ -4726,22 +4758,22 @@ function reconcileCachedErrors(errors, deltaMessages, deltaToolCalls, baseMsgCou
4726
4758
  const message = deltaMessages[j];
4727
4759
  if (message.role !== "assistant" || !Array.isArray(message.content))
4728
4760
  continue;
4729
- const retry = message.content.flatMap(flattenToolCallBlock).find((call) => toolOperationSignature(call.name, call.arguments) === error.operationSignature);
4730
- if (!retry)
4761
+ const retries = message.content.flatMap(flattenToolCallBlock).filter((call) => toolOperationSignature(call.name, call.arguments) === error.operationSignature);
4762
+ if (!retries.length)
4731
4763
  continue;
4732
4764
  retryAttempted = true;
4733
- for (let k = j + 1;k < Math.min(deltaMessages.length, j + ERROR_RESOLVE_WINDOW); k++) {
4734
- const result = deltaMessages[k];
4735
- if (result.role !== "toolResult" || result.isError)
4736
- continue;
4737
- const resultCall = deltaToolCalls.get(result.toolCallId ?? "");
4738
- const matches = retry.id == null ? Boolean(resultCall && toolOperationSignature(resultCall.name, resultCall.arguments) === error.operationSignature) : result.toolCallId === retry.id;
4739
- if (matches) {
4740
- resolved = true;
4741
- break;
4765
+ for (const retry of retries) {
4766
+ for (let k = j + 1;k < Math.min(deltaMessages.length, j + ERROR_RESOLVE_WINDOW); k++) {
4767
+ if (resolvesErrorResult(deltaMessages[k], retry, deltaToolCalls)) {
4768
+ resolved = true;
4769
+ break;
4770
+ }
4742
4771
  }
4772
+ if (resolved)
4773
+ break;
4743
4774
  }
4744
- break;
4775
+ if (resolved)
4776
+ break;
4745
4777
  }
4746
4778
  return { ...error, retryAttempted, resolved };
4747
4779
  });
@@ -6579,7 +6611,7 @@ function formatVerificationGap(gap) {
6579
6611
  case "missing-constraint":
6580
6612
  return "Missing constraint: " + gap.text.slice(0, TRUNC.TOPIC_LABEL);
6581
6613
  case "missing-decision":
6582
- return "Missing decision: " + gap.summary.slice(0, TRUNC.TOPIC_LABEL);
6614
+ return "Missing decision: " + gap.summary.slice(0, TRUNC.TOPIC_LABEL) + (gap.answer ? " \u2192 " + gap.answer.slice(0, TRUNC.TOPIC_LABEL) : "");
6583
6615
  case "missing-goal":
6584
6616
  return "Main goal may be missing from summary";
6585
6617
  case "fabricated-file":
@@ -6757,7 +6789,8 @@ function stemToken(token) {
6757
6789
  return lower;
6758
6790
  }
6759
6791
  function semanticTokens(text) {
6760
- return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2 || NEGATION_MARKERS.has(token));
6792
+ const normalized = text.normalize("NFKC").replace(/[\u{2018}\u{2019}\u{02BC}`\u{00B4}]/gu, "'").replace(/\bcannot\b/gi, "can not").replace(/\b(\w+)n't\b/gi, "$1 not").replace(/^\s*(?:Constraint|Goal|Decision):\s*/i, "");
6793
+ return (normalized.match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2 || NEGATION_MARKERS.has(token));
6761
6794
  }
6762
6795
  var semanticShapeCache = new Map;
6763
6796
  var semanticFragmentCache = new Map;
@@ -6808,7 +6841,10 @@ function hasSemanticEvidence(source, target) {
6808
6841
  if (!concepts.length)
6809
6842
  return true;
6810
6843
  const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
6844
+ const verbatim = sourceTokens.join(" ");
6811
6845
  return semanticFragments(target).some((tokens) => {
6846
+ if (tokens.join(" ") === verbatim)
6847
+ return true;
6812
6848
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
6813
6849
  if (overlap < required)
6814
6850
  return false;
@@ -6848,15 +6884,114 @@ function hasSemanticContradiction(source, target) {
6848
6884
  });
6849
6885
  }
6850
6886
  var TEMPORAL_CONSTRAINT_RE = /\b(?:yet|until|before|first|for now|hold off|wait|hen\u00FCz|\u015Fimdilik|\u00F6nce)\b/iu;
6887
+ var WH_QUESTION_LEAD_RE = /^(?:why|what|how|when|where|who|whom|which|whose|neden|ni\u00e7in|niye|nas\u0131l|hangi|kim)\b/iu;
6888
+ var AUX_QUESTION_LEAD_RE = /^(?:do|does|did|can|could|should|would|will|has|have|had|is|are|was|were|am)\b/i;
6889
+ var QUESTION_SUBJECT_RE = /^(?:you|u|i|we|it|they|he|she|this|that|these|those|there)\b/i;
6890
+ function looksLikeQuestion(text) {
6891
+ return text.split(/\r?\n/).some((line) => {
6892
+ const trimmed = line.trim();
6893
+ if (trimmed.endsWith("?"))
6894
+ return true;
6895
+ if (WH_QUESTION_LEAD_RE.test(trimmed))
6896
+ return true;
6897
+ const rest = trimmed.replace(AUX_QUESTION_LEAD_RE, "").trim();
6898
+ return AUX_QUESTION_LEAD_RE.test(trimmed) && QUESTION_SUBJECT_RE.test(rest);
6899
+ });
6900
+ }
6901
+ var FIRST_PERSON_GRANT_RE = /\b(?:i|we)(?:'ve|\s+have)?\s+(?:approv\w+|allow\w+|permitt?\w*|ok(?:ay(?:ed)?)?|confirm\w+|green[- ]?light\w*|sanction\w*)\b/iu;
6902
+ var QUOTATION_RE = /\b(?:the log|logs?\b|the (?:output|console|error|diff|dashboard)|according to|it says|it said|they said|reported that|quoted?)\b/iu;
6903
+ var DENIAL_RE = /\b(?:denied|deny|refus\w*|forbid\w*|retract\w+|did not ask|didn't ask|never asked|never said|not allowed|not permitted|no permission|without permission|wasn't asked|weren't asked)\b/iu;
6904
+ var CRITICISM_RE = /\b(?:why|how come|explain(?:ing|ed)?|tell me|show me|describe|justify|what were you|supposed to|weren't you|shouldn't you|neden|ni\u00e7in|niye|a\u00e7\u0131kla|anlat|nas\u0131l)\b/iu;
6905
+ var ACK_STRIP_RE = /^(?:ok(?:ay)?|okey|yes|yep|sure|alright|right|now|actually|please|just|go ahead|go for it|proceed|do it|ship it|approved?|alright then|tamam|olur|onay(?:l\u0131yorum|lanm\u0131\u015ft\u0131r| ver)?|devam(?:\s+et)?|ba\u015fla)[,;:.!\s]+/i;
6906
+ var POLICY_GRANT_PREDICATE_RE = /\b(?:are|is|was|were|be|been|remain(?:s|ed)?)\s+(?:now\s+|hereby\s+|officially\s+)?(?:allowed|permitted|approved|authorized|acceptable|optional|fine|okay|ok|enabled|lifted)|\bno longer\s+(?:required|needed|banned|prohibited|forbidden|necessary)|\bcan\s+be\b|\bmay\s+be\b/i;
6907
+ function addresseeModalGrant(anchor) {
6908
+ return new RegExp("\\b(?:you|y'?all|we|i)\\s+(?:can|could|may|might)\\s+(?:now\\s+|also\\s+|please\\s+|just\\s+)*(?:[\\p{L}]+\\s+){0,2}" + anchor.replace(/[^\p{L}\p{N}_-]/gu, "\\$&") + "\\b", "iu");
6909
+ }
6910
+ var IMPERATIVE_LEAD_TOKENS = new Set([
6911
+ "do",
6912
+ "does",
6913
+ "did",
6914
+ "please",
6915
+ "now",
6916
+ "just",
6917
+ "go"
6918
+ ]);
6919
+ var DIRECTIVE_COMPLEMENTS = new Set([
6920
+ "it",
6921
+ "them",
6922
+ "this",
6923
+ "that",
6924
+ "these",
6925
+ "those",
6926
+ "everything",
6927
+ "now",
6928
+ "again",
6929
+ "please",
6930
+ "immediately",
6931
+ "today",
6932
+ "tonight",
6933
+ "away",
6934
+ "ahead",
6935
+ "the",
6936
+ "a",
6937
+ "an",
6938
+ "our",
6939
+ "your",
6940
+ "my",
6941
+ "his",
6942
+ "her",
6943
+ "their"
6944
+ ]);
6945
+ function anchorFollowedByComplement(clause, anchor) {
6946
+ const words = clause.split(/\s+/).map((word) => word.replace(/(^\W+|\W+$)/g, "").toLowerCase()).filter(Boolean);
6947
+ for (let i = 0;i < words.length; i++) {
6948
+ if (stemToken(words[i]) !== anchor)
6949
+ continue;
6950
+ const next = words[i + 1];
6951
+ return next !== undefined && DIRECTIVE_COMPLEMENTS.has(stemToken(next));
6952
+ }
6953
+ return false;
6954
+ }
6955
+ function positiveImperativeEvidence(release, anchor, allowImperative) {
6956
+ let rest = release.trim();
6957
+ for (let round = 0;round < 3 && ACK_STRIP_RE.test(rest); round++)
6958
+ rest = rest.replace(ACK_STRIP_RE, "").trim();
6959
+ const modalGrant = addresseeModalGrant(anchor);
6960
+ const clauses = rest.split(/\r?\n|[.;]/).flatMap((fragment) => fragment.split(/\s(?:[\u2014\u2013]|-)\s|,\s|;\s|:\s/)).map((clause) => clause.trim()).filter(Boolean);
6961
+ return clauses.some((clause) => {
6962
+ const tokens = semanticTokens(clause);
6963
+ if (!tokens.includes(anchor) || hasEffectiveTargetNegation(tokens, anchor))
6964
+ return false;
6965
+ if (allowImperative && IMPERATIVE_LEAD_TOKENS.has(tokens[0] ?? "") && tokens[1] === anchor)
6966
+ return true;
6967
+ if (POLICY_GRANT_PREDICATE_RE.test(clause))
6968
+ return true;
6969
+ if (modalGrant.test(clause))
6970
+ return true;
6971
+ if (allowImperative && tokens[0] === anchor && anchorFollowedByComplement(clause, anchor))
6972
+ return true;
6973
+ return false;
6974
+ });
6975
+ }
6851
6976
  function releasesConstraint(constraint, release) {
6852
- if (hasSemanticContradiction(constraint, release))
6853
- return true;
6854
- if (!TEMPORAL_CONSTRAINT_RE.test(constraint))
6977
+ if (looksLikeQuestion(release))
6978
+ return false;
6979
+ if (CRITICISM_RE.test(release))
6980
+ return false;
6981
+ if (QUOTATION_RE.test(release))
6982
+ return false;
6983
+ if (DENIAL_RE.test(release))
6855
6984
  return false;
6856
6985
  const { anchor, negative } = semanticShape(constraint);
6986
+ const deferral = TEMPORAL_CONSTRAINT_RE.test(constraint);
6987
+ if (hasSemanticContradiction(constraint, release)) {
6988
+ return FIRST_PERSON_GRANT_RE.test(release) || Boolean(anchor) && positiveImperativeEvidence(release, anchor, deferral);
6989
+ }
6990
+ if (!deferral)
6991
+ return false;
6857
6992
  if (!anchor || !negative)
6858
6993
  return false;
6859
- return semanticFragments(release).some((tokens) => tokens.includes(anchor) && !hasEffectiveTargetNegation(tokens, anchor));
6994
+ return positiveImperativeEvidence(release, anchor, true);
6860
6995
  }
6861
6996
  function isDeterministicallyPatchable(gap) {
6862
6997
  if (gap.kind === "fabricated-file")
@@ -6887,6 +7022,105 @@ function repairSummaryDeterministically(summary, result, extraction, continuity
6887
7022
  }
6888
7023
  return { summary, result, patched };
6889
7024
  }
7025
+ function questionOverlapOnLine(question, line) {
7026
+ const { concepts } = semanticShape(question);
7027
+ if (!concepts.length)
7028
+ return 0;
7029
+ const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
7030
+ const tokens = semanticTokens(line);
7031
+ const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
7032
+ return overlap >= required ? overlap : 0;
7033
+ }
7034
+ function exactQuestionOwner(head, questions) {
7035
+ const headKey = normalizeFactKey(head);
7036
+ if (!headKey)
7037
+ return null;
7038
+ let owner = null;
7039
+ let ownerLength = -1;
7040
+ for (const question of questions) {
7041
+ const key = normalizeFactKey(question);
7042
+ if (key && headKey.includes(key) && key.length > ownerLength) {
7043
+ owner = question;
7044
+ ownerLength = key.length;
7045
+ }
7046
+ }
7047
+ return owner;
7048
+ }
7049
+ function lineBelongsToQuestion(question, head, questions) {
7050
+ if (exactQuestionOwner(head, questions) === question)
7051
+ return true;
7052
+ const own = questionOverlapOnLine(question, head);
7053
+ if (!own)
7054
+ return false;
7055
+ return questions.every((other) => other === question || questionOverlapOnLine(other, head) < own);
7056
+ }
7057
+ function containsAnswerText(haystack, answer) {
7058
+ const needle = answer.trim().toLowerCase();
7059
+ if (!needle)
7060
+ return false;
7061
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7062
+ return new RegExp("(?<![\\p{L}\\p{N}])" + escaped + "(?![\\p{L}\\p{N}])", "iu").test(haystack);
7063
+ }
7064
+ function lineCarriesAnswer(haystack, answer) {
7065
+ if (containsAnswerText(haystack, answer))
7066
+ return true;
7067
+ const { concepts } = semanticShape(answer);
7068
+ return concepts.length > 0 && hasSemanticEvidence(answer, haystack);
7069
+ }
7070
+ function decisionLineSlots(line, question, questions) {
7071
+ let fuzzy = null;
7072
+ for (let index = line.indexOf("\u2192");index >= 0; index = line.indexOf("\u2192", index + 1)) {
7073
+ const candidateHead = line.slice(0, index).replace(/\*+/g, "").trim();
7074
+ const tail = line.slice(index + 1).replace(/\*+/g, "").trim();
7075
+ if (!candidateHead || !tail)
7076
+ continue;
7077
+ if (exactQuestionOwner(candidateHead, questions) === question) {
7078
+ return { head: candidateHead, tail };
7079
+ }
7080
+ if (!fuzzy && lineBelongsToQuestion(question, candidateHead, questions)) {
7081
+ fuzzy = { head: candidateHead, tail };
7082
+ }
7083
+ }
7084
+ return fuzzy;
7085
+ }
7086
+ function stripDecisionAnswerTails(decisionBody, questions) {
7087
+ return decisionBody.split(/\r?\n/).map((line) => {
7088
+ let fuzzyCut = -1;
7089
+ for (let index = line.indexOf("\u2192");index >= 0; index = line.indexOf("\u2192", index + 1)) {
7090
+ const candidateHead = line.slice(0, index).replace(/\*+/g, "").trim();
7091
+ if (!candidateHead)
7092
+ continue;
7093
+ if (exactQuestionOwner(candidateHead, questions) !== null) {
7094
+ return line.slice(0, index);
7095
+ }
7096
+ if (fuzzyCut < 0 && questions.some((question) => lineBelongsToQuestion(question, candidateHead, questions))) {
7097
+ fuzzyCut = index;
7098
+ }
7099
+ }
7100
+ return fuzzyCut >= 0 ? line.slice(0, fuzzyCut) : line;
7101
+ }).join(`
7102
+ `);
7103
+ }
7104
+ function decisionAnswerPresent(question, answer, decisionBody, questions) {
7105
+ return decisionBody.split(/\r?\n/).some((rawLine) => {
7106
+ const line = rawLine.replace(/^\s*(?:[-*+]\s+)?/, "").trim();
7107
+ return decisionLineSlots(line, question, questions) !== null && lineCarriesAnswer(line, answer);
7108
+ });
7109
+ }
7110
+ function decisionAnswerConflict(question, answer, decisionBody, questions) {
7111
+ for (const rawLine of decisionBody.split(/\r?\n/)) {
7112
+ const line = rawLine.replace(/^\s*(?:[-*+]\s+)?/, "").trim();
7113
+ const slots = decisionLineSlots(line, question, questions);
7114
+ if (!slots || !slots.tail)
7115
+ continue;
7116
+ if (hasSemanticEvidence(question, slots.tail))
7117
+ continue;
7118
+ if (lineCarriesAnswer(slots.tail, answer))
7119
+ continue;
7120
+ return true;
7121
+ }
7122
+ return false;
7123
+ }
6890
7124
  function addGap(accumulator, gap, penalty) {
6891
7125
  accumulator.gaps.push(gap);
6892
7126
  accumulator.score -= penalty;
@@ -6929,10 +7163,14 @@ function collectVerificationEvidence(extraction, continuity, evidence) {
6929
7163
  ...(continuity?.constraints ?? []).flatMap((item) => item.confidence >= 0.8 ? [{ text: item.text }] : []),
6930
7164
  ...steeringConstraints
6931
7165
  ], (item) => item.text).filter((item) => !isNonLiveConstraintText(item.text) && !retired.has(normalizeFactKey(item.text)));
6932
- const decisions = uniqueByText([
6933
- ...extraction.decisions.flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : []),
6934
- ...(continuity?.decisions ?? []).flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : [])
6935
- ], (item) => item.summary);
7166
+ const decisionEntries = [
7167
+ ...continuity?.decisions ?? [],
7168
+ ...extraction.decisions
7169
+ ].flatMap((item) => item.type === "explicit" ? [{ summary: item.summary, userResponse: item.userResponse }] : []);
7170
+ const decisions = collapseDecisionsByQuestion(decisionEntries).map((item) => ({
7171
+ summary: item.summary,
7172
+ answer: item.userResponse?.trim() || null
7173
+ }));
6936
7174
  return {
6937
7175
  unresolved,
6938
7176
  resolved,
@@ -7042,11 +7280,25 @@ function verifySemanticCoverage(parsed, collected, accumulator) {
7042
7280
  }
7043
7281
  }
7044
7282
  const decisionBody = findSection(parsed, "decisions")?.body ?? "";
7283
+ const questions = collected.decisions.map((decision) => decision.summary);
7284
+ const decisionQuestionBody = stripDecisionAnswerTails(decisionBody, questions);
7045
7285
  for (const decision of collected.decisions) {
7046
- if (!hasSemanticEvidence(decision.summary, decisionBody)) {
7047
- addGap(accumulator, { kind: "missing-decision", summary: decision.summary }, 8);
7286
+ const questionVerified = hasSemanticEvidence(decision.summary, decisionQuestionBody);
7287
+ const answerVerified = !decision.answer || decisionAnswerPresent(decision.summary, decision.answer, decisionBody, questions);
7288
+ if (!questionVerified || !answerVerified) {
7289
+ addGap(accumulator, {
7290
+ kind: "missing-decision",
7291
+ summary: decision.summary,
7292
+ ...decision.answer ? { answer: decision.answer } : {}
7293
+ }, 8);
7048
7294
  }
7049
- if (hasSemanticContradiction(decision.summary, decisionBody)) {
7295
+ if (decision.answer && decisionAnswerConflict(decision.summary, decision.answer, decisionBody, questions)) {
7296
+ addGap(accumulator, {
7297
+ kind: "inconsistency",
7298
+ detail: "decision-answer-conflict: summary carries a different active answer for " + decision.summary.slice(0, TRUNC.SNIPPET)
7299
+ }, 20);
7300
+ }
7301
+ if (hasSemanticContradiction(decision.summary, decisionQuestionBody)) {
7050
7302
  addGap(accumulator, {
7051
7303
  kind: "inconsistency",
7052
7304
  detail: "semantic-contradiction: decision contradicts " + decision.summary.slice(0, TRUNC.SNIPPET)
@@ -7059,7 +7311,7 @@ function verifyFileReferences(summary, extraction, continuity, evidence, collect
7059
7311
  ...collected.unresolved.map((item) => item.message),
7060
7312
  ...collected.resolved.map((item) => item.message),
7061
7313
  ...collected.constraints.map((item) => item.text),
7062
- ...collected.decisions.map((item) => item.summary),
7314
+ ...collected.decisions.map((item) => item.answer ? item.summary + " \u2192 " + item.answer : item.summary),
7063
7315
  ...collected.goal ? [collected.goal] : [],
7064
7316
  ...extraction.lastUserMessages,
7065
7317
  ...extraction.timeline.map((item) => item.summary),
@@ -7248,9 +7500,12 @@ function patchDeterministic(summary, gaps, extraction, continuity = null, eviden
7248
7500
  case "missing-constraint":
7249
7501
  canonical = appendToSection(canonical, "constraints", "- " + safe(gap.text, TRUNC.CONSTRAINT_TEXT));
7250
7502
  break;
7251
- case "missing-decision":
7252
- canonical = appendToSection(canonical, "decisions", "- **" + safe(gap.summary, TRUNC.DECISION_SUMMARY) + "**");
7503
+ case "missing-decision": {
7504
+ const question = safe(gap.summary, TRUNC.DECISION_SUMMARY);
7505
+ const answer = gap.answer ? safe(gap.answer, TRUNC.USER_RESPONSE) : "";
7506
+ canonical = appendToSection(canonical, "decisions", "- **" + question + "**" + (answer ? " \u2192 " + answer : ""));
7253
7507
  break;
7508
+ }
7254
7509
  case "missing-goal":
7255
7510
  canonical = upsertSection(canonical, "goal", safe(gap.goal, TRUNC.DETAIL) || "Continue the current task.");
7256
7511
  break;
@@ -7309,9 +7564,12 @@ function hasUnclosedMarkdownFence(markdown) {
7309
7564
  }
7310
7565
  return open !== null;
7311
7566
  }
7567
+ var TERMINAL_STOP_REASON_RE = /^(?:stop|endturn)$/i;
7312
7568
  function patchResponseIsTruncated(patched, stopReason) {
7313
- const reason = String(stopReason ?? "");
7314
- return /(?:length|truncat|max(?:imum)?[_ -]?(?:output[_ -]?)?tokens?|token[_ -]?limit)/i.test(reason) || /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
7569
+ const reason = String(stopReason ?? "").trim();
7570
+ if (reason && !TERMINAL_STOP_REASON_RE.test(reason))
7571
+ return true;
7572
+ return /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
7315
7573
  }
7316
7574
  function sectionIdentity(section) {
7317
7575
  return section.kind === "unknown" ? "unknown:" + section.heading.trim().toLowerCase() : section.kind;
@@ -7651,7 +7909,11 @@ function mergeOpenLoops(current, previous) {
7651
7909
  function mergeCompactionStates(previous, current) {
7652
7910
  if (!previous) {
7653
7911
  const active = applyContinuityOverrides(current, current.factOverrides ?? []);
7654
- return { ...active, openLoops: mergeOpenLoops(active.openLoops, []) };
7912
+ return {
7913
+ ...active,
7914
+ decisions: collapseDecisionsByQuestion(active.decisions).map((item, index) => ({ ...item, id: ID_PREFIX.DECISION + (index + 1) })),
7915
+ openLoops: mergeOpenLoops(active.openLoops, [])
7916
+ };
7655
7917
  }
7656
7918
  const factOverrides = mergeBy(current.factOverrides ?? [], previous.factOverrides ?? [], (item) => item.kind + ":" + item.summaryKey, 50);
7657
7919
  const activeCurrent = applyContinuityOverrides(current, factOverrides);
@@ -7671,7 +7933,17 @@ function mergeCompactionStates(previous, current) {
7671
7933
  }
7672
7934
  return false;
7673
7935
  };
7674
- const decisions = mergeBy(activeCurrent.decisions, activePrevious.decisions, (item) => normalizeFactKey(item.summary), 30).map((item, index) => ({ ...item, id: ID_PREFIX.DECISION + (index + 1) }));
7936
+ const collapsedCurrent = collapseDecisionsByQuestion(activeCurrent.decisions);
7937
+ const collapsedPrevious = collapseDecisionsByQuestion(activePrevious.decisions);
7938
+ const authoritative = collapseDecisionsByQuestion([
7939
+ ...collapsedPrevious,
7940
+ ...collapsedCurrent
7941
+ ]);
7942
+ const currentKeys = new Set(collapsedCurrent.map((item) => normalizeFactKey(item.summary)));
7943
+ const decisions = [
7944
+ ...authoritative.filter((item) => currentKeys.has(normalizeFactKey(item.summary))),
7945
+ ...authoritative.filter((item) => !currentKeys.has(normalizeFactKey(item.summary)))
7946
+ ].slice(0, 30).map((item, index) => ({ ...item, id: ID_PREFIX.DECISION + (index + 1) }));
7675
7947
  const constraints = mergeBy(activeCurrent.constraints, activePrevious.constraints, (item) => normalizeFactKey(item.text), 30).map((item, index) => ({ ...item, id: "constraint-" + (index + 1) }));
7676
7948
  const unresolvedErrors = mergeBy(activeCurrent.unresolvedErrors, activePrevious.unresolvedErrors.filter((error) => !resolvedKeys.has(normalizeFactKey(error.message))), (item) => normalizeFactKey(item.message), 15).map((item, index) => ({ ...item, id: ID_PREFIX.ERROR + (index + 1) }));
7677
7949
  const openLoops = mergeOpenLoops(activeCurrent.openLoops, activePrevious.openLoops.map((loop) => isResolvedLoop(loop) ? { ...loop, status: "resolved" } : loop));
@@ -7703,8 +7975,8 @@ function mergeCompactionStates(previous, current) {
7703
7975
  function renderContinuityCapsule(state, maxChars = TRUNC.CONTINUITY_CAPSULE, existing = "") {
7704
7976
  const haystack = normalizeFactKey(existing);
7705
7977
  const lines = ["## Continuity Ledger"];
7706
- const add = (label, value) => {
7707
- const text = summaryEvidenceLine(value, TRUNC.MESSAGE);
7978
+ const add = (label, value, maxLength = TRUNC.MESSAGE) => {
7979
+ const text = summaryEvidenceLine(value, maxLength);
7708
7980
  if (!text || haystack.includes(normalizeFactKey(text)))
7709
7981
  return;
7710
7982
  const line = "- " + label + ": " + text;
@@ -7716,8 +7988,13 @@ function renderContinuityCapsule(state, maxChars = TRUNC.CONTINUITY_CAPSULE, exi
7716
7988
  add("Goal", state.goal);
7717
7989
  for (const item of state.constraints)
7718
7990
  add("Constraint", item.text);
7719
- for (const item of state.decisions)
7720
- add("Decision", item.summary + (item.userResponse ? " \u2192 " + item.userResponse : ""));
7991
+ for (const item of state.decisions) {
7992
+ const question = summaryEvidenceLine(item.summary, TRUNC.DECISION_SUMMARY);
7993
+ const answer = item.userResponse ? summaryEvidenceLine(item.userResponse, TRUNC.USER_RESPONSE) : "";
7994
+ const decisionLine = question + (answer ? " \u2192 " + answer : "");
7995
+ if (decisionLine)
7996
+ add("Decision", decisionLine, decisionLine.length);
7997
+ }
7721
7998
  for (const item of state.unresolvedErrors)
7722
7999
  add("Unresolved error", item.message);
7723
8000
  for (const item of state.resolvedErrors.slice(-5))
@@ -8258,7 +8535,16 @@ function pendingMatchesBranch(pending, branch) {
8258
8535
  }
8259
8536
  function readerSignature(ctx) {
8260
8537
  const model = ctx.model;
8261
- return model && JSON.stringify([model.provider, model.id, model.api, model.baseUrl, model.contextWindow, model.maxTokens]);
8538
+ const prompt = ctx.getSystemPrompt?.();
8539
+ return model && JSON.stringify([
8540
+ model.provider,
8541
+ model.id,
8542
+ model.api,
8543
+ model.baseUrl,
8544
+ model.contextWindow,
8545
+ model.maxTokens,
8546
+ prompt === undefined ? null : createHash4("sha256").update(prompt).digest("hex")
8547
+ ]);
8262
8548
  }
8263
8549
  function revalidatePending(pending, ctx, reserveTokens = 0) {
8264
8550
  const model = ctx.model;
@@ -8298,7 +8584,7 @@ function revalidatePending(pending, ctx, reserveTokens = 0) {
8298
8584
  };
8299
8585
  }
8300
8586
  function createPendingSlot(opts) {
8301
- const ttlMs = opts.ttlMs;
8587
+ const ttlMs = () => typeof opts.ttlMs === "function" ? opts.ttlMs() : opts.ttlMs;
8302
8588
  const now = opts.now ?? Date.now;
8303
8589
  const maxEntries = Math.max(1, opts.maxEntries ?? 64);
8304
8590
  const entries = new Map;
@@ -8318,7 +8604,7 @@ function createPendingSlot(opts) {
8318
8604
  const current = now();
8319
8605
  let removedNewest = false;
8320
8606
  for (const [sessionId, entry] of entries) {
8321
- if (current - entry.createdAt <= ttlMs)
8607
+ if (current - entry.createdAt <= ttlMs())
8322
8608
  continue;
8323
8609
  entries.delete(sessionId);
8324
8610
  if (newestSessionId === sessionId)
@@ -8345,7 +8631,7 @@ function createPendingSlot(opts) {
8345
8631
  const entry = entries.get(currentSessionId);
8346
8632
  if (entry) {
8347
8633
  const ageMs = now() - entry.createdAt;
8348
- if (ageMs > ttlMs) {
8634
+ if (ageMs > ttlMs()) {
8349
8635
  deleteEntry(currentSessionId);
8350
8636
  prune();
8351
8637
  return { kind: "expired", ageMs };
@@ -10758,6 +11044,7 @@ function buildExplorerRequest(extraction, model, prevSummary, userNote) {
10758
11044
  function explorationToolSupportKey(model) {
10759
11045
  return [model.provider, model.api, model.baseUrl ?? "", model.id].join("\x00");
10760
11046
  }
11047
+ var SYNTHESIS_CALL_RESERVE = 2;
10761
11048
  async function exploreConversation(llmMessages, extraction, model, auth, prevSummary, userNote, signal, maxRounds = MAX_EXPLORATION_ROUNDS, notify, services) {
10762
11049
  const svc = services ?? getDefaultServices();
10763
11050
  const request = buildExplorerRequest(extraction, model, prevSummary, userNote);
@@ -10768,6 +11055,10 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
10768
11055
  const now = svc.clock.now();
10769
11056
  const cachedSupport = toolSupport.get(cacheKey, now);
10770
11057
  let supportsTools = cachedSupport === true;
11058
+ if (svc.budget.remainingCalls() <= SYNTHESIS_CALL_RESERVE) {
11059
+ notify?.("Explore skipped \xB7 remaining calls reserved for synthesis", "info");
11060
+ return { report: fallbackExplorationReport(llmMessages), rounds: 0, toolSupported: supportsTools };
11061
+ }
10771
11062
  try {
10772
11063
  if (cachedSupport === false) {
10773
11064
  if (notify)
@@ -10810,7 +11101,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
10810
11101
  });
10811
11102
  }
10812
11103
  let rounds = 1;
10813
- while (rounds < maxRounds) {
11104
+ while (rounds < maxRounds && svc.budget.remainingCalls() > SYNTHESIS_CALL_RESERVE) {
10814
11105
  rounds++;
10815
11106
  let response;
10816
11107
  try {
@@ -10897,6 +11188,9 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
10897
11188
  return { report, rounds: 1, toolSupported: supportsTools };
10898
11189
  }
10899
11190
  async function explorationRetry(model, auth, llmMessages, extraction, userNote, signal, services) {
11191
+ const svc = services ?? getDefaultServices();
11192
+ if (svc.budget.remainingCalls() <= SYNTHESIS_CALL_RESERVE)
11193
+ return fallbackExplorationReport(llmMessages);
10900
11194
  const last5 = llmMessages.slice(-5).map((m) => "[" + m?.role + "] " + extractText(m?.content).slice(0, TRUNC.PREVIEW)).join(`
10901
11195
  `);
10902
11196
  const retryPrompt = `IMPORTANT: Output ONLY valid raw JSON. No markdown. No explanation. No code fences. Just the JSON object.
@@ -10925,7 +11219,7 @@ User steering: ` + userNote : "");
10925
11219
  headers: auth.headers,
10926
11220
  maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens),
10927
11221
  signal
10928
- }, services);
11222
+ }, svc);
10929
11223
  const text = resp.content.filter((c) => c.type === "text").map((c) => c.text).join("").trim();
10930
11224
  return parseExplorationReport(text, llmMessages);
10931
11225
  } catch (e) {
@@ -10965,6 +11259,9 @@ Output ONLY JSON: {"mainGoal":"...","sessionType":"implementation|review|debuggi
10965
11259
  };
10966
11260
  }
10967
11261
  async function directExploration(llmMessages, extraction, model, auth, prevSummary, userNote, signal, services) {
11262
+ const svc = services ?? getDefaultServices();
11263
+ if (svc.budget.remainingCalls() <= SYNTHESIS_CALL_RESERVE)
11264
+ return fallbackExplorationReport(llmMessages);
10968
11265
  const request = buildDirectExplorationRequest(llmMessages, extraction, model, prevSummary, userNote);
10969
11266
  try {
10970
11267
  const resp = await trackedComplete("explore-direct", model, request.context, {
@@ -10972,7 +11269,7 @@ async function directExploration(llmMessages, extraction, model, auth, prevSumma
10972
11269
  headers: auth.headers,
10973
11270
  maxTokens: request.maxTokens,
10974
11271
  signal
10975
- }, services);
11272
+ }, svc);
10976
11273
  const text = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
10977
11274
  `).trim();
10978
11275
  return parseExplorationReport(text, llmMessages);
@@ -11452,6 +11749,8 @@ async function singlePassCompact(request, model, auth, signal, services) {
11452
11749
  `).trim();
11453
11750
  if (!summary.startsWith("##"))
11454
11751
  throw new Error("Single-pass malformed output");
11752
+ if (patchResponseIsTruncated(summary, resp.stopReason))
11753
+ throw new Error("Single-pass truncated output");
11455
11754
  return { summary, llmCalls: 1 };
11456
11755
  }
11457
11756
  function buildBatchRequest(batch, extraction, model, maxOutputTokens) {
@@ -11525,7 +11824,7 @@ async function summarizeBatch(batch, extraction, model, auth, signal, services,
11525
11824
  try {
11526
11825
  sectionMap = await attempt();
11527
11826
  } catch (err) {
11528
- if (err instanceof BatchSummaryFormatError && /non-terminal stop reason length/.test(err.message) && services?.thinkingLevels.summaryThinkingLevel !== "minimal") {
11827
+ if (err instanceof BatchSummaryFormatError && /non-terminal stop reason length/.test(err.message) && services?.thinkingLevels.summaryThinkingLevel !== "minimal" && (services ?? getDefaultServices()).budget.remainingCalls() > 1) {
11529
11828
  debug("Batch synthesis exhausted the output budget (reasoning shared it); retrying once with minimal reasoning");
11530
11829
  sectionMap = await attempt("minimal");
11531
11830
  } else {
@@ -11604,8 +11903,11 @@ async function assembleLLM(summaries, extraction, report, model, auth, budget, p
11604
11903
  maxTokens: Math.min(budget, getProviderCaps(model.provider).maxOutputTokens),
11605
11904
  signal
11606
11905
  }, services);
11607
- return resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
11906
+ const assembled = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
11608
11907
  `).trim();
11908
+ if (patchResponseIsTruncated(assembled, resp.stopReason))
11909
+ throw new Error("Assembly truncated output");
11910
+ return assembled;
11609
11911
  }
11610
11912
  function assembleFallback(summaries, extraction, steering = {}, budgetTokens = 6000, continuity = null) {
11611
11913
  const safe = (value, max = TRUNC.PREVIEW_MID) => summaryEvidenceLine(value, max);
@@ -11620,7 +11922,7 @@ function assembleFallback(summaries, extraction, steering = {}, budgetTokens = 6
11620
11922
  constraints.push("- [focus] Preserve detail about: " + safe(steering.focus, TRUNC.CONSTRAINT_TEXT));
11621
11923
  if (steering.note?.trim())
11622
11924
  constraints.push("- [note] " + safe(steering.note, TRUNC.CONSTRAINT_TEXT));
11623
- const decisions = extraction.decisions.map((item) => {
11925
+ const decisions = collapseDecisionsByQuestion(extraction.decisions).map((item) => {
11624
11926
  const summary = safe(item.summary, TRUNC.DECISION_SUMMARY);
11625
11927
  const response = item.userResponse ? safe(item.userResponse, TRUNC.USER_RESPONSE) : "";
11626
11928
  return summary ? "- **" + summary + "**" + (response ? " \u2192 " + response : "") : "";
@@ -11695,6 +11997,20 @@ function failedChunkSummary(ch) {
11695
11997
  }
11696
11998
 
11697
11999
  // src/app/stage-auth.ts
12000
+ function abortableAuth(invoke, signal) {
12001
+ return new Promise((resolve, reject) => {
12002
+ const auth = invoke();
12003
+ const onAbort = () => reject(signal.reason ?? new Error("Stage auth aborted"));
12004
+ signal.addEventListener("abort", onAbort, { once: true });
12005
+ auth.then((value) => {
12006
+ signal.removeEventListener("abort", onAbort);
12007
+ resolve(value);
12008
+ }, (error) => {
12009
+ signal.removeEventListener("abort", onAbort);
12010
+ reject(error);
12011
+ });
12012
+ });
12013
+ }
11698
12014
  async function resolveStageAuth(rc, stage) {
11699
12015
  const model = stage === "summary" ? rc.summaryModel : stage === "explore" ? rc.segModel : rc.verifyModel;
11700
12016
  const existing = stage === "summary" ? rc.summaryAuth : stage === "explore" ? rc.segAuth : rc.verifyAuth;
@@ -11715,11 +12031,13 @@ async function resolveStageAuth(rc, stage) {
11715
12031
  rc.verifyAuth = shared;
11716
12032
  return shared;
11717
12033
  }
11718
- const auth = await rc.ctx.modelRegistry.getApiKeyAndHeaders(model);
12034
+ rc.cancellation.signal.throwIfAborted();
12035
+ const auth = await abortableAuth(() => rc.ctx.modelRegistry.getApiKeyAndHeaders(model), rc.cancellation.signal);
11719
12036
  if (!auth.ok || !auth.apiKey) {
11720
12037
  throw new Error("Authentication unavailable for " + stage + " route " + model.provider + "/" + model.id);
11721
12038
  }
11722
12039
  const resolved = { apiKey: auth.apiKey, headers: auth.headers };
12040
+ rc.cancellation.signal.throwIfAborted();
11723
12041
  if (stage === "summary")
11724
12042
  rc.summaryAuth = resolved;
11725
12043
  else if (stage === "explore")
@@ -11862,7 +12180,7 @@ async function summarizeConversation(rc) {
11862
12180
  cacheable = false;
11863
12181
  generationFallbacks.push("single-pass generation failed");
11864
12182
  recordIssue({ key: "synth.single-pass", message: "Single-pass synthesis used deterministic fallback (" + errorDetail(err) + ").", error: err });
11865
- rc.notify("Single-pass generation stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "warning");
12183
+ rc.notify("Single-pass generation stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "info");
11866
12184
  finalSummary = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
11867
12185
  method = "heuristic";
11868
12186
  }
@@ -11960,7 +12278,7 @@ async function summarizeConversation(rc) {
11960
12278
  cacheable = false;
11961
12279
  generationFallbacks.push("1 synthesis batch fallback");
11962
12280
  recordIssue({ key: "synth.batch", message: "Synthesis batch used deterministic fallback (" + errorDetail(err) + ").", error: err });
11963
- rc.notify("Synthesis batch stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(err) + "]", "warning");
12281
+ rc.notify("Synthesis batch stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(err) + "]", "info");
11964
12282
  showProgressOverlay(rc.ctx, {
11965
12283
  phase: 3,
11966
12284
  phaseName: "Synthesize",
@@ -11993,7 +12311,7 @@ async function summarizeConversation(rc) {
11993
12311
  const idx = nextBatch++;
11994
12312
  if (idx >= batchCallLimit)
11995
12313
  return;
11996
- if (budgetStopped || rc.services.budget.reason()) {
12314
+ if (budgetStopped || rc.services.budget.reason() || rc.services.budget.remainingCalls() <= 1) {
11997
12315
  budgetStopped = true;
11998
12316
  results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
11999
12317
  } else {
@@ -12036,7 +12354,7 @@ async function summarizeConversation(rc) {
12036
12354
  if (failedBatches.length) {
12037
12355
  cacheable = false;
12038
12356
  generationFallbacks.push(failedBatches.length + " synthesis batch fallback(s)");
12039
- rc.notify(failedBatches.length + " synthesis batch(es) stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(failedBatches[0]) + "]", "warning");
12357
+ rc.notify(failedBatches.length + " synthesis batch(es) stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(failedBatches[0]) + "]", "info");
12040
12358
  showProgressOverlay(rc.ctx, {
12041
12359
  phase: 3,
12042
12360
  phaseName: "Synthesize",
@@ -12068,7 +12386,7 @@ async function summarizeConversation(rc) {
12068
12386
  cacheable = false;
12069
12387
  generationFallbacks.push("assembly generation failed");
12070
12388
  recordIssue({ key: "synth.assembly", message: "Assembly used deterministic fallback (" + errorDetail(err) + ").", error: err });
12071
- rc.notify("Assembly stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "warning");
12389
+ rc.notify("Assembly stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "info");
12072
12390
  method = "heuristic";
12073
12391
  finalSummary = assembleFallback(summaries, extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
12074
12392
  }
@@ -13113,11 +13431,30 @@ function supersededResults(projection, candidates) {
13113
13431
  });
13114
13432
  return result;
13115
13433
  }
13116
- function planContextTrim(branch, afterId) {
13434
+ function thinkingEditReason(branch, entries, readerApi) {
13435
+ if (readerApi && readerApi !== "anthropic-messages")
13436
+ return;
13437
+ const edits = new Map(entries.flatMap((entry) => entry.type === "context_edit" ? [[entry.targetId, entry.replacement]] : []));
13438
+ let changed = false;
13439
+ for (const { sourceEntry, messages } of buildSessionProjection2(branch).entries) {
13440
+ if (edits.has(sourceEntry.id)) {
13441
+ changed = true;
13442
+ if (edits.get(sourceEntry.id) === null)
13443
+ continue;
13444
+ }
13445
+ if (changed && messages.some((message) => message.role === "assistant" && message.api === "anthropic-messages" && message.content.some((block) => block.type === "thinking" && Boolean(block.thinkingSignature)))) {
13446
+ return "Cleanup would invalidate retained signed Anthropic thinking. Use provider-native compaction instead.";
13447
+ }
13448
+ }
13449
+ }
13450
+ function planContextTrim(branch, afterId, options = {}) {
13117
13451
  if (afterId && !branch.some((entry) => entry.id === afterId))
13118
13452
  throw new Error("Trim boundary is not on the active branch.");
13119
13453
  const candidates = archivableResults(branch);
13120
- const boundaries = [afterId, lastAnchorBoundary(branch)].filter((id) => Boolean(id));
13454
+ const anchorBoundary = options.anchorBoundary === undefined ? lastAnchorBoundary(branch) : options.anchorBoundary;
13455
+ if (anchorBoundary && !branch.some((entry) => entry.id === anchorBoundary))
13456
+ throw new Error("Anchor boundary is not on the active branch.");
13457
+ const boundaries = [afterId, anchorBoundary].filter((id) => Boolean(id));
13121
13458
  const protectedIds = new Set(boundaries.flatMap((id) => branch.slice(0, branch.findIndex((entry) => entry.id === id) + 1).map((entry) => entry.id)));
13122
13459
  const edited = new Set(branch.flatMap((entry) => entry.type === "context_edit" ? [entry.targetId] : []));
13123
13460
  const projection = buildSessionProjection2(branch).entries;
@@ -13155,6 +13492,18 @@ function planContextTrim(branch, afterId) {
13155
13492
  archives.push({ id, sha256: digest(text), chars: text.length });
13156
13493
  savedChars += text.length - marker.length;
13157
13494
  }
13495
+ const blockedReason = thinkingEditReason(branch, entries, options.readerApi);
13496
+ if (blockedReason)
13497
+ return {
13498
+ entries: [],
13499
+ references: [],
13500
+ archives: [],
13501
+ savedChars: 0,
13502
+ automatic: "signed-thinking",
13503
+ cooldownTurns: 0,
13504
+ superseded: 0,
13505
+ blockedReason
13506
+ };
13158
13507
  const supersededCount = eligible.filter((item) => superseded.has(item.id)).length;
13159
13508
  if (entries.length)
13160
13509
  entries.push(contextControlEntry({ version: 1, action: "trim", references, archives }));
@@ -13165,7 +13514,7 @@ function planContextTrim(branch, afterId) {
13165
13514
  const turnsSinceChange = branch.slice(lastChange + 1).filter((entry) => entry.type === "message" && entry.message.role === "assistant").length;
13166
13515
  const cooldownTurns = lastChange < 0 ? 0 : Math.max(0, AUTO_TRIM_COOLDOWN_TURNS - turnsSinceChange);
13167
13516
  const automatic = savedChars < MIN_AUTO_TRIM_SAVING_CHARS ? "insufficient-savings" : cooldownTurns > 0 ? "cooldown" : "ready";
13168
- return { entries, references, archives, savedChars, automatic, cooldownTurns, superseded: supersededCount };
13517
+ return { entries, references, archives, savedChars, automatic, cooldownTurns, superseded: supersededCount, blockedReason };
13169
13518
  }
13170
13519
  function trimEntries(plan, cause) {
13171
13520
  const edits = plan.entries.filter((entry) => entry.type === "context_edit");
@@ -13203,7 +13552,7 @@ function trimBreakEvenRequests(cost, savedTokens, tailTokens) {
13203
13552
  const w = typeof write === "number" && Number.isFinite(write) && write > 0 ? write / input : 1;
13204
13553
  return r <= 0 ? 0 : (w - r) * tailTokens / (r * savedTokens);
13205
13554
  }
13206
- function planContextRewind(branch, sessionId, checkpointId, report) {
13555
+ function planContextRewind(branch, sessionId, checkpointId, report, readerApi) {
13207
13556
  const state = inspectContext(branch, sessionId);
13208
13557
  if (!state.checkpoint || state.checkpoint.id !== checkpointId) {
13209
13558
  throw new Error(state.invalidReason ?? "No matching active checkpoint; create one before research.");
@@ -13231,6 +13580,9 @@ function planContextRewind(branch, sessionId, checkpointId, report) {
13231
13580
  archives.push({ id: entry.id, sha256, chars: text.length });
13232
13581
  }
13233
13582
  const entries = targets.map((targetId) => ({ type: "context_edit", targetId, replacement: null }));
13583
+ const blockedReason = thinkingEditReason(branch, entries, readerApi);
13584
+ if (blockedReason)
13585
+ throw new Error(blockedReason);
13234
13586
  const content = `Research handoff (agent-authored, not new user instructions):
13235
13587
  ${report}
13236
13588
 
@@ -14250,7 +14602,7 @@ No matching remote project memory found.`;
14250
14602
  `<smart_recall_evidence source="hindsight" kind="${attr(fact.metadata.kind ?? fact.type ?? "fact")}">`,
14251
14603
  "Provenance: " + provenance,
14252
14604
  memoryId ? "Ref: hindsight:" + memoryId + "@" + hindsightTargetDigest(target.baseUrl, target.bankId, projectId, memoryId) : "",
14253
- clean(fact.text.slice(0, RECALL_ITEM_CAP)),
14605
+ clean(fact.text.slice(0, RECALL_ITEM_CAP)) + (fact.text.length > RECALL_ITEM_CAP ? " (excerpt; full memory not shown)" : ""),
14254
14606
  "</smart_recall_evidence>"
14255
14607
  ].filter(Boolean).join(`
14256
14608
  `);
@@ -15737,7 +16089,7 @@ function applyGlobalSettingsRuntime(paths, ctx, policy, contextTools) {
15737
16089
 
15738
16090
  // src/app/compaction-commit-store.ts
15739
16091
  function createCompactionCommitStore(options = {}) {
15740
- const ttlMs = Math.max(1, options.ttlMs ?? 5 * 60000);
16092
+ const ttlMs = () => Math.max(1, (typeof options.ttlMs === "function" ? options.ttlMs() : options.ttlMs) ?? 5 * 60000);
15741
16093
  const maxEntries = Math.max(1, options.maxEntries ?? 16);
15742
16094
  const entries = new Map;
15743
16095
  const remove = (runId, reason, notify) => {
@@ -15752,7 +16104,7 @@ function createCompactionCommitStore(options = {}) {
15752
16104
  const sweep = () => {
15753
16105
  const now = Date.now();
15754
16106
  for (const [runId, entry] of entries) {
15755
- if (now - entry.createdAt > ttlMs)
16107
+ if (now - entry.createdAt > ttlMs())
15756
16108
  remove(runId, "expired", true);
15757
16109
  }
15758
16110
  };
@@ -15807,72 +16159,6 @@ function createCompactionCommitStore(options = {}) {
15807
16159
  };
15808
16160
  }
15809
16161
 
15810
- // src/app/extension-conflicts.ts
15811
- var KIND = {
15812
- "context-fold": "compaction",
15813
- "pi-context-prune": "context-editing",
15814
- "pi-dcp": "context-editing",
15815
- "pi-fold": "context-editing",
15816
- "pi-openai-toolkit": "compaction",
15817
- "pi-toolkit": "context-editing"
15818
- };
15819
- var SEGMENT_OWNER = {
15820
- "context-fold": "context-fold",
15821
- dcp: "pi-dcp",
15822
- "pi-context-prune": "pi-context-prune",
15823
- "pi-dcp": "pi-dcp",
15824
- "pi-fold": "pi-fold",
15825
- "pi-openai-toolkit": "pi-openai-toolkit"
15826
- };
15827
- function segments(info) {
15828
- const out = new Set;
15829
- if (!info)
15830
- return out;
15831
- for (const text of [info.path ?? "", info.source ?? "", info.baseDir ?? ""]) {
15832
- for (const raw of text.toLowerCase().split(/[\\/:]+/)) {
15833
- const segment = raw.replace(/(.)@.*$/, "$1").replace(/\.(?:[cm]?[jt]s|git)$/, "");
15834
- if (segment)
15835
- out.add(segment);
15836
- }
15837
- }
15838
- return out;
15839
- }
15840
- function detectExtensionConflicts(registry) {
15841
- const entries = [
15842
- ...registry.commands.filter((command) => command.source === "extension").map((command) => ({ name: command.name.toLowerCase(), label: `/${command.name} command`, segments: segments(command.sourceInfo) })),
15843
- ...registry.tools.map((tool) => ({ name: tool.name.toLowerCase(), label: `${tool.name} tool`, segments: segments(tool.sourceInfo) }))
15844
- ].filter((entry) => !(entry.segments.has("pi-smart-compact") || entry.name.startsWith("smart_") || entry.name === "smart-compact"));
15845
- const found = new Map;
15846
- const add = (name, evidence) => {
15847
- if (!found.has(name))
15848
- found.set(name, evidence);
15849
- };
15850
- const bareFold = [];
15851
- for (const entry of entries) {
15852
- const owner = [...entry.segments].map((segment) => SEGMENT_OWNER[segment]).find(Boolean);
15853
- if (owner)
15854
- add(owner, `${entry.label} from ${owner}`);
15855
- else if (entry.segments.has("pi-toolkit") && entry.name === "context")
15856
- add("pi-toolkit", `${entry.label} from pi-toolkit`);
15857
- else if (entry.name === "fold-handoff")
15858
- add("context-fold", entry.label);
15859
- else if (entry.name === "prune")
15860
- add("pi-context-prune", entry.label);
15861
- else if (entry.name === "fold")
15862
- bareFold.push(entry);
15863
- }
15864
- for (const entry of bareFold)
15865
- add(found.has("context-fold") ? "context-fold" : "pi-fold", entry.label);
15866
- return [...found].map(([name, evidence]) => ({ name, kind: KIND[name], evidence })).sort((a, b) => a.name.localeCompare(b.name));
15867
- }
15868
- function conflictNotice(conflicts) {
15869
- const names = conflicts.map((conflict) => conflict.name);
15870
- const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
15871
- const verb = names.length === 1 ? "also compacts or edits" : "also compact or edit";
15872
- const evidence = conflicts.map((conflict) => `${conflict.name}: ${conflict.evidence}`).join("; ");
15873
- return `${list} ${verb} this conversation (${evidence}). Two owners of the same history are not safe in any load order: summaries can be discarded and cache prefixes rewritten twice. Keep only one loaded (Pi: /settings \u203A packages, or the extensions list).`;
15874
- }
15875
-
15876
16162
  // src/app/native-continuity-bridge.ts
15877
16163
  import crypto6 from "crypto";
15878
16164
  import fs13 from "fs";
@@ -16082,89 +16368,6 @@ function createNativeContinuityBridge(opts = {}) {
16082
16368
  };
16083
16369
  }
16084
16370
 
16085
- // src/app/settled-auto-trigger.ts
16086
- function createSettledAutoTrigger(options = {}) {
16087
- const now = options.now ?? Date.now;
16088
- const cooldownMs = Math.max(0, options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS);
16089
- const active = new Map;
16090
- const lastCompactionAt = new Map;
16091
- const noteCompaction = (sessionId) => {
16092
- if (!isUnresolvedSessionId(sessionId))
16093
- lastCompactionAt.set(sessionId, now());
16094
- };
16095
- const clear = (sessionId) => {
16096
- active.delete(sessionId);
16097
- lastCompactionAt.delete(sessionId);
16098
- };
16099
- const request = async (ctx, config) => {
16100
- if (!config.autoTrigger || !["settled", "background"].includes(config.autoTriggerStrategy))
16101
- return;
16102
- const sessionId = resolveSessionId(ctx);
16103
- if (isUnresolvedSessionId(sessionId) || active.has(sessionId))
16104
- return;
16105
- const usage = ctx.getContextUsage();
16106
- const totalTokens = usage?.tokens;
16107
- if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < MIN_TOKEN_THRESHOLD || !ctx.model)
16108
- return;
16109
- const contextPercent = safeContextPercent(totalTokens, effectiveContextWindow(ctx.model, config));
16110
- if (contextPercent < config.minContextPercent)
16111
- return;
16112
- const lastCompaction = lastCompactionAt.get(sessionId);
16113
- if (lastCompaction !== undefined && now() - lastCompaction < cooldownMs)
16114
- return;
16115
- if (!ctx.isIdle() || ctx.hasPendingMessages())
16116
- return;
16117
- const requestToken = Symbol(sessionId);
16118
- active.set(sessionId, requestToken);
16119
- await new Promise((resolve) => {
16120
- let finished = false;
16121
- const watchdogMs = options.watchdogMs ?? AUTO_TRIGGER_TIMEOUT_CAP_MS + config.autoTriggerTimeoutMs;
16122
- const watchdog = setTimeout(() => {
16123
- reportIssue({
16124
- key: "auto.settled-no-callback",
16125
- message: "Pi did not report the automatic compaction result within " + Math.round(watchdogMs / 1000) + "s. The next settled turn may request it again."
16126
- }, ctx);
16127
- finish();
16128
- }, watchdogMs);
16129
- watchdog.unref?.();
16130
- const finish = () => {
16131
- if (finished)
16132
- return;
16133
- finished = true;
16134
- clearTimeout(watchdog);
16135
- if (active.get(sessionId) === requestToken)
16136
- active.delete(sessionId);
16137
- resolve();
16138
- };
16139
- try {
16140
- ctx.compact({
16141
- onComplete: () => {
16142
- if (active.get(sessionId) === requestToken)
16143
- noteCompaction(sessionId);
16144
- finish();
16145
- },
16146
- onError: (error) => {
16147
- reportIssue({
16148
- key: "auto.settled-apply",
16149
- message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16150
- error
16151
- }, ctx);
16152
- finish();
16153
- }
16154
- });
16155
- } catch (error) {
16156
- reportIssue({
16157
- key: "auto.settled-apply",
16158
- message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16159
- error
16160
- }, ctx);
16161
- finish();
16162
- }
16163
- });
16164
- };
16165
- return { request, noteCompaction, clear };
16166
- }
16167
-
16168
16371
  // src/app/background-preparation.ts
16169
16372
  function recordPreparationDiscard(sessionId, snapshot, reason) {
16170
16373
  return appendMetricsSnapshot(sessionId, {
@@ -16186,11 +16389,31 @@ function preparationWindow(config, window) {
16186
16389
  const startTokens = explicit ? Math.max(MIN_TOKEN_THRESHOLD, window * prepare / 100) : preparationStartTokens(applyTokens);
16187
16390
  return { startTokens, applyTokens };
16188
16391
  }
16392
+ function contextPressure(ctx, config) {
16393
+ const raw = ctx.getContextUsage?.()?.tokens;
16394
+ const tokens = typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? raw : null;
16395
+ const rawWindow = effectiveContextWindow(ctx.model, config);
16396
+ const policyWindow = typeof rawWindow === "number" && Number.isFinite(rawWindow) && rawWindow > 0 ? rawWindow : null;
16397
+ const gates = policyWindow === null ? null : preparationWindow(config, policyWindow);
16398
+ const cleanupTokens = gates ? Math.ceil(gates.startTokens) : null;
16399
+ const compactionTokens = gates ? Math.ceil(Math.max(MIN_TOKEN_THRESHOLD, gates.applyTokens)) : null;
16400
+ return {
16401
+ tokens,
16402
+ modelWindow: ctx.model?.contextWindow ?? null,
16403
+ policyWindow,
16404
+ percent: tokens !== null && policyWindow !== null ? tokens / policyWindow * 100 : null,
16405
+ cleanupTokens,
16406
+ compactionTokens,
16407
+ cleanup: tokens !== null && cleanupTokens !== null && tokens >= cleanupTokens,
16408
+ compaction: tokens !== null && compactionTokens !== null && tokens >= compactionTokens
16409
+ };
16410
+ }
16189
16411
  function snapshotContext(ctx) {
16190
16412
  const branch = structuredClone(ctx.sessionManager.getBranch());
16191
16413
  const sessionId = resolveSessionId(ctx);
16192
16414
  const sessionFile = ctx.sessionManager.getSessionFile?.();
16193
16415
  const usage = structuredClone(ctx.getContextUsage());
16416
+ const systemPrompt = ctx.getSystemPrompt?.() ?? "";
16194
16417
  const sessionManager = new Proxy(ctx.sessionManager, {
16195
16418
  get(target, key) {
16196
16419
  if (key === "getBranch")
@@ -16209,20 +16432,21 @@ function snapshotContext(ctx) {
16209
16432
  model: ctx.model && { ...ctx.model },
16210
16433
  hasUI: false,
16211
16434
  getContextUsage: () => usage,
16435
+ getSystemPrompt: () => systemPrompt,
16212
16436
  ui: { ...ctx.ui, notify() {}, setStatus() {}, setWidget() {} }
16213
16437
  };
16214
16438
  return Object.assign(snapshot, { silentBackground: true });
16215
16439
  }
16216
16440
  function signature(ctx, config) {
16217
16441
  const model = ctx.model;
16218
- return JSON.stringify([model?.provider, model?.id, model?.api, model?.contextWindow, model?.maxTokens, config]);
16442
+ return JSON.stringify([model?.provider, model?.id, model?.api, model?.baseUrl, model?.contextWindow, model?.maxTokens, ctx.getSystemPrompt?.(), config]);
16219
16443
  }
16220
16444
  function enabled(config) {
16221
16445
  return config.autoTrigger && config.autoTriggerStrategy === "background";
16222
16446
  }
16223
16447
  function createBackgroundPreparation(options) {
16224
16448
  const now = options.now ?? Date.now;
16225
- const ttlMs = options.ttlMs ?? FIVE_MINUTES_MS;
16449
+ const signatureOf = (ctx, config) => signature(ctx, config) + (options.toolSignature?.() ?? "");
16226
16450
  const cooldownMs = options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS;
16227
16451
  let current = null;
16228
16452
  let lastAttempt;
@@ -16269,9 +16493,9 @@ function createBackgroundPreparation(options) {
16269
16493
  return "config";
16270
16494
  if (task.sessionId !== resolveSessionId(ctx))
16271
16495
  return "session";
16272
- if (task.signature !== signature(ctx, config))
16496
+ if (task.signature !== signatureOf(ctx, config))
16273
16497
  return "config";
16274
- if (now() - (task.readyAt ?? task.createdAt) > ttlMs)
16498
+ if (now() - (task.readyAt ?? task.createdAt) > (options.ttlMs ?? config.pendingTtlMs))
16275
16499
  return "ttl";
16276
16500
  const branch = ctx.sessionManager.getBranch();
16277
16501
  const origin = branch.findIndex((entry) => entry.id === task.originId);
@@ -16303,7 +16527,7 @@ function createBackgroundPreparation(options) {
16303
16527
  const task = {
16304
16528
  sessionId,
16305
16529
  originId,
16306
- signature: signature(ctx, config),
16530
+ signature: signatureOf(ctx, config),
16307
16531
  controller: new AbortController,
16308
16532
  createdAt: now(),
16309
16533
  ctx
@@ -16311,7 +16535,15 @@ function createBackgroundPreparation(options) {
16311
16535
  current = task;
16312
16536
  lastAttempt = { sessionId, at: now() };
16313
16537
  const preparationConfig = { ...config, minContextPercent: startTokens / window * 100 };
16314
- track(Promise.resolve().then(() => options.prepare(snapshot, preparationConfig, task.controller.signal)).then((pending) => {
16538
+ track(Promise.resolve().then(() => {
16539
+ if (current !== task || task.controller.signal.aborted)
16540
+ return null;
16541
+ if (!valid(task, ctx, config)) {
16542
+ cancel("config");
16543
+ return null;
16544
+ }
16545
+ return options.prepare(snapshot, preparationConfig, task.controller.signal);
16546
+ }).then((pending) => {
16315
16547
  if (pending) {
16316
16548
  task.pending = pending;
16317
16549
  task.readyAt = now();
@@ -16396,6 +16628,83 @@ function createBackgroundPreparation(options) {
16396
16628
  };
16397
16629
  }
16398
16630
 
16631
+ // src/app/settled-auto-trigger.ts
16632
+ function createSettledAutoTrigger(options = {}) {
16633
+ const now = options.now ?? Date.now;
16634
+ const cooldownMs = Math.max(0, options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS);
16635
+ const active = new Map;
16636
+ const cooldownStartedAt = new Map;
16637
+ const noteCompaction = (sessionId) => {
16638
+ if (!isUnresolvedSessionId(sessionId))
16639
+ cooldownStartedAt.set(sessionId, now());
16640
+ };
16641
+ const clear = (sessionId) => {
16642
+ active.delete(sessionId);
16643
+ cooldownStartedAt.delete(sessionId);
16644
+ };
16645
+ const request = async (ctx, config) => {
16646
+ if (!config.autoTrigger || !["settled", "background"].includes(config.autoTriggerStrategy))
16647
+ return;
16648
+ const sessionId = resolveSessionId(ctx);
16649
+ if (isUnresolvedSessionId(sessionId) || active.has(sessionId))
16650
+ return;
16651
+ if (!contextPressure(ctx, config).compaction)
16652
+ return;
16653
+ const cooldownStart = cooldownStartedAt.get(sessionId);
16654
+ if (cooldownStart !== undefined && now() - cooldownStart < cooldownMs)
16655
+ return;
16656
+ if (!ctx.isIdle() || ctx.hasPendingMessages())
16657
+ return;
16658
+ const requestToken = Symbol(sessionId);
16659
+ active.set(sessionId, requestToken);
16660
+ await new Promise((resolve) => {
16661
+ let finished = false;
16662
+ const watchdogMs = options.watchdogMs ?? AUTO_TRIGGER_TIMEOUT_CAP_MS + config.autoTriggerTimeoutMs;
16663
+ const watchdog = setTimeout(() => {
16664
+ const hostBusy = typeof ctx.isIdle === "function" ? !ctx.isIdle() : false;
16665
+ reportIssue({
16666
+ key: "auto.settled-no-callback",
16667
+ message: hostBusy ? "Pi is still busy after " + Math.round(watchdogMs / 1000) + "s; the automatic compaction result may still arrive and be applied late. No action needed; automatic retries wait for the cooldown." : "Pi did not report the automatic compaction result within " + Math.round(watchdogMs / 1000) + "s. Automatic retries wait for the cooldown. Run /smart-compact manually if needed."
16668
+ }, ctx);
16669
+ finish();
16670
+ }, watchdogMs);
16671
+ watchdog.unref?.();
16672
+ const finish = () => {
16673
+ if (finished)
16674
+ return;
16675
+ finished = true;
16676
+ clearTimeout(watchdog);
16677
+ if (active.get(sessionId) === requestToken) {
16678
+ active.delete(sessionId);
16679
+ cooldownStartedAt.set(sessionId, now());
16680
+ }
16681
+ resolve();
16682
+ };
16683
+ try {
16684
+ ctx.compact({
16685
+ onComplete: finish,
16686
+ onError: (error) => {
16687
+ reportIssue({
16688
+ key: "auto.settled-apply",
16689
+ message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16690
+ error
16691
+ }, ctx);
16692
+ finish();
16693
+ }
16694
+ });
16695
+ } catch (error) {
16696
+ reportIssue({
16697
+ key: "auto.settled-apply",
16698
+ message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16699
+ error
16700
+ }, ctx);
16701
+ finish();
16702
+ }
16703
+ });
16704
+ };
16705
+ return { request, noteCompaction, clear };
16706
+ }
16707
+
16399
16708
  // src/app/host-cache-ledger.ts
16400
16709
  var FOREIGN_REBUILD_WARN_COUNT = 3;
16401
16710
  var num = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
@@ -16559,8 +16868,106 @@ function compactionUsage(snapshot, resolveModel) {
16559
16868
 
16560
16869
  // src/app/register-context-tools.ts
16561
16870
  import { StringEnum } from "@earendil-works/pi-ai";
16871
+ import { Text as Text4 } from "@earendil-works/pi-tui";
16562
16872
  import { Type as Type3 } from "typebox";
16563
16873
 
16874
+ // src/ui/tool-rows.ts
16875
+ import { stripVTControlCharacters as stripVTControlCharacters2 } from "util";
16876
+ import { Text as Text3, truncateToWidth as truncateToWidth3 } from "@earendil-works/pi-tui";
16877
+ import { keyHint } from "@earendil-works/pi-coding-agent";
16878
+ var STATUS_COLOR = {
16879
+ done: "success",
16880
+ queued: "dim",
16881
+ pending: "warning",
16882
+ failed: "error",
16883
+ cancelled: "warning",
16884
+ skipped: "dim",
16885
+ info: "muted"
16886
+ };
16887
+ var STATUS_WORD = {
16888
+ done: "done",
16889
+ queued: "queued",
16890
+ pending: "pending",
16891
+ failed: "failed",
16892
+ cancelled: "cancelled",
16893
+ skipped: "skipped",
16894
+ info: ""
16895
+ };
16896
+ function sanitizeVisible(text) {
16897
+ return stripVTControlCharacters2(text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, " ");
16898
+ }
16899
+ function firstTextContent(content) {
16900
+ const text = content?.find((part) => part.type === "text" && part.text)?.text;
16901
+ return text ? sanitizeVisible(text) : "";
16902
+ }
16903
+ function summarizeLine(text, maxLength = 120) {
16904
+ const single = sanitizeVisible(text).split(/\r?\n/).find((line) => line.trim()) ?? "";
16905
+ const clipped = single.trim().slice(0, maxLength);
16906
+ return clipped + (single.trim().length > maxLength ? "\u2026" : "");
16907
+ }
16908
+ function statusLabel(theme, status) {
16909
+ const word = STATUS_WORD[status];
16910
+ return word ? theme.fg(STATUS_COLOR[status], word) : "";
16911
+ }
16912
+ function metaLine(theme, key, value) {
16913
+ return theme.fg("dim", key + ":") + " " + sanitizeVisible(value);
16914
+ }
16915
+ function previewBlock(lines, expanded, maxCollapsed, maxLength = 160) {
16916
+ const clean = lines.map((line) => sanitizeVisible(line));
16917
+ if (expanded)
16918
+ return clean;
16919
+ const shown = clean.slice(0, maxCollapsed).map((line) => line.slice(0, maxLength));
16920
+ if (clean.length > maxCollapsed) {
16921
+ shown.push("\u2026 (" + (clean.length - maxCollapsed) + " more)");
16922
+ }
16923
+ return shown;
16924
+ }
16925
+ function allTextParts(content) {
16926
+ return (content ?? []).filter((part) => part.type === "text" && part.text).map((part) => sanitizeVisible(part.text));
16927
+ }
16928
+ function safeArg(value, maxLength = 120) {
16929
+ if (typeof value !== "string")
16930
+ return "";
16931
+ return sanitizeVisible(value).trim().slice(0, maxLength);
16932
+ }
16933
+ function expandHint(theme) {
16934
+ try {
16935
+ return theme.fg("dim", keyHint("app.tools.expand", "expand"));
16936
+ } catch {
16937
+ return theme.fg("dim", "expand tool output for full details");
16938
+ }
16939
+ }
16940
+ function expandedRow(theme, result, summaryLines) {
16941
+ const content = allTextParts(result.content).join(`
16942
+ `);
16943
+ const head = summaryLines.filter(Boolean).join(`
16944
+ `);
16945
+ return new Text3(head + (content ? `
16946
+ ` + theme.fg("borderMuted", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500") + `
16947
+ ` + content : ""), 0, 0);
16948
+ }
16949
+ function rawFallbackRow(theme, result, expanded) {
16950
+ const parts = allTextParts(result.content);
16951
+ if (!parts.length) {
16952
+ return new Text3(theme.fg("warning", "No result details available"), 0, 0);
16953
+ }
16954
+ if (expanded)
16955
+ return new Text3(parts.join(`
16956
+ `), 0, 0);
16957
+ const joined = parts.join(`
16958
+ `);
16959
+ const bounded = truncateToWidth3(joined.split(/\r?\n/)[0] ?? "", 200, "\u2026");
16960
+ return new Text3(bounded + (joined.length > bounded.length ? `
16961
+ \u2026 (expanded shows full content)` : ""), 0, 0);
16962
+ }
16963
+ function tryRow(theme, build, result, expanded) {
16964
+ try {
16965
+ return build();
16966
+ } catch {
16967
+ return rawFallbackRow(theme, result, expanded);
16968
+ }
16969
+ }
16970
+
16564
16971
  // src/app/mnemopi-memory.ts
16565
16972
  import { execFile as execFile2 } from "child_process";
16566
16973
  import { existsSync as existsSync5 } from "fs";
@@ -16848,6 +17255,15 @@ function registerContextTools(pi) {
16848
17255
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes("smart_recall"))
16849
17256
  throw new Error("smart_recall is disabled in Pi settings or /tools.");
16850
17257
  return executeRecall(params, signal, ctx);
17258
+ },
17259
+ renderCall(args, theme) {
17260
+ const label = theme.fg("toolTitle", "smart_recall ");
17261
+ const scope = args.scope ? theme.fg("dim", " [" + safeArg(args.scope, 20) + "]") : "";
17262
+ const query = safeArg(args.query, 100);
17263
+ return new Text4(label + theme.fg("muted", query) + scope, 0, 0);
17264
+ },
17265
+ renderResult(result, { expanded }, theme, context) {
17266
+ return tryRow(theme, () => renderRecallRow(result, expanded, theme, context), result, expanded);
16851
17267
  }
16852
17268
  });
16853
17269
  pi.registerTool({
@@ -16882,9 +17298,197 @@ function registerContextTools(pi) {
16882
17298
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes("smart_save_memory"))
16883
17299
  throw new Error("smart_save_memory is disabled in Pi settings or /tools.");
16884
17300
  return executeSaveMemory(params, signal, ctx);
17301
+ },
17302
+ renderCall(args, theme) {
17303
+ const label = theme.fg("toolTitle", "smart_save_memory ");
17304
+ if (args.ref && (args.status ?? "active") === "resolved") {
17305
+ return new Text4(label + theme.fg("muted", "resolve ") + theme.fg("dim", safeArg(args.ref, 80)), 0, 0);
17306
+ }
17307
+ const kind = theme.fg("accent", safeArg(args.kind ?? "memory", 40));
17308
+ const title = safeArg(args.title, 80);
17309
+ return new Text4(label + kind + (title ? " " + theme.fg("muted", title) : ""), 0, 0);
17310
+ },
17311
+ renderResult(result, { expanded }, theme, context) {
17312
+ return tryRow(theme, () => renderSaveRow(result, expanded, theme, context), result, expanded);
16885
17313
  }
16886
17314
  });
16887
17315
  }
17316
+ function receiptBuckets(receipts) {
17317
+ const buckets = { done: 0, pending: 0, failed: 0, unknown: 0 };
17318
+ for (const receipt of receipts) {
17319
+ const state = receipt.state ?? "unknown";
17320
+ if (state === "completed" || state === "deleted")
17321
+ buckets.done++;
17322
+ else if (state === "failed")
17323
+ buckets.failed++;
17324
+ else if (state === "unknown")
17325
+ buckets.unknown++;
17326
+ else
17327
+ buckets.pending++;
17328
+ }
17329
+ return buckets;
17330
+ }
17331
+ function renderRecallRow(result, expanded, theme, context) {
17332
+ if (context.isError) {
17333
+ const head = theme.fg("error", "recall failed:");
17334
+ return expanded ? expandedRow(theme, result, [head]) : new Text4(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
17335
+ }
17336
+ const details = result.details;
17337
+ if (!details)
17338
+ return rawFallbackRow(theme, result, expanded);
17339
+ const lines = [];
17340
+ if (Array.isArray(details.results)) {
17341
+ const results = details.results;
17342
+ lines.push(statusLabel(theme, results.length ? "done" : "skipped") + " " + theme.fg("muted", results.length + " local match(es)"));
17343
+ if (!expanded) {
17344
+ lines.push(...previewBlock(results.map((item) => "[" + (item.kind ?? "fact") + "] " + summarizeLine(item.title ?? "", 100) + (item.content ? " \u2014 " + summarizeLine(item.content, 80) : "")), false, 3));
17345
+ }
17346
+ return expanded ? expandedRow(theme, result, lines) : new Text4(lines.join(`
17347
+ `), 0, 0);
17348
+ }
17349
+ const mnemopi = details.mnemopi;
17350
+ if (mnemopi) {
17351
+ if (mnemopi.state === "recalled") {
17352
+ lines.push(statusLabel(theme, mnemopi.facts?.length ? "done" : "skipped") + " " + theme.fg("muted", (mnemopi.facts?.length ?? 0) + " mnemopi match(es)"));
17353
+ if (!expanded) {
17354
+ lines.push(...previewBlock((mnemopi.facts ?? []).map((fact) => "[" + (fact.kind ?? "fact") + "] " + summarizeLine(fact.title ?? "", 100) + (fact.content ? " \u2014 " + summarizeLine(fact.content, 80) : "")), false, 3));
17355
+ }
17356
+ } else {
17357
+ const status = mnemopi.state === "failed" ? "failed" : mnemopi.state === "skipped" ? "skipped" : "pending";
17358
+ lines.push(statusLabel(theme, status) + " " + theme.fg("muted", "mnemopi " + (mnemopi.state ?? "")));
17359
+ if (mnemopi.reason)
17360
+ lines.push(theme.fg("dim", summarizeLine(mnemopi.reason, 140)));
17361
+ }
17362
+ return expanded ? expandedRow(theme, result, lines) : new Text4(lines.join(`
17363
+ `), 0, 0);
17364
+ }
17365
+ const remote = details.remote;
17366
+ if (remote) {
17367
+ if (remote.state === "ok") {
17368
+ const buckets = receiptBuckets(remote.receipts ?? []);
17369
+ lines.push(statusLabel(theme, remote.facts?.length ? "done" : "skipped") + " " + theme.fg("muted", (remote.facts?.length ?? 0) + " remote fact(s)"));
17370
+ if (remote.receipts?.length) {
17371
+ lines.push(metaLine(theme, "prior saves", buckets.done + " completed, " + buckets.pending + " pending, " + buckets.failed + " failed, " + buckets.unknown + " unknown"));
17372
+ }
17373
+ if (!expanded) {
17374
+ lines.push(...previewBlock((remote.facts ?? []).map((fact) => (fact.type ? "[" + fact.type + "] " : "") + summarizeLine(fact.preview ?? "", 110)), false, 3));
17375
+ lines.push(expandHint(theme));
17376
+ }
17377
+ } else {
17378
+ const status = remote.state === "failed" ? "failed" : remote.state === "not-configured" || remote.state === "skipped" ? "skipped" : "pending";
17379
+ lines.push(statusLabel(theme, status) + " " + theme.fg("muted", "hindsight " + (remote.state ?? "")));
17380
+ if (remote.reason)
17381
+ lines.push(theme.fg("dim", summarizeLine(remote.reason, 140)));
17382
+ }
17383
+ return expanded ? expandedRow(theme, result, lines) : new Text4(lines.join(`
17384
+ `), 0, 0);
17385
+ }
17386
+ return rawFallbackRow(theme, result, expanded);
17387
+ }
17388
+ function renderSaveRow(result, expanded, theme, context) {
17389
+ if (context.isError) {
17390
+ const head = theme.fg("error", "save failed:");
17391
+ return expanded ? expandedRow(theme, result, [head]) : new Text4(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
17392
+ }
17393
+ const details = result.details;
17394
+ if (!details)
17395
+ return rawFallbackRow(theme, result, expanded);
17396
+ const lines = [];
17397
+ const finish = () => expanded ? expandedRow(theme, result, lines) : new Text4(lines.join(`
17398
+ `), 0, 0);
17399
+ if (details.approved === false) {
17400
+ lines.push(statusLabel(theme, "cancelled") + " " + theme.fg("muted", "not saved \u2014 user did not approve"));
17401
+ return finish();
17402
+ }
17403
+ if (typeof details.closed === "number") {
17404
+ if (details.closed > 0) {
17405
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "resolved"));
17406
+ if (expanded && typeof details.ref === "string") {
17407
+ lines.push(metaLine(theme, "ref", details.ref));
17408
+ }
17409
+ } else {
17410
+ lines.push(statusLabel(theme, "skipped") + " " + theme.fg("muted", "no active match \u2014 nothing changed"));
17411
+ if (expanded && typeof details.ref === "string") {
17412
+ lines.push(metaLine(theme, "ref", details.ref));
17413
+ }
17414
+ }
17415
+ return finish();
17416
+ }
17417
+ const local = details.memory;
17418
+ if (local && typeof details.ref === "string") {
17419
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "saved locally"));
17420
+ const fact = "[" + (local.kind ?? "memory") + "] " + (local.title ?? "");
17421
+ if (fact.trim() !== "[]")
17422
+ lines.push(metaLine(theme, "fact", fact));
17423
+ if (expanded)
17424
+ lines.push(metaLine(theme, "ref", details.ref));
17425
+ else
17426
+ lines.push(expandHint(theme));
17427
+ return finish();
17428
+ }
17429
+ const mnemopi = details.mnemopi;
17430
+ if (mnemopi) {
17431
+ if (mnemopi.state === "saved") {
17432
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "mnemopi saved"));
17433
+ if (expanded && typeof details.ref === "string")
17434
+ lines.push(metaLine(theme, "ref", details.ref));
17435
+ } else if (mnemopi.state === "resolved") {
17436
+ if (mnemopi.closed) {
17437
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "mnemopi resolved"));
17438
+ } else {
17439
+ lines.push(statusLabel(theme, "skipped") + " " + theme.fg("muted", "no matching active fact \u2014 nothing changed"));
17440
+ }
17441
+ if (expanded && typeof details.ref === "string")
17442
+ lines.push(metaLine(theme, "ref", details.ref));
17443
+ } else {
17444
+ const status = mnemopi.state === "failed" ? "failed" : "pending";
17445
+ lines.push(statusLabel(theme, status) + " " + theme.fg("muted", "mnemopi " + (mnemopi.state ?? "done")));
17446
+ if (mnemopi.reason)
17447
+ lines.push(theme.fg("dim", summarizeLine(mnemopi.reason, 140)));
17448
+ }
17449
+ return finish();
17450
+ }
17451
+ const remote = details.remote;
17452
+ if (remote) {
17453
+ const state = remote.state ?? "unknown";
17454
+ if (state === "completed") {
17455
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "saved & searchable"));
17456
+ } else if (state === "accepted") {
17457
+ lines.push(statusLabel(theme, "pending") + " " + theme.fg("muted", "accepted \u2014 not yet searchable"));
17458
+ } else if (state === "failed") {
17459
+ lines.push(statusLabel(theme, "failed") + " " + theme.fg("muted", "not saved"));
17460
+ } else if (state === "not-configured") {
17461
+ lines.push(statusLabel(theme, "skipped") + " " + theme.fg("muted", "hindsight not configured"));
17462
+ } else if (state === "deleted") {
17463
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "deleted remotely"));
17464
+ } else if (state === "not-found") {
17465
+ lines.push(statusLabel(theme, "skipped") + " " + theme.fg("muted", "already absent \u2014 nothing deleted"));
17466
+ } else if (state === "pending") {
17467
+ lines.push(statusLabel(theme, "pending") + " " + theme.fg("muted", "delete pending remotely"));
17468
+ } else {
17469
+ lines.push(statusLabel(theme, "pending") + " " + theme.fg("muted", "outcome unknown"));
17470
+ }
17471
+ if (remote.title)
17472
+ lines.push(metaLine(theme, "fact", remote.title));
17473
+ if (typeof details.ref === "string" && expanded) {
17474
+ lines.push(metaLine(theme, "ref", details.ref));
17475
+ } else if (typeof details.ref === "string" && (state === "completed" || state === "accepted")) {
17476
+ lines.push(expandHint(theme));
17477
+ }
17478
+ if (expanded || state === "unknown" || state === "failed") {
17479
+ if (remote.operationId)
17480
+ lines.push(metaLine(theme, "operation", remote.operationId));
17481
+ if (remote.documentId)
17482
+ lines.push(metaLine(theme, "document", remote.documentId));
17483
+ if (remote.target)
17484
+ lines.push(metaLine(theme, "server", remote.target));
17485
+ }
17486
+ if (remote.reason)
17487
+ lines.push(theme.fg("dim", summarizeLine(remote.reason, 140)));
17488
+ return finish();
17489
+ }
17490
+ return rawFallbackRow(theme, result, expanded);
17491
+ }
16888
17492
  function textResult(text, details = undefined) {
16889
17493
  return { content: [{ type: "text", text }], details };
16890
17494
  }
@@ -16958,7 +17562,10 @@ Not yet searchable remotely: ` + pending.map((receipt) => receipt.documentId + "
16958
17562
  }
16959
17563
  const kinds = params.kinds?.length ? new Set(params.kinds) : null;
16960
17564
  const facts = outcome.facts.filter((fact) => !kinds || !fact.metadata.kind || kinds.has(fact.metadata.kind)).slice(0, params.limit ?? 5);
16961
- return textResult(formatHindsightFacts(hindsight.target, projectId, facts) + pendingNote, {
17565
+ const buckets = receiptBuckets(refreshed);
17566
+ const receiptSummary = refreshed.length ? "Prior saves: " + buckets.done + " completed, " + buckets.pending + " pending, " + buckets.failed + " failed, " + buckets.unknown + " unknown." : "";
17567
+ return textResult(formatHindsightFacts(hindsight.target, projectId, facts) + pendingNote + (receiptSummary ? `
17568
+ ` + receiptSummary : ""), {
16962
17569
  remote: {
16963
17570
  state: "ok",
16964
17571
  target: describeHindsightTarget(hindsight.target),
@@ -16966,7 +17573,8 @@ Not yet searchable remotely: ` + pending.map((receipt) => receipt.documentId + "
16966
17573
  id: fact.id,
16967
17574
  documentId: fact.documentId,
16968
17575
  type: fact.type,
16969
- mentionedAt: fact.mentionedAt
17576
+ mentionedAt: fact.mentionedAt,
17577
+ preview: fact.text.replace(/\s+/g, " ").slice(0, 160)
16970
17578
  })),
16971
17579
  redactions: scrubber.count(),
16972
17580
  receipts: refreshed.map((receipt) => ({
@@ -17085,13 +17693,20 @@ Destination:
17085
17693
  ref: "hindsight:" + memoryId + "@" + digest,
17086
17694
  remote: "receipt" in outcome && outcome.receipt ? {
17087
17695
  state: outcome.state,
17696
+ title,
17088
17697
  documentId: outcome.receipt.documentId,
17089
17698
  operationId: outcome.receipt.operationId,
17090
17699
  target: describeHindsightTarget(hindsight.target),
17091
17700
  ..."reason" in outcome ? { reason: outcome.reason } : {}
17092
17701
  } : outcome
17093
17702
  };
17094
- return textResult(describeRemoteSave(outcome), details);
17703
+ let text = describeRemoteSave(outcome);
17704
+ if ("receipt" in outcome && outcome.receipt && (outcome.state === "completed" || outcome.state === "accepted")) {
17705
+ text += `
17706
+ Title: ` + title + `
17707
+ Ref: hindsight:` + memoryId + "@" + digest + (outcome.state === "accepted" ? " (accepted, NOT yet searchable; resolves only after the server completes indexing)" : "");
17708
+ }
17709
+ return textResult(text, details);
17095
17710
  }
17096
17711
  async function resolveMemoryByRef(params, signal, ctx, config, projectId) {
17097
17712
  const ref = params.ref ? parseMemoryRef(params.ref) : null;
@@ -17210,6 +17825,9 @@ function resolveModels(ctx, primary, config, explicit = false) {
17210
17825
  return { segModel, sumModel, verifyModel };
17211
17826
  }
17212
17827
 
17828
+ // src/app/register-smart-compact-tool.ts
17829
+ import { Text as Text5 } from "@earendil-works/pi-tui";
17830
+
17213
17831
  // src/ui/dashboard-format.ts
17214
17832
  function dashboardPageSize(rows = process.stdout.rows) {
17215
17833
  if (!rows)
@@ -17555,16 +18173,16 @@ function statusClass(status) {
17555
18173
  return "warn";
17556
18174
  return "good";
17557
18175
  }
17558
- function statusLabel(status) {
18176
+ function statusLabel2(status) {
17559
18177
  return status ?? "success";
17560
18178
  }
17561
18179
  function badge(status) {
17562
- const label = statusLabel(status);
18180
+ const label = statusLabel2(status);
17563
18181
  return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
17564
18182
  }
17565
18183
  function summarizeDashboard(entries) {
17566
18184
  const durations = entries.map(metricDuration).filter(Boolean);
17567
- const success = entries.filter((e) => statusLabel(e.status) === "success").length;
18185
+ const success = entries.filter((e) => statusLabel2(e.status) === "success").length;
17568
18186
  const timeout = entries.filter((e) => e.status === "timeout").length;
17569
18187
  const error = entries.filter((e) => e.status === "error" && e.failureKind !== "cancelled").length;
17570
18188
  const dryRun = entries.filter((e) => e.status === "dry-run").length;
@@ -18075,7 +18693,7 @@ function registerSmartCompactTool(pi, dependencies) {
18075
18693
  pi.registerTool({
18076
18694
  name: "smart_compact",
18077
18695
  label: "Smart Compact",
18078
- description: "Prepares and stages a verified summary for the next /compact (expires in 5 min; never applies mid-turn). Call only when actual context usage is high; tool=XX% is tool-output share, not fullness.",
18696
+ description: "Prepares and stages a verified summary for the next /compact (configured staging TTL; never applies mid-turn). Call only when actual context usage is high; tool=XX% is tool-output share, not fullness.",
18079
18697
  parameters: {
18080
18698
  type: "object",
18081
18699
  properties: {
@@ -18121,6 +18739,19 @@ function registerSmartCompactTool(pi, dependencies) {
18121
18739
  }
18122
18740
  }
18123
18741
  },
18742
+ renderCall(args, theme) {
18743
+ const label = theme.fg("toolTitle", "smart_compact ");
18744
+ const flags = [
18745
+ safeArg(args.mode, 20),
18746
+ args.dry_run === true ? "dry-run" : "",
18747
+ args.report === true ? "report" : "",
18748
+ args.dashboard === true ? "dashboard" : ""
18749
+ ].filter(Boolean).join(" ");
18750
+ return new Text5(label + theme.fg("muted", flags || "auto"), 0, 0);
18751
+ },
18752
+ renderResult(result, { expanded }, theme, context) {
18753
+ return tryRow(theme, () => renderCompactRow(result, expanded, theme, context), result, expanded);
18754
+ },
18124
18755
  async execute(_id, params, signal, _onUpdate, ctx) {
18125
18756
  if (!policy.isAgentToolEnabled()) {
18126
18757
  return textResult2("smart_compact is hidden from the agent for this session. The user can still run /smart-compact manually.");
@@ -18144,13 +18775,13 @@ function registerSmartCompactTool(pi, dependencies) {
18144
18775
  const dashboard = action === "dashboard" ? writeMetricsDashboard() : null;
18145
18776
  return textResult2(report + (dashboard ? `
18146
18777
 
18147
- Dashboard: ` + dashboard : ""));
18778
+ Dashboard: ` + dashboard : ""), { display: { kind: "metrics", dashboard: dashboard !== null } });
18148
18779
  }
18149
18780
  const config = loadConfig();
18150
18781
  const resolvedMode = mode ?? config.mode;
18151
18782
  const sessionId = resolveSessionId(ctx);
18152
18783
  if (!dryRun && pendingRef.peek(sessionId)) {
18153
- return textResult2("A smart summary is already staged; context is unchanged. Run /compact before the 5-minute staging TTL expires to apply it. No LLM calls were made.");
18784
+ return textResult2("A smart summary is already staged; context is unchanged. Run /compact before the configured staging TTL expires to apply it. No LLM calls were made.");
18154
18785
  }
18155
18786
  const usage = ctx.getContextUsage?.();
18156
18787
  const totalTokens = usage?.tokens ?? 0;
@@ -18194,10 +18825,19 @@ Dashboard: ` + dashboard : ""));
18194
18825
  content: [
18195
18826
  {
18196
18827
  type: "text",
18197
- text: (staged.details.method === "native" ? "Native compaction prepared (" + staged.details.model + "; provider state, not EESV-verified). Tokens: " : "Smart summary prepared (" + resolvedMode + " \u2192 " + (staged.details.mode ?? staged.details.profile) + "). Tokens: ") + (staged.tokensBefore ?? 0).toLocaleString() + " \u2014 staged, not applied, for " + Math.round(FIVE_MINUTES_MS / 60000) + " min. Context is unchanged. Run /compact within that time to apply it; expiry discards the candidate."
18828
+ text: (staged.details.method === "native" ? "Native compaction prepared (" + staged.details.model + "; provider state, not EESV-verified). Tokens: " : "Smart summary prepared (" + resolvedMode + " \u2192 " + (staged.details.mode ?? staged.details.profile) + "). Tokens: ") + (staged.tokensBefore ?? 0).toLocaleString() + " \u2014 staged, not applied, for " + config.pendingTtlMs / 60000 + " min. Context is unchanged. Run /compact within that time to apply it; expiry discards the candidate."
18198
18829
  }
18199
18830
  ],
18200
- details: staged.details
18831
+ details: {
18832
+ ...staged.details,
18833
+ display: {
18834
+ state: "staged",
18835
+ method: staged.details.method,
18836
+ mode: staged.details.mode ?? staged.details.profile ?? resolvedMode,
18837
+ tokens: staged.tokensBefore ?? 0,
18838
+ ttlMinutes: config.pendingTtlMs / 60000
18839
+ }
18840
+ }
18201
18841
  };
18202
18842
  }
18203
18843
  if (outcome.kind === "dry-run") {
@@ -18209,13 +18849,16 @@ Dashboard: ` + dashboard : ""));
18209
18849
  text: "Dry run finished (" + resolvedMode + ", " + seconds + "s). Pipeline ran successfully; no summary was staged."
18210
18850
  }
18211
18851
  ],
18212
- details: outcome.details
18852
+ details: {
18853
+ ...outcome.details,
18854
+ display: { state: "dry-run", mode: resolvedMode, seconds: Number(seconds) }
18855
+ }
18213
18856
  };
18214
18857
  }
18215
18858
  if (outcome.kind === "cancelled") {
18216
- return textResult2("Smart compact cancelled by " + outcome.source + "; no summary was staged.");
18859
+ return textResult2("Smart compact cancelled by " + outcome.source + "; no summary was staged.", { display: { state: "cancelled", source: outcome.source } });
18217
18860
  }
18218
- return textResult2("Smart compact skipped: " + outcome.reason.replace(/-/g, " ") + ". No summary was staged.");
18861
+ return textResult2("Smart compact skipped: " + outcome.reason.replace(/-/g, " ") + ". No summary was staged.", { display: { state: "skipped" } });
18219
18862
  } catch (error) {
18220
18863
  recordIssue({ key: "tool.smart-compact", message: "smart_compact failed: " + errorDetail(error) + ".", error });
18221
18864
  throw new Error(formatCompactErrorForUi(error));
@@ -18223,13 +18866,42 @@ Dashboard: ` + dashboard : ""));
18223
18866
  }
18224
18867
  });
18225
18868
  }
18226
- function textResult2(text) {
18227
- return { content: [{ type: "text", text }], details: undefined };
18869
+ function textResult2(text, details = undefined) {
18870
+ return { content: [{ type: "text", text }], details };
18871
+ }
18872
+ function renderCompactRow(result, expanded, theme, context) {
18873
+ if (context.isError) {
18874
+ const head = theme.fg("error", "smart_compact failed:");
18875
+ return expanded ? expandedRow(theme, result, [head]) : new Text5(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
18876
+ }
18877
+ const display = result.details?.display;
18878
+ if (!display)
18879
+ return rawFallbackRow(theme, result, expanded);
18880
+ const lines = [];
18881
+ if (display.state === "staged") {
18882
+ lines.push(statusLabel(theme, "pending") + " " + theme.fg("muted", "staged \u2014 NOT applied; run /compact within " + (Number(display.ttlMinutes) || 0) + " min"));
18883
+ lines.push(metaLine(theme, "summary", (display.method === "native" ? "native" : String(display.mode ?? "")) + " \xB7 " + (Number(display.tokens) || 0).toLocaleString() + " tokens"));
18884
+ } else if (display.state === "dry-run") {
18885
+ lines.push(statusLabel(theme, "info") + " " + theme.fg("muted", "dry run \u2014 nothing staged"));
18886
+ if (display.seconds)
18887
+ lines.push(metaLine(theme, "pipeline", display.seconds + "s"));
18888
+ } else if (display.state === "cancelled") {
18889
+ lines.push(statusLabel(theme, "cancelled") + " " + theme.fg("muted", "by " + String(display.source ?? "host")));
18890
+ } else if (display.state === "skipped") {
18891
+ lines.push(statusLabel(theme, "skipped") + " " + theme.fg("dim", summarizeLine(firstTextContent(result.content), 140)));
18892
+ } else if (display.kind === "metrics") {
18893
+ lines.push(statusLabel(theme, "info") + " " + theme.fg("muted", "metrics report" + (display.dashboard ? " (dashboard written)" : "")));
18894
+ } else {
18895
+ return rawFallbackRow(theme, result, expanded);
18896
+ }
18897
+ return expanded ? expandedRow(theme, result, lines) : new Text5(lines.join(`
18898
+ `), 0, 0);
18228
18899
  }
18229
18900
 
18230
18901
  // src/app/register-smart-context-tool.ts
18231
18902
  import { randomUUID as randomUUID4 } from "crypto";
18232
18903
  import { StringEnum as StringEnum2 } from "@earendil-works/pi-ai";
18904
+ import { Text as Text6 } from "@earendil-works/pi-tui";
18233
18905
  import { Type as Type4 } from "typebox";
18234
18906
 
18235
18907
  // src/app/context-evidence.ts
@@ -18477,11 +19149,11 @@ function registerSmartContextTool(pi, options = {}) {
18477
19149
  const current = config();
18478
19150
  return new SecretScrubber(current.scrubSecrets, current.scrubPii).scrubText(text).value;
18479
19151
  };
18480
- const reply = (text) => ({ content: [{ type: "text", text }], details: undefined });
19152
+ const reply = (text, details = undefined) => ({ content: [{ type: "text", text }], details });
18481
19153
  pi.registerTool({
18482
19154
  name: TOOL_NAME,
18483
19155
  label: "Session Context",
18484
- description: "Context hygiene: status/search/read archived output (scope=lineage adds parent sessions); plan/trim old output; checkpoint, then rewind(report) drops research. Applies after the batch; no file/process rollback.",
19156
+ description: "Check status for context pressure/gates. Before a large read-only detour, checkpoint; when finished, rewind(report) keeps findings and drops safe research under pressure. plan/trim old output; search/read archived evidence (scope=lineage includes parents). Changes apply after the batch, never roll back files/processes.",
18485
19157
  parameters: Type4.Object({
18486
19158
  action: StringEnum2(["status", "plan", "checkpoint", "rewind", "trim", "read", "search"]),
18487
19159
  label: Type4.Optional(Type4.String({ maxLength: 120, description: "Checkpoint label." })),
@@ -18505,15 +19177,28 @@ function registerSmartContextTool(pi, options = {}) {
18505
19177
  const branch = ctx.sessionManager.getBranch();
18506
19178
  const state = inspectContext(branch, sessionId);
18507
19179
  if (params.action === "plan") {
18508
- const plan = planContextTrim(branch, state.checkpoint?.originId);
18509
- return reply(JSON.stringify({
19180
+ const plan = planContextTrim(branch, state.checkpoint?.originId, { readerApi: ctx.model?.api });
19181
+ const payload = {
19182
+ pressure: contextPressure(ctx, config()),
19183
+ blockedReason: plan.blockedReason,
18510
19184
  outputs: plan.references.length,
18511
19185
  superseded: plan.superseded,
18512
19186
  savedChars: plan.savedChars,
18513
19187
  batch: plan.automatic,
18514
19188
  cooldownTurns: plan.cooldownTurns,
18515
- note: "No changes applied. Automatic hygiene also requires enablement and an uncontested boundary; it commits under pressure, at price break-even, or once the prompt cache is cold. Estimates are not billed-token savings."
18516
- }));
19189
+ note: "No changes applied. Automatic cleanup requires enablement, a safe batch and an uncontested boundary. Pressure-only is the default; cache economics is opt-in. Estimates are not billed-token savings."
19190
+ };
19191
+ return reply(JSON.stringify(payload), {
19192
+ display: {
19193
+ kind: "plan",
19194
+ pressurePercent: payload.pressure?.percent == null ? null : Math.round(payload.pressure.percent),
19195
+ outputs: payload.outputs,
19196
+ superseded: payload.superseded,
19197
+ savedChars: payload.savedChars,
19198
+ batchReady: payload.batch === "ready",
19199
+ batchReason: payload.blockedReason ?? payload.batch ?? null
19200
+ }
19201
+ });
18517
19202
  }
18518
19203
  const offset = params.offset ?? 0;
18519
19204
  if (params.action === "status" || params.action === "read" || params.action === "search") {
@@ -18530,7 +19215,20 @@ function registerSmartContextTool(pi, options = {}) {
18530
19215
  const evidence = contextEvidence(branch, sessionId, new SecretScrubber(settings.scrubSecrets, settings.scrubPii), lineage);
18531
19216
  if (params.action === "status") {
18532
19217
  const sources = evidence.list.slice(offset, offset + limit);
18533
- return reply(scrub(JSON.stringify({
19218
+ const plan = planContextTrim(branch, state.checkpoint?.originId, { readerApi: ctx.model?.api });
19219
+ const pressure = contextPressure(ctx, settings);
19220
+ const cleanup = {
19221
+ enabled: settings.contextHygieneEnabled || settings.autoTrigger && settings.autoTriggerStrategy === "background",
19222
+ pressureOnly: settings.contextPressureOnly,
19223
+ maintenanceAvailable: options.canAutoTrim?.(ctx) !== false,
19224
+ batch: plan.automatic,
19225
+ savedChars: plan.savedChars,
19226
+ blockedReason: plan.blockedReason,
19227
+ agentAllowed: options.canAgentMutate?.(ctx) !== false
19228
+ };
19229
+ const payload = {
19230
+ pressure,
19231
+ cleanup,
18534
19232
  checkpoint: state.checkpoint ? { id: state.checkpoint.id, label: state.checkpoint.label } : null,
18535
19233
  rewind: Boolean(state.checkpoint),
18536
19234
  reason: state.invalidReason,
@@ -18546,7 +19244,21 @@ function registerSmartContextTool(pi, options = {}) {
18546
19244
  sources: evidence.list.filter((source) => source.session === parent.sessionId).length
18547
19245
  })) } : {},
18548
19246
  nextOffset: offset + limit < evidence.list.length ? offset + limit : null
18549
- })));
19247
+ };
19248
+ return reply(scrub(JSON.stringify(payload)), {
19249
+ display: {
19250
+ kind: "status",
19251
+ pressurePercent: pressure.percent == null ? null : Math.round(pressure.percent),
19252
+ cleanupEnabled: cleanup.enabled,
19253
+ cleanupBatchReady: plan.automatic === "ready",
19254
+ cleanupBatchReason: plan.automatic === "ready" ? null : plan.automatic,
19255
+ cleanupBlockedReason: cleanup.blockedReason ?? null,
19256
+ checkpoint: payload.checkpoint?.label ?? null,
19257
+ pending: payload.pending ?? null,
19258
+ archivedOutputs: payload.archivedOutputs,
19259
+ nextOffset: payload.nextOffset
19260
+ }
19261
+ });
18550
19262
  }
18551
19263
  if (params.action === "search") {
18552
19264
  if (!params.query)
@@ -18567,6 +19279,12 @@ ${page.text}`);
18567
19279
  if (options.canAgentMutate?.(ctx) === false) {
18568
19280
  throw new Error("Agent-requested context changes are disabled by policy; status, plan, search and read stay available.");
18569
19281
  }
19282
+ if (params.action !== "checkpoint" && config().contextPressureOnly && !contextPressure(ctx, config()).cleanup) {
19283
+ throw new Error("No context pressure (or usage is unavailable); history left unchanged. Check status. Human commands can request early cleanup.");
19284
+ }
19285
+ if (params.action !== "checkpoint" && options.canAutoTrim?.(ctx) === false) {
19286
+ throw new Error("Prepared/running compaction or unavailable recovery tools take priority; history left unchanged.");
19287
+ }
18570
19288
  if (options.isPaused?.(ctx))
18571
19289
  throw new Error("Context changes are paused while a navigation pivot is pending.");
18572
19290
  if (queued)
@@ -18593,7 +19311,21 @@ ${page.text}`);
18593
19311
  text: params.action === "rewind" ? text : text.slice(0, 120),
18594
19312
  signal
18595
19313
  };
18596
- return reply(`${params.action} queued for the completed tool batch. ${params.action === "rewind" ? "Stop research here; files and processes stay unchanged." : "Use status to inspect the committed state."}`);
19314
+ return reply(`${params.action} queued for the completed tool batch. ${params.action === "rewind" ? "Stop research here; files and processes stay unchanged." : "Use status to inspect the committed state."}`, {
19315
+ display: { state: "queued", action: params.action, label: text.slice(0, 120) }
19316
+ });
19317
+ },
19318
+ renderCall(args, theme) {
19319
+ const label = theme.fg("toolTitle", "smart_context ");
19320
+ const action = safeArg(args.action, 20);
19321
+ const id = safeArg(args.id, 80);
19322
+ const query = safeArg(args.query, 80);
19323
+ const labelArg = safeArg(args.label, 80);
19324
+ const detail = id ? " " + theme.fg("dim", id) : query ? " " + theme.fg("dim", query) : labelArg ? " " + theme.fg("muted", labelArg) : "";
19325
+ return new Text6(label + theme.fg("accent", action) + detail, 0, 0);
19326
+ },
19327
+ renderResult(result, { expanded }, theme, context) {
19328
+ return tryRow(theme, () => renderSmartContextRow(result, expanded, theme, context), result, expanded);
18597
19329
  }
18598
19330
  });
18599
19331
  const clear = () => {
@@ -18640,7 +19372,8 @@ ${page.text}`);
18640
19372
  if (!current) {
18641
19373
  if (mark?.sessionId !== sessionId)
18642
19374
  return;
18643
- if (!unchangedSince(branch, mark.leafId)) {
19375
+ const settings = config();
19376
+ if (!settings.contextHygieneEnabled || settings.contextPressureOnly || options.canAutoTrim?.(ctx) === false || !unchangedSince(branch, mark.leafId) || lastAnchorBoundary(branch) !== lastAnchorBoundary(branch.slice(0, branch.findIndex((entry) => entry.id === mark.leafId) + 1)) || thinkingEditReason(branch, mark.entries, ctx.model?.api)) {
18644
19377
  mark = null;
18645
19378
  return;
18646
19379
  }
@@ -18665,7 +19398,7 @@ ${page.text}`);
18665
19398
  return changed ? { messages } : undefined;
18666
19399
  };
18667
19400
  const vetoWarming = (event, ctx, sessionId) => {
18668
- if (mark?.sessionId !== sessionId || !unchangedSince(ctx.sessionManager.getBranch(), mark.leafId))
19401
+ if (config().contextPressureOnly || mark?.sessionId !== sessionId || !unchangedSince(ctx.sessionManager.getBranch(), mark.leafId) || thinkingEditReason(ctx.sessionManager.getBranch(), mark.entries, ctx.model?.api))
18669
19402
  return false;
18670
19403
  const cost = ctx.model?.cost;
18671
19404
  const price = cost ? cost.cacheWrite > 0 ? cost.cacheWrite : cost.input : NaN;
@@ -18683,15 +19416,25 @@ ${page.text}`);
18683
19416
  if (event.outcome !== "completed" || request?.signal?.aborted) {
18684
19417
  return request ? cancelled(event, "The turn did not complete.") : undefined;
18685
19418
  }
18686
- if (request && !request.manual && (config().toolLoading === "off" || !active()))
19419
+ const requestTool = request?.anchorBoundary !== undefined ? "smart_navigation" : TOOL_NAME;
19420
+ if (request && !request.manual && (config().toolLoading === "off" || !pi.getActiveTools().includes(requestTool)))
18687
19421
  return cancelled(event, "Agent tool access was revoked.");
19422
+ if (request?.anchorBoundary !== undefined && !config().contextNavigationEnabled)
19423
+ return cancelled(event, "Navigation was disabled.");
18688
19424
  if (options.isPaused?.(ctx)) {
18689
19425
  return request ? cancelled(event, "A pending navigation pivot takes priority.") : undefined;
18690
19426
  }
18691
19427
  if (request && !request.manual && options.canAgentMutate?.(ctx) === false) {
18692
19428
  return cancelled(event, "Agent-requested context changes are disabled by policy.");
18693
19429
  }
18694
- let pressure = false;
19430
+ if (request && !request.manual && request.action !== "checkpoint" && options.canAutoTrim?.(ctx) === false) {
19431
+ return cancelled(event, "Prepared/running compaction or unavailable recovery tools take priority.");
19432
+ }
19433
+ const settings = config();
19434
+ const pressure = contextPressure(ctx, settings).cleanup;
19435
+ if (request && !request.manual && request.action !== "checkpoint" && settings.contextPressureOnly && !pressure) {
19436
+ return cancelled(event, "Context pressure cleared; history left unchanged.");
19437
+ }
18695
19438
  if (!request) {
18696
19439
  const configNow = config();
18697
19440
  const hygiene = configNow.contextHygieneEnabled;
@@ -18700,11 +19443,11 @@ ${page.text}`);
18700
19443
  const enabled = hygiene || configNow.autoTrigger && configNow.autoTriggerStrategy === "background";
18701
19444
  if (!enabled || options.canAutoTrim?.(ctx) === false)
18702
19445
  return;
18703
- const usage = ctx.getContextUsage()?.tokens;
18704
- const window = effectiveContextWindow(ctx.model, configNow);
18705
- pressure = typeof usage === "number" && Number.isFinite(usage) && typeof window === "number" && Number.isFinite(window) && window > 0 && usage >= preparationWindow(configNow, window).startTokens;
18706
- if (!hygiene && !pressure)
18707
- return;
19446
+ if ((configNow.contextPressureOnly || !hygiene) && !pressure) {
19447
+ mark = null;
19448
+ if (!applied)
19449
+ return;
19450
+ }
18708
19451
  }
18709
19452
  const branch = ctx.sessionManager.getBranch();
18710
19453
  const sessionId = resolveSessionId(ctx);
@@ -18735,13 +19478,15 @@ ${page.text}`);
18735
19478
  }
18736
19479
  })];
18737
19480
  } else if (request?.action === "rewind") {
18738
- entries = planContextRewind(branch, sessionId, request.checkpointId, request.text).entries;
19481
+ entries = planContextRewind(branch, sessionId, request.checkpointId, request.text, ctx.model?.api).entries;
18739
19482
  } else if (!request && applied?.sessionId === sessionId && unchangedSince(branch, applied.leafId)) {
18740
19483
  mark = null;
18741
19484
  entries = applied.entries;
18742
19485
  applied = null;
18743
19486
  } else {
18744
- const plan = planContextTrim(branch, state.checkpoint?.originId);
19487
+ const plan = planContextTrim(branch, state.checkpoint?.originId, { anchorBoundary: request?.anchorBoundary, readerApi: ctx.model?.api });
19488
+ if (plan.blockedReason)
19489
+ return request ? cancelled(event, plan.blockedReason) : undefined;
18745
19490
  if (request) {
18746
19491
  entries = trimEntries(plan, request.manual ? "manual" : "agent");
18747
19492
  } else {
@@ -18782,41 +19527,54 @@ ${page.text}`);
18782
19527
  }
18783
19528
  return { entries: [...event.entries, ...entries] };
18784
19529
  });
18785
- const controller = {
18786
- requestManualTrim(ctx) {
18787
- const sessionId = resolveSessionId(ctx);
18788
- if (isUnresolvedSessionId(sessionId)) {
18789
- return { state: "unavailable", notice: "Context control needs an identifiable session." };
18790
- }
18791
- if (options.isPaused?.(ctx)) {
18792
- return { state: "paused", notice: "A navigation pivot is pending; try again after it finishes." };
18793
- }
18794
- if (queued) {
18795
- return { state: "busy", notice: "Another context change is already queued; it applies at the next turn boundary." };
18796
- }
18797
- const branch = ctx.sessionManager.getBranch();
18798
- const plan = planContextTrim(branch, inspectContext(branch, sessionId).checkpoint?.originId);
18799
- if (!plan.entries.length) {
18800
- return { state: "no-eligible", notice: "No eligible archived output to trim." };
18801
- }
18802
- mark = null;
18803
- queued = {
18804
- action: "trim",
18805
- callId: "",
18806
- sessionId,
18807
- originId: branch.at(-1)?.id ?? "",
18808
- checkpointId: "",
18809
- text: "",
18810
- manual: true
18811
- };
18812
- return {
18813
- state: "queued",
18814
- notice: "Manual trim queued. The first next provider request is not yet trimmed; the change applies at the next completed turn boundary. A pending navigation pivot or newer boundary change cancels it."
18815
- };
18816
- },
19530
+ const requestTrim = (ctx, anchor) => {
19531
+ const sessionId = resolveSessionId(ctx);
19532
+ if (isUnresolvedSessionId(sessionId)) {
19533
+ return { state: "unavailable", notice: "Context control needs an identifiable session." };
19534
+ }
19535
+ if (options.isPaused?.(ctx)) {
19536
+ return { state: "paused", notice: "A navigation pivot is pending; try again after it finishes." };
19537
+ }
19538
+ if (queued) {
19539
+ return { state: "busy", notice: "Another context change is already queued; it applies at the next turn boundary." };
19540
+ }
19541
+ if (anchor && options.canAutoTrim?.(ctx) === false) {
19542
+ return { state: "busy", notice: "Cleanup deferred: prepared/running compaction or unavailable recovery tools take priority." };
19543
+ }
19544
+ if (anchor?.callId && (options.canAgentMutate?.(ctx) === false || config().contextPressureOnly && !contextPressure(ctx, config()).cleanup)) {
19545
+ return { state: "unavailable", notice: "Cleanup not requested: no context pressure or agent changes are disabled." };
19546
+ }
19547
+ const branch = ctx.sessionManager.getBranch();
19548
+ const origin = anchor ? branch.findIndex((entry) => entry.id === anchor.originId) : -1;
19549
+ if (anchor && origin < 0)
19550
+ return { state: "unavailable", notice: "Anchor origin is no longer on this branch." };
19551
+ const anchorBoundary = anchor ? lastAnchorBoundary(branch.slice(0, origin + 1)) ?? null : undefined;
19552
+ const plan = planContextTrim(branch, inspectContext(branch, sessionId).checkpoint?.originId, { anchorBoundary, readerApi: ctx.model?.api });
19553
+ if (!plan.entries.length || anchor?.callId && plan.automatic !== "ready") {
19554
+ return { state: "no-eligible", notice: plan.blockedReason ?? (anchor ? "No safe cleanup batch ready; recent turns and protected prefixes stay unchanged." : "No eligible archived output to trim.") };
19555
+ }
19556
+ mark = null;
19557
+ queued = {
19558
+ action: "trim",
19559
+ callId: anchor?.callId ?? "",
19560
+ sessionId,
19561
+ originId: anchor?.originId ?? branch.at(-1)?.id ?? "",
19562
+ checkpointId: "",
19563
+ text: "",
19564
+ manual: !anchor?.callId,
19565
+ anchorBoundary,
19566
+ signal: anchor?.signal
19567
+ };
19568
+ return {
19569
+ state: "queued",
19570
+ notice: anchor?.callId ? "Anchor cleanup queued for the completed tool batch; prior anchor prefixes and recent turns stay protected. Use status to confirm the committed state." : "Manual trim queued. The first next provider request is not yet trimmed; the change applies at the next completed turn boundary. A pending navigation pivot or newer boundary change cancels it."
19571
+ };
19572
+ };
19573
+ return {
19574
+ requestManualTrim: (ctx) => requestTrim(ctx),
19575
+ requestAnchorTrim: (ctx, originId, callId, signal) => requestTrim(ctx, { originId, callId, signal }),
18817
19576
  deferredTrim: deferred
18818
19577
  };
18819
- return controller;
18820
19578
  }
18821
19579
  function cancelled(event, reason) {
18822
19580
  return {
@@ -18828,6 +19586,48 @@ function cancelled(event, reason) {
18828
19586
  }]
18829
19587
  };
18830
19588
  }
19589
+ function renderSmartContextRow(result, expanded, theme, context) {
19590
+ if (context.isError) {
19591
+ const head = theme.fg("error", "smart_context failed:");
19592
+ return expanded ? expandedRow(theme, result, [head]) : new Text6(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
19593
+ }
19594
+ const display = result.details?.display;
19595
+ if (!display)
19596
+ return rawFallbackRow(theme, result, expanded);
19597
+ const lines = [];
19598
+ if (display.state === "queued") {
19599
+ lines.push(statusLabel(theme, "queued") + " " + theme.fg("muted", String(display.action ?? "change") + " queued \u2014 applies at the turn boundary, not yet applied"));
19600
+ if (typeof display.label === "string" && display.label) {
19601
+ lines.push(theme.fg("dim", summarizeLine(display.label, 120)));
19602
+ }
19603
+ return new Text6(lines.join(`
19604
+ `), 0, 0);
19605
+ }
19606
+ if (display.kind === "status") {
19607
+ const percent = display.pressurePercent;
19608
+ lines.push(theme.fg("muted", "context " + (percent == null ? "unknown" : percent + "%")) + " " + statusLabel(theme, display.cleanupBatchReady ? "pending" : "info") + " " + theme.fg("dim", display.cleanupBatchReady ? "cleanup batch ready" : display.cleanupBatchReason ? "cleanup: " + String(display.cleanupBatchReason) : "no cleanup batch"));
19609
+ lines.push(metaLine(theme, "archived outputs", String(display.archivedOutputs ?? 0)));
19610
+ if (display.checkpoint)
19611
+ lines.push(metaLine(theme, "checkpoint", String(display.checkpoint)));
19612
+ if (display.pending)
19613
+ lines.push(metaLine(theme, "pending", String(display.pending)));
19614
+ if (display.cleanupBlockedReason)
19615
+ lines.push(theme.fg("dim", summarizeLine(String(display.cleanupBlockedReason), 120)));
19616
+ return expanded ? expandedRow(theme, result, lines) : new Text6(lines.join(`
19617
+ `), 0, 0);
19618
+ }
19619
+ if (display.kind === "plan") {
19620
+ const percent = display.pressurePercent;
19621
+ lines.push(theme.fg("muted", "context " + (percent == null ? "unknown" : percent + "%")) + " " + theme.fg("dim", "trim plan: " + (Number(display.outputs) || 0) + " output(s) \u2248" + (Number(display.savedChars) || 0).toLocaleString() + " chars"));
19622
+ if (display.batchReady)
19623
+ lines.push(metaLine(theme, "batch", "ready"));
19624
+ else if (display.batchReason)
19625
+ lines.push(metaLine(theme, "batch", String(display.batchReason)));
19626
+ return expanded ? expandedRow(theme, result, lines) : new Text6(lines.join(`
19627
+ `), 0, 0);
19628
+ }
19629
+ return rawFallbackRow(theme, result, expanded);
19630
+ }
18831
19631
 
18832
19632
  // src/app/model-feasibility.ts
18833
19633
  async function createModelFeasibilityResolver(ctx, config, requestedMode = config.mode, steering = {}) {
@@ -19241,7 +20041,7 @@ import {
19241
20041
  getKeybindings as getKeybindings2,
19242
20042
  Key as Key4,
19243
20043
  matchesKey as matchesKey4,
19244
- Text as Text5,
20044
+ Text as Text9,
19245
20045
  wrapTextWithAnsi as wrapTextWithAnsi3
19246
20046
  } from "@earendil-works/pi-tui";
19247
20047
 
@@ -19250,7 +20050,7 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
19250
20050
  import {
19251
20051
  getKeybindings,
19252
20052
  SettingsList,
19253
- truncateToWidth as truncateToWidth3,
20053
+ truncateToWidth as truncateToWidth4,
19254
20054
  visibleWidth as visibleWidth2,
19255
20055
  wrapTextWithAnsi as wrapTextWithAnsi2
19256
20056
  } from "@earendil-works/pi-tui";
@@ -19296,8 +20096,8 @@ class SmartSettingsList extends SettingsList {
19296
20096
  internals.items.forEach((item, index) => {
19297
20097
  const original = saved[index];
19298
20098
  const shown = (this.inactive.has(item.id) ? INACTIVE_PREFIX : "") + original.currentValue;
19299
- item.label = truncateToWidth3(original.label, labelWidth, "\u2026");
19300
- item.currentValue = truncateToWidth3(shown, valueWidth, "\u2026");
20099
+ item.label = truncateToWidth4(original.label, labelWidth, "\u2026");
20100
+ item.currentValue = truncateToWidth4(shown, valueWidth, "\u2026");
19301
20101
  if (index === internals.selectedIndex) {
19302
20102
  item.description = [
19303
20103
  item.label !== original.label ? original.label : "",
@@ -19338,7 +20138,7 @@ class SmartSettingsList extends SettingsList {
19338
20138
  // src/ui/settings-overlay.ts
19339
20139
  import {
19340
20140
  Container as Container4,
19341
- Text as Text4
20141
+ Text as Text8
19342
20142
  } from "@earendil-works/pi-tui";
19343
20143
 
19344
20144
  // src/ui/settings-complex.ts
@@ -19349,7 +20149,7 @@ import {
19349
20149
  Key as Key3,
19350
20150
  matchesKey as matchesKey3,
19351
20151
  SelectList as SelectList3,
19352
- Text as Text3
20152
+ Text as Text7
19353
20153
  } from "@earendil-works/pi-tui";
19354
20154
  var MODEL_SETTINGS = [
19355
20155
  {
@@ -19397,7 +20197,7 @@ var MIN_CONTEXT_SETTING = {
19397
20197
  var MAX_CONTEXT_SETTING = {
19398
20198
  id: "maxContextTokens",
19399
20199
  label: "Context cap for start % (tokens)",
19400
- description: "Automatic compaction measures Start at context % against this many tokens instead of the full model window, when smaller. Model requests and safety headroom still use the real window. 0 = off.",
20200
+ description: "Cleanup, agent tools and automatic compaction measure pressure against this cap when smaller than the model window. Model requests and safety headroom still use the real window. 0 = off.",
19401
20201
  placeholder: "0 or 16384\u20132000000; blank = off",
19402
20202
  parse: numberParser(CONFIG_NUMERIC_LIMITS.maxContextTokens),
19403
20203
  format: (value) => value === undefined || value === 0 ? "off" : String(value)
@@ -19405,8 +20205,8 @@ var MAX_CONTEXT_SETTING = {
19405
20205
  var parsePreparePercent = numberParser(CONFIG_NUMERIC_LIMITS.prepareContextPercent);
19406
20206
  var PREPARE_CONTEXT_SETTING = {
19407
20207
  id: "prepareContextPercent",
19408
- label: "Prepare at context %",
19409
- description: "Prepare in background only: starts the summary at this %; it is applied at Start at context %, so this must be lower. Auto starts 8k\u201332k tokens earlier.",
20208
+ label: "Cleanup / prepare at context %",
20209
+ description: "Early pressure gate for cleanup and optional background preparation. Compaction starts at Start at context %, so this must be lower. Auto starts 8k\u201332k tokens earlier.",
19410
20210
  placeholder: "0\u2013100 below Start at; auto or blank = Auto",
19411
20211
  parse: (input) => input.trim().toLowerCase() === "auto" ? undefined : parsePreparePercent(input),
19412
20212
  format: (value) => value === undefined || value === null ? "Auto" : String(value)
@@ -19615,7 +20415,7 @@ class InputSettingEditor extends Container3 {
19615
20415
  save;
19616
20416
  done;
19617
20417
  input = new Input;
19618
- status = new Text3("", 0, 0);
20418
+ status = new Text7("", 0, 0);
19619
20419
  saving = false;
19620
20420
  pending = Promise.resolve();
19621
20421
  get focused() {
@@ -19629,10 +20429,10 @@ class InputSettingEditor extends Container3 {
19629
20429
  this.requestRender = requestRender;
19630
20430
  this.save = save;
19631
20431
  this.done = done;
19632
- this.addChild(new Text3(setting.label, 0, 0));
19633
- this.addChild(new Text3(setting.description, 0, 0));
19634
- this.addChild(new Text3(`Allowed: ${setting.placeholder}`, 0, 0));
19635
- this.addChild(new Text3("", 0, 0));
20432
+ this.addChild(new Text7(setting.label, 0, 0));
20433
+ this.addChild(new Text7(setting.description, 0, 0));
20434
+ this.addChild(new Text7(`Allowed: ${setting.placeholder}`, 0, 0));
20435
+ this.addChild(new Text7("", 0, 0));
19636
20436
  this.input.setValue(initial);
19637
20437
  this.input.handleInput("\x1B[F");
19638
20438
  this.input.onSubmit = (value) => {
@@ -19660,9 +20460,9 @@ class InputSettingEditor extends Container3 {
19660
20460
  this.done();
19661
20461
  };
19662
20462
  this.addChild(this.input);
19663
- this.addChild(new Text3("", 0, 0));
20463
+ this.addChild(new Text7("", 0, 0));
19664
20464
  this.addChild(this.status);
19665
- this.addChild(new Text3("Enter save \xB7 Esc cancel", 0, 0));
20465
+ this.addChild(new Text7("Enter save \xB7 Esc cancel", 0, 0));
19666
20466
  }
19667
20467
  handleInput(data) {
19668
20468
  this.input.handleInput(data);
@@ -19677,7 +20477,7 @@ class ModelSettingEditor extends Container3 {
19677
20477
  requestRender;
19678
20478
  search = new Input;
19679
20479
  list;
19680
- status = new Text3("", 0, 0);
20480
+ status = new Text7("", 0, 0);
19681
20481
  saving = false;
19682
20482
  pending = Promise.resolve();
19683
20483
  get focused() {
@@ -19689,9 +20489,9 @@ class ModelSettingEditor extends Container3 {
19689
20489
  constructor(models, selectedValue, requestRender, save, done) {
19690
20490
  super();
19691
20491
  this.requestRender = requestRender;
19692
- this.addChild(new Text3("Type to search models", 0, 0));
20492
+ this.addChild(new Text7("Type to search models", 0, 0));
19693
20493
  this.addChild(this.search);
19694
- this.addChild(new Text3("", 0, 0));
20494
+ this.addChild(new Text7("", 0, 0));
19695
20495
  this.list = new SelectList3(models, 10, {
19696
20496
  selectedPrefix: (text) => text,
19697
20497
  selectedText: (text) => text,
@@ -19726,7 +20526,7 @@ class ModelSettingEditor extends Container3 {
19726
20526
  };
19727
20527
  this.addChild(this.list);
19728
20528
  this.addChild(this.status);
19729
- this.addChild(new Text3("Type to filter \xB7 Enter select \xB7 Esc cancel", 0, 0));
20529
+ this.addChild(new Text7("Type to filter \xB7 Enter select \xB7 Esc cancel", 0, 0));
19730
20530
  }
19731
20531
  handleInput(data) {
19732
20532
  if (matchesKey3(data, Key3.up) || matchesKey3(data, Key3.down) || matchesKey3(data, Key3.enter) || matchesKey3(data, Key3.escape)) {
@@ -19965,7 +20765,6 @@ var THINKING_VALUES = [
19965
20765
  "max"
19966
20766
  ];
19967
20767
  var needsAutoTrigger = (config) => config.autoTrigger ? undefined : "turn on Automatic compaction first";
19968
- var needsBackgroundTiming = (config) => config.autoTrigger && config.autoTriggerStrategy === "background" ? undefined : "used only when Start when = Prepare in background";
19969
20768
  var needsMnemopi = (config) => config.memoryBackend === "mnemopi" ? undefined : "used only when Memory store = Mnemopi";
19970
20769
  var needsHindsight = (config) => config.memoryBackend === "hindsight" ? undefined : "used only when Memory store = Hindsight server";
19971
20770
  var needsBackups = (config) => config.backupEnabled ? undefined : "turn on Backups first";
@@ -19996,9 +20795,9 @@ var CATEGORIES = [
19996
20795
  labels: { "native-hook": "Before Pi's compaction", settled: "When idle", background: "Prepare in background" }
19997
20796
  }
19998
20797
  },
19999
- { kind: "input", setting: MIN_CONTEXT_SETTING, inactive: needsAutoTrigger },
20000
- { kind: "input", setting: MAX_CONTEXT_SETTING, inactive: needsAutoTrigger },
20001
- { kind: "input", setting: PREPARE_CONTEXT_SETTING, inactive: needsBackgroundTiming },
20798
+ { kind: "input", setting: MIN_CONTEXT_SETTING },
20799
+ { kind: "input", setting: MAX_CONTEXT_SETTING },
20800
+ { kind: "input", setting: PREPARE_CONTEXT_SETTING },
20002
20801
  {
20003
20802
  kind: "choice",
20004
20803
  setting: {
@@ -20113,7 +20912,7 @@ var CATEGORIES = [
20113
20912
  setting: {
20114
20913
  id: "toolLoading",
20115
20914
  label: "Agent tools",
20116
- description: "On demand: the agent sees a small loader and opens tools when needed. Always available: every permitted tool is active. Off: no context tools can run. Pi may retain previously loaded declarations in cached history. Permissions such as Agent can compact still apply.",
20915
+ description: "Always available (default): permitted tools stay visible with a stable prompt prefix. On demand saves initial schema tokens but later loading can rebuild the cache. Off disables agent context tools, not human commands. Host permissions still apply.",
20117
20916
  values: ["lazy", "eager", "off"],
20118
20917
  labels: { lazy: "On demand", eager: "Always available", off: "Off" }
20119
20918
  }
@@ -20182,17 +20981,27 @@ var CATEGORIES = [
20182
20981
  {
20183
20982
  id: "hygiene",
20184
20983
  label: "Tool output cleanup",
20185
- description: "Keeps the context small between compactions; local, no model calls",
20984
+ description: "Recoverable, batched cleanup; no model call, but history edits can rebuild the prompt cache",
20186
20985
  rows: [
20187
20986
  {
20188
20987
  kind: "choice",
20189
20988
  setting: {
20190
20989
  id: "contextHygieneEnabled",
20191
20990
  label: "Automatic cleanup",
20192
- description: "Moves old tool output to local archives when it pays off, when the prompt cache is cold, or when context gets tight; recent turns and instructions stay.",
20991
+ description: "Archives safe old output in batches under context pressure; preserves recent turns, instructions and signed thinking dependencies. Cleanup and compaction use the same pressure window.",
20193
20992
  values: BOOLEAN_VALUES
20194
20993
  }
20195
20994
  },
20995
+ {
20996
+ kind: "choice",
20997
+ setting: {
20998
+ id: "contextPressureOnly",
20999
+ label: "Cleanup timing",
21000
+ description: "Pressure only (default) leaves roomy cached history alone, including agent trim/rewind/anchor requests. Economic also allows break-even/cold-cache cleanup. Human commands can request early cleanup; safety checks always apply.",
21001
+ values: BOOLEAN_VALUES,
21002
+ labels: { true: "Pressure only", false: "Economic (opt-in)" }
21003
+ }
21004
+ },
20196
21005
  {
20197
21006
  kind: "choice",
20198
21007
  setting: {
@@ -20722,16 +21531,16 @@ async function showSmartCompactSettings(ctx, policy, coordinator = new GlobalSet
20722
21531
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
20723
21532
  const root = createSettingsRoot(policy, ctx, done, () => tui.requestRender(), coordinator, onApplied, writeConfig);
20724
21533
  const container = new Container4;
20725
- container.addChild(new Text4(theme.fg("accent", theme.bold("Smart Compact Settings")), 0, 0));
20726
- container.addChild(new Text4(theme.fg("dim", "Changes are saved for all projects; This branch only changes just this branch."), 0, 0));
20727
- container.addChild(new Text4(theme.fg("dim", "/smart-compact always works. \u2022 = changed from default."), 0, 0));
21534
+ container.addChild(new Text8(theme.fg("accent", theme.bold("Smart Compact Settings")), 0, 0));
21535
+ container.addChild(new Text8(theme.fg("dim", "Changes are saved for all projects; This branch only changes just this branch."), 0, 0));
21536
+ container.addChild(new Text8(theme.fg("dim", "/smart-compact always works. \u2022 = changed from default."), 0, 0));
20728
21537
  const invalid = invalidSettingsLine();
20729
21538
  if (invalid)
20730
- container.addChild(new Text4(theme.fg("warning", invalid), 0, 0));
20731
- container.addChild(new Text4("", 0, 0));
21539
+ container.addChild(new Text8(theme.fg("warning", invalid), 0, 0));
21540
+ container.addChild(new Text8("", 0, 0));
20732
21541
  container.addChild(root);
20733
- container.addChild(new Text4("", 0, 0));
20734
- container.addChild(new Text4(theme.fg("dim", "\u2191\u2193 move \xB7 Enter change/open \xB7 r reset \xB7 Esc back"), 0, 0));
21542
+ container.addChild(new Text8("", 0, 0));
21543
+ container.addChild(new Text8(theme.fg("dim", "\u2191\u2193 move \xB7 Enter change/open \xB7 r reset \xB7 Esc back"), 0, 0));
20735
21544
  return createSettingsController(root, container, () => tui.requestRender());
20736
21545
  });
20737
21546
  }
@@ -20757,7 +21566,7 @@ var BEHAVIOR_PROFILES = [
20757
21566
  {
20758
21567
  id: "cleanup",
20759
21568
  label: "Cleanup only",
20760
- summary: "Moves old tool output out of the context when it pays off, when the prompt cache is cold, or when context gets full. Local, no model calls; never compacts by itself.",
21569
+ summary: "Archives safe old output using Cleanup timing (pressure-only by default). No model call, but history edits can rebuild the cache; never compacts by itself.",
20761
21570
  patch: { autoTrigger: false, contextHygieneEnabled: true, agentToolAccess: "disabled" }
20762
21571
  },
20763
21572
  {
@@ -20778,11 +21587,11 @@ var AGENT_WORDS = {
20778
21587
  disabled: "not allowed"
20779
21588
  };
20780
21589
  function deriveBehaviorProfile(config) {
20781
- if (config.autoTrigger === DEFAULT_CONFIG.autoTrigger && config.autoTriggerStrategy === DEFAULT_CONFIG.autoTriggerStrategy && config.contextHygieneEnabled === DEFAULT_CONFIG.contextHygieneEnabled && config.agentToolAccess === DEFAULT_CONFIG.agentToolAccess) {
21590
+ if (config.autoTrigger === DEFAULT_CONFIG.autoTrigger && config.autoTriggerStrategy === DEFAULT_CONFIG.autoTriggerStrategy && config.contextHygieneEnabled === DEFAULT_CONFIG.contextHygieneEnabled && config.contextPressureOnly === DEFAULT_CONFIG.contextPressureOnly && config.agentToolAccess === DEFAULT_CONFIG.agentToolAccess) {
20782
21591
  return {
20783
21592
  id: "custom",
20784
- label: "With Pi (default)",
20785
- detail: "Replaces Pi's summary when Pi's own auto-compaction fires; agent access follows Pi."
21593
+ label: "Pressure-first (default)",
21594
+ detail: "Batched cleanup under early pressure, then idle compaction at the apply gate; agent access follows Pi."
20786
21595
  };
20787
21596
  }
20788
21597
  const found = derive(BEHAVIOR_PROFILES, ({ patch }) => Object.entries(patch).every(([key, value]) => config[key] === value), "Custom");
@@ -20987,7 +21796,7 @@ function createHomeList(options, finish, requestRender) {
20987
21796
  id: "trim",
20988
21797
  label: "Clean up tool output",
20989
21798
  currentValue: "no model call",
20990
- description: "Queue cleanup for the next completed turn. Older output stays retrievable; the next model request is still sent untrimmed."
21799
+ description: "Queue cleanup for the next completed turn. Older output stays retrievable; the next model request is still sent untrimmed. No summarizer call, but a history rewrite can incur cache cost."
20991
21800
  };
20992
21801
  const setup = {
20993
21802
  id: "setup",
@@ -21228,7 +22037,7 @@ async function showSmartCompactHome(options) {
21228
22037
  return options.ctx.ui.custom((tui, theme, _keybindings, done) => {
21229
22038
  const list = createHomeList(options, done, () => tui.requestRender());
21230
22039
  const container = new Container5;
21231
- container.addChild(new Text5(theme.fg("accent", theme.bold("Smart Compact")), 0, 0));
22040
+ container.addChild(new Text9(theme.fg("accent", theme.bold("Smart Compact")), 0, 0));
21232
22041
  container.addChild({
21233
22042
  render(width) {
21234
22043
  const config = loadConfig();
@@ -21244,15 +22053,15 @@ async function showSmartCompactHome(options) {
21244
22053
  });
21245
22054
  const invalid = invalidSettingsLine();
21246
22055
  if (invalid)
21247
- container.addChild(new Text5(theme.fg("warning", invalid), 0, 0));
21248
- container.addChild(new Text5("", 0, 0));
22056
+ container.addChild(new Text9(theme.fg("warning", invalid), 0, 0));
22057
+ container.addChild(new Text9("", 0, 0));
21249
22058
  container.addChild(list);
21250
22059
  return createSettingsController(list, container, () => tui.requestRender());
21251
22060
  });
21252
22061
  }
21253
22062
 
21254
22063
  // src/ui/metrics-dashboard-overlay.ts
21255
- import { Key as Key5, matchesKey as matchesKey5, truncateToWidth as truncateToWidth4 } from "@earendil-works/pi-tui";
22064
+ import { Key as Key5, matchesKey as matchesKey5, truncateToWidth as truncateToWidth5 } from "@earendil-works/pi-tui";
21256
22065
  async function showMetricsDashboardUI(ctx, opts) {
21257
22066
  const entries = opts.entries;
21258
22067
  const latest = entries.at(-1);
@@ -21347,23 +22156,23 @@ async function showMetricsDashboardUI(ctx, opts) {
21347
22156
  return theme.fg(insights.quality.targetMet ? "success" : "warning", " \u2022 Quality " + insights.quality.healthScore + "/100");
21348
22157
  };
21349
22158
  const renderHeader = (width) => [
21350
- truncateToWidth4(theme.fg("accent", theme.bold(" \uD83D\uDCCA Smart Compact Dashboard")) + theme.fg("dim", " " + entries.length + " recorded run(s)"), width),
21351
- truncateToWidth4(theme.fg("dim", " session: " + (opts.currentSessionId ?? "unknown")) + theme.fg("dim", latest && Number.isFinite(latest.verificationScore) ? " \u2022 latest score " + metricScore(latest) : "") + theme.fg(insights.confidence.targetMet ? "success" : "warning", " \u2022 Data Confidence " + insights.confidence.score + "/100") + qualityStatus(), width),
21352
- truncateToWidth4(theme.fg("borderMuted", "\u2500".repeat(Math.max(0, width))), width)
22159
+ truncateToWidth5(theme.fg("accent", theme.bold(" \uD83D\uDCCA Smart Compact Dashboard")) + theme.fg("dim", " " + entries.length + " recorded run(s)"), width),
22160
+ truncateToWidth5(theme.fg("dim", " session: " + (opts.currentSessionId ?? "unknown")) + theme.fg("dim", latest && Number.isFinite(latest.verificationScore) ? " \u2022 latest score " + metricScore(latest) : "") + theme.fg(insights.confidence.targetMet ? "success" : "warning", " \u2022 Data Confidence " + insights.confidence.score + "/100") + qualityStatus(), width),
22161
+ truncateToWidth5(theme.fg("borderMuted", "\u2500".repeat(Math.max(0, width))), width)
21353
22162
  ];
21354
22163
  return {
21355
22164
  render(width) {
21356
22165
  const lines = renderHeader(width);
21357
22166
  if (view === "menu") {
21358
- lines.push(truncateToWidth4(theme.fg("text", " Choose what to inspect:"), width), "");
22167
+ lines.push(truncateToWidth5(theme.fg("text", " Choose what to inspect:"), width), "");
21359
22168
  for (let index = 0;index < menuItems.length; index++) {
21360
22169
  const item = menuItems[index];
21361
22170
  const active = index === selected;
21362
22171
  const label = active ? theme.fg("accent", theme.bold(item.label)) : theme.fg("text", item.label);
21363
- lines.push(truncateToWidth4((active ? " \u203A " : " ") + label, width));
21364
- lines.push(truncateToWidth4(" " + theme.fg(active ? "muted" : "dim", item.desc), width));
22172
+ lines.push(truncateToWidth5((active ? " \u203A " : " ") + label, width));
22173
+ lines.push(truncateToWidth5(" " + theme.fg(active ? "muted" : "dim", item.desc), width));
21365
22174
  }
21366
- lines.push("", truncateToWidth4(theme.fg("dim", " \u2191\u2193 navigate \u2022 enter open \u2022 esc/q close"), width));
22175
+ lines.push("", truncateToWidth5(theme.fg("dim", " \u2191\u2193 navigate \u2022 enter open \u2022 esc/q close"), width));
21367
22176
  return lines;
21368
22177
  }
21369
22178
  const content = pageLines();
@@ -21376,12 +22185,12 @@ async function showMetricsDashboardUI(ctx, opts) {
21376
22185
  styled = theme.fg("accent", theme.bold(line));
21377
22186
  else if (line.startsWith("-"))
21378
22187
  styled = theme.fg("dim", line);
21379
- lines.push(truncateToWidth4(" " + styled, width));
22188
+ lines.push(truncateToWidth5(" " + styled, width));
21380
22189
  }
21381
22190
  if (content.length > pageSize) {
21382
- lines.push(truncateToWidth4(theme.fg("dim", " showing " + (scroll + 1) + "-" + Math.min(content.length, scroll + pageSize) + " of " + content.length), width));
22191
+ lines.push(truncateToWidth5(theme.fg("dim", " showing " + (scroll + 1) + "-" + Math.min(content.length, scroll + pageSize) + " of " + content.length), width));
21383
22192
  }
21384
- lines.push("", truncateToWidth4(theme.fg("dim", " \u2191\u2193 scroll \u2022 pgup/pgdn page \u2022 home/end jump \u2022 b back \u2022 esc/q close"), width));
22193
+ lines.push("", truncateToWidth5(theme.fg("dim", " \u2191\u2193 scroll \u2022 pgup/pgdn page \u2022 home/end jump \u2022 b back \u2022 esc/q close"), width));
21385
22194
  return lines;
21386
22195
  },
21387
22196
  invalidate() {},
@@ -21443,7 +22252,7 @@ import {
21443
22252
  import {
21444
22253
  Container as Container6,
21445
22254
  Input as Input2,
21446
- Text as Text6,
22255
+ Text as Text10,
21447
22256
  wrapTextWithAnsi as wrapTextWithAnsi4
21448
22257
  } from "@earendil-works/pi-tui";
21449
22258
  var PAGE_SIZE = 8;
@@ -21552,7 +22361,7 @@ class Framed {
21552
22361
 
21553
22362
  class Prompt extends Container6 {
21554
22363
  input;
21555
- status = new Text6("", 0, 0);
22364
+ status = new Text10("", 0, 0);
21556
22365
  busy = false;
21557
22366
  get focused() {
21558
22367
  return this.input.focused;
@@ -21564,8 +22373,8 @@ class Prompt extends Container6 {
21564
22373
  super();
21565
22374
  this.input = new Input2;
21566
22375
  for (const line of lines)
21567
- this.addChild(new Text6(line, 0, 0));
21568
- this.addChild(new Text6("", 0, 0));
22376
+ this.addChild(new Text10(line, 0, 0));
22377
+ this.addChild(new Text10("", 0, 0));
21569
22378
  this.input.setValue(initial);
21570
22379
  this.input.handleInput("\x1B[F");
21571
22380
  this.input.onSubmit = (value) => {
@@ -21577,9 +22386,9 @@ class Prompt extends Container6 {
21577
22386
  cancel();
21578
22387
  };
21579
22388
  this.addChild(this.input);
21580
- this.addChild(new Text6("", 0, 0));
22389
+ this.addChild(new Text10("", 0, 0));
21581
22390
  this.addChild(this.status);
21582
- this.addChild(new Text6(hint, 0, 0));
22391
+ this.addChild(new Text10(hint, 0, 0));
21583
22392
  }
21584
22393
  setStatus(text, busy = false) {
21585
22394
  this.status.setText(text);
@@ -21594,7 +22403,7 @@ class TextArea {
21594
22403
  open;
21595
22404
  submit;
21596
22405
  editor;
21597
- status = new Text6("", 0, 0);
22406
+ status = new Text10("", 0, 0);
21598
22407
  busy = false;
21599
22408
  active = false;
21600
22409
  constructor(open, submit) {
@@ -22336,11 +23145,13 @@ async function describeEffectiveState(ctx, config, policy, runtime) {
22336
23145
  lines.push("Agent tool: " + (policy.agentToolEnabled ? "exposed" : "not exposed") + " (policy " + policy.agentToolAccess + ")");
22337
23146
  if (ctx.model && !ctx.model.promptCache)
22338
23147
  lines.push("Reader prompt-cache lifetime: not declared; host cache warming cannot schedule from this metadata. No cache savings are assumed.");
22339
- const window = ctx.model?.contextWindow;
22340
- const gateWindow = effectiveContextWindow(ctx.model, config);
22341
- const tokens = ctx.getContextUsage()?.tokens;
22342
- if (typeof window === "number" && Number.isFinite(window) && window > 0 && typeof gateWindow === "number") {
22343
- const gates = preparationWindow(config, gateWindow);
23148
+ const pressure = contextPressure(ctx, config);
23149
+ const window = pressure.modelWindow;
23150
+ const gateWindow = pressure.policyWindow;
23151
+ const tokens = pressure.tokens;
23152
+ if (typeof window === "number" && Number.isFinite(window) && window > 0 && gateWindow !== null) {
23153
+ const gates = { startTokens: pressure.cleanupTokens, applyTokens: pressure.compactionTokens };
23154
+ lines.push("Cleanup gate: \u2265" + gates.startTokens.toLocaleString("en-US") + " tokens; policy usage " + (pressure.percent === null ? "unknown" : pressure.percent.toFixed(1) + "%"));
22344
23155
  const apply = Math.ceil(Math.max(MIN_TOKEN_THRESHOLD, gates.applyTokens)).toLocaleString("en-US");
22345
23156
  const gate = " (" + config.minContextPercent + "% of " + (gateWindow < window ? "maxContextTokens " : "window ") + gateWindow.toLocaleString("en-US") + ", floor " + MIN_TOKEN_THRESHOLD.toLocaleString("en-US") + ")";
22346
23157
  lines.push("Context: " + (typeof tokens === "number" && Number.isFinite(tokens) ? Math.ceil(tokens).toLocaleString("en-US") : "unknown") + "/" + window.toLocaleString("en-US") + " tokens; " + (!policy.autoTrigger ? "automatic compaction off" : hostDriven ? "Pi decides when; PSC replaces Pi's threshold summary only \u2265" + apply + gate + ", overflow skips the % gate" : "PSC starts \u2265" + apply + gate));
@@ -22352,9 +23163,9 @@ async function describeEffectiveState(ctx, config, policy, runtime) {
22352
23163
  lines.push("Runtime: " + (runtime ? (runtime.running ? "running" : "idle") + "; preparation " + runtime.preparation : "not attached") + "; candidate reuse is revalidated at apply");
22353
23164
  if (runtime?.cacheLedger?.length)
22354
23165
  lines.push(...runtime.cacheLedger);
22355
- lines.push("Auto limits: " + Math.min(config.autoTriggerTimeoutMs, AUTO_TRIGGER_TIMEOUT_CAP_MS) / 1000 + " s timeout; " + SETTLED_TRIGGER_COOLDOWN_MS / 60000 + " min cooldown; " + FIVE_MINUTES_MS / 60000 + " min preparation TTL");
23166
+ lines.push("Auto limits: " + Math.min(config.autoTriggerTimeoutMs, AUTO_TRIGGER_TIMEOUT_CAP_MS) / 1000 + " s timeout; " + SETTLED_TRIGGER_COOLDOWN_MS / 60000 + " min cooldown; " + config.pendingTtlMs / 60000 + " min preparation TTL");
22356
23167
  const backgroundHygiene = policy.autoTrigger && config.autoTriggerStrategy === "background";
22357
- lines.push("Context hygiene: " + (config.contextHygieneEnabled ? "on (explicit: commits under pressure, at cache break-even, or on a cold cache)" : backgroundHygiene ? "on (enabled by effective background strategy, pressure-gated)" : "off") + ((config.contextHygieneEnabled || backgroundHygiene) && config.toolLoading === "off" ? " \u2014 automatic trims inactive: agent tools are off, so archived output could not be read back" : ""));
23168
+ lines.push("Context hygiene: " + (config.contextHygieneEnabled ? config.contextPressureOnly ? "on (pressure only; roomy cached history stays unchanged)" : "on (economic opt-in: pressure, cache break-even, or cold cache)" : backgroundHygiene ? "on (enabled by effective background strategy, pressure-gated)" : "off") + ((config.contextHygieneEnabled || backgroundHygiene) && config.toolLoading === "off" ? " \u2014 automatic trims inactive: agent tools are off, so archived output could not be read back" : ""));
22358
23169
  const memory = await describeMemoryBackendReadiness(config);
22359
23170
  lines.push("Memory selected: " + config.memoryBackend + (projectId ? " (project-bound)" : " \u2014 unavailable: no project scope"));
22360
23171
  lines.push("Memory readiness: " + memory.reason);
@@ -22596,7 +23407,8 @@ async function showStorage(ctx) {
22596
23407
  function compactNowState(ctx, dependencies) {
22597
23408
  const config = loadConfig();
22598
23409
  const tokens = ctx.getContextUsage()?.tokens;
22599
- const value = tokens == null ? "not measured yet" : Math.round(tokens / 1000) + "k tokens \xB7 " + Math.round(safeContextPercent(tokens, ctx.model?.contextWindow)) + "%";
23410
+ const window = effectiveContextWindow(ctx.model, config);
23411
+ const value = tokens == null ? "not measured yet" : Math.round(tokens / 1000) + "k tokens \xB7 " + Math.round(safeContextPercent(tokens, window)) + "%" + (window !== ctx.model?.contextWindow ? " of " + window?.toLocaleString("en-US") + " policy window" : "");
22600
23412
  const runtime = dependencies.getRuntimeState?.(ctx);
22601
23413
  if (runtime?.running)
22602
23414
  return { value, blocked: "Compaction is already running. Wait for it to finish." };
@@ -23008,6 +23820,7 @@ function createSmartCompactPolicy(pi, syncTools) {
23008
23820
 
23009
23821
  // src/app/lazy-tools.ts
23010
23822
  import { StringEnum as StringEnum3 } from "@earendil-works/pi-ai";
23823
+ import { Text as Text11 } from "@earendil-works/pi-tui";
23011
23824
  import { Type as Type5 } from "typebox";
23012
23825
 
23013
23826
  // src/app/context-guide.ts
@@ -23176,33 +23989,127 @@ function registerContextToolLoader(pi, exposure) {
23176
23989
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes(LOADER))
23177
23990
  throw new Error("Context tools are disabled in Pi settings or /tools.");
23178
23991
  let text;
23992
+ let details = undefined;
23179
23993
  if (params.action === "guide")
23180
23994
  text = await readContextGuide();
23181
- else if (params.action === "status")
23182
- text = JSON.stringify(exposure.status());
23183
- else {
23995
+ else if (params.action === "status") {
23996
+ const status = exposure.status();
23997
+ text = JSON.stringify(status);
23998
+ details = { display: { kind: "tool-groups" }, status };
23999
+ } else {
23184
24000
  if (!params.group)
23185
24001
  throw new Error(`${params.action} requires a group.`);
23186
- if (params.action === "load")
23187
- text = `Loaded: ${exposure.load(params.group).join(", ")}. Available on the next model turn; no feature instructions were injected.`;
23188
- else {
24002
+ if (params.action === "load") {
24003
+ const names = exposure.load(params.group);
24004
+ text = `Loaded: ${names.join(", ")}. Available on the next model turn; no feature instructions were injected.`;
24005
+ details = { display: { state: "loaded", group: params.group, tools: names } };
24006
+ } else {
23189
24007
  if (loadConfig().toolLoading === "eager")
23190
24008
  throw new Error("Eager mode keeps tools loaded. Select On demand or Off in /smart-compact settings before unloading a group.");
23191
24009
  exposure.unload(params.group);
23192
24010
  text = `Unloaded ${params.group}. No files or session history changed.`;
24011
+ details = { display: { state: "unloaded", group: params.group } };
23193
24012
  }
23194
24013
  }
23195
- return { content: [{ type: "text", text }], details: undefined };
24014
+ return { content: [{ type: "text", text }], details };
24015
+ },
24016
+ renderCall(args, theme) {
24017
+ const label = theme.fg("toolTitle", "smart_tools ");
24018
+ const action = safeArg(args.action, 20);
24019
+ const group = safeArg(args.group, 40);
24020
+ return new Text11(label + theme.fg("accent", action) + (group ? " " + theme.fg("dim", group) : ""), 0, 0);
24021
+ },
24022
+ renderResult(result, options, theme, context) {
24023
+ return tryRow(theme, () => renderToolsRow(result, theme, context, options.expanded), result, options.expanded);
23196
24024
  }
23197
24025
  });
23198
24026
  }
24027
+ function renderToolsRow(result, theme, context, expanded) {
24028
+ if (context.isError) {
24029
+ const head = theme.fg("error", "smart_tools failed:");
24030
+ return expanded ? expandedRow(theme, result, [head]) : new Text11(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
24031
+ }
24032
+ const details = result.details;
24033
+ if (!details)
24034
+ return rawFallbackRow(theme, result, expanded);
24035
+ const lines = [];
24036
+ if (details.display?.state === "loaded") {
24037
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "loaded " + String(details.display.group)));
24038
+ if (details.display.tools?.length) {
24039
+ lines.push(theme.fg("dim", details.display.tools.join(", ")));
24040
+ }
24041
+ return new Text11(lines.join(`
24042
+ `), 0, 0);
24043
+ }
24044
+ if (details.display?.state === "unloaded") {
24045
+ lines.push(statusLabel(theme, "info") + " " + theme.fg("muted", "unloaded " + String(details.display.group)));
24046
+ return new Text11(lines.join(`
24047
+ `), 0, 0);
24048
+ }
24049
+ if (details.display?.kind === "tool-groups" && details.status) {
24050
+ lines.push(theme.fg("muted", "mode: ") + theme.fg("accent", String(details.status.mode ?? "")));
24051
+ for (const group of details.status.groups ?? []) {
24052
+ const marker = group.active.length ? theme.fg("success", "\u25CF") : group.available ? theme.fg("dim", "\u25CB") : theme.fg("error", "\u2715");
24053
+ const state = group.active.length ? group.active.length + " active" : group.available ? "available" : "disabled";
24054
+ lines.push(marker + " " + group.group + " " + theme.fg("dim", state));
24055
+ }
24056
+ return expanded ? expandedRow(theme, result, lines) : new Text11(lines.join(`
24057
+ `), 0, 0);
24058
+ }
24059
+ return rawFallbackRow(theme, result, expanded);
24060
+ }
23199
24061
 
23200
24062
  // src/app/register-navigation.ts
23201
24063
  import { randomUUID as randomUUID5 } from "crypto";
23202
24064
  import path26 from "path";
23203
24065
  import { StringEnum as StringEnum4 } from "@earendil-works/pi-ai";
23204
24066
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
24067
+ import { Text as Text12 } from "@earendil-works/pi-tui";
23205
24068
  import { Type as Type6 } from "typebox";
24069
+ function renderNavigationRow(result, expanded, theme, context) {
24070
+ if (context.isError) {
24071
+ const head = theme.fg("error", "navigation failed:");
24072
+ return expanded ? expandedRow(theme, result, [head]) : new Text12(head + " " + summarizeLine(firstTextContent(result.content), 140), 0, 0);
24073
+ }
24074
+ const details = result.details;
24075
+ if (!details)
24076
+ return rawFallbackRow(theme, result, expanded);
24077
+ const display = details.display;
24078
+ const lines = [];
24079
+ if (display?.state === "queued" && display.action === "pivot") {
24080
+ lines.push(statusLabel(theme, "queued") + " " + theme.fg("muted", "pivot queued \u2014 not applied; takes effect after the turn settles"));
24081
+ if (display.target)
24082
+ lines.push(metaLine(theme, "target", String(display.target)));
24083
+ return new Text12(lines.join(`
24084
+ `), 0, 0);
24085
+ }
24086
+ if (details.anchor) {
24087
+ const anchor = details.anchor;
24088
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "anchor recorded"));
24089
+ lines.push(metaLine(theme, "name", anchor.name ?? ""));
24090
+ if (anchor.targetId)
24091
+ lines.push(metaLine(theme, "target", anchor.targetId));
24092
+ return expanded ? expandedRow(theme, result, lines) : new Text12(lines.join(`
24093
+ `), 0, 0);
24094
+ }
24095
+ if (display?.kind === "anchors" || display?.kind === "anchor-recall") {
24096
+ const count = Number(display.count) || 0;
24097
+ lines.push(statusLabel(theme, count ? "done" : "skipped") + " " + theme.fg("muted", count + " anchor(s) " + (display.kind === "anchor-recall" ? "recalled" : "listed")));
24098
+ if (expanded)
24099
+ return expandedRow(theme, result, lines);
24100
+ lines.push(expandHint(theme));
24101
+ return new Text12(lines.join(`
24102
+ `), 0, 0);
24103
+ }
24104
+ if (display?.kind === "anchor-detail") {
24105
+ lines.push(statusLabel(theme, "done") + " " + theme.fg("muted", "anchor detail"));
24106
+ if (display.name)
24107
+ lines.push(metaLine(theme, "name", String(display.name)));
24108
+ return expanded ? expandedRow(theme, result, lines) : new Text12(lines.join(`
24109
+ `), 0, 0);
24110
+ }
24111
+ return rawFallbackRow(theme, result, expanded);
24112
+ }
23206
24113
  var STATUS = "smart-context-navigation";
23207
24114
  var APPLY_PREFIX = "context --apply=";
23208
24115
  function registerNavigation(pi, options = {}) {
@@ -23214,7 +24121,7 @@ function registerNavigation(pi, options = {}) {
23214
24121
  const settings = config();
23215
24122
  return new SecretScrubber(settings.scrubSecrets, settings.scrubPii).scrubText(text).value;
23216
24123
  };
23217
- const reply = (text) => ({ content: [{ type: "text", text: scrub(text) }], details: undefined });
24124
+ const reply = (text, details = undefined) => ({ content: [{ type: "text", text: scrub(text) }], details });
23218
24125
  const enabled = () => {
23219
24126
  if (!config().contextNavigationEnabled)
23220
24127
  throw new Error("Context navigation is disabled in Pi Continuity settings.");
@@ -23237,10 +24144,10 @@ function registerNavigation(pi, options = {}) {
23237
24144
  const branch = ctx.sessionManager.getBranch();
23238
24145
  const index = branch.findLastIndex((entry) => anchorFromEntry(entry) !== null);
23239
24146
  const anchor = index < 0 ? null : anchorFromEntry(branch[index]);
23240
- const percent = ctx.getContextUsage()?.percent;
24147
+ const pressure = contextPressure(ctx, config());
23241
24148
  const parts = [];
23242
- if (typeof percent === "number" && Number.isFinite(percent))
23243
- parts.push(`ctx ${Math.min(100, Math.round(percent))}%`);
24149
+ if (pressure.percent !== null)
24150
+ parts.push(`ctx ${Math.round(pressure.percent)}%${pressure.policyWindow !== pressure.modelWindow ? ` of ${pressure.policyWindow?.toLocaleString("en-US")} policy window` : ""}`);
23244
24151
  if (anchor)
23245
24152
  parts.push(`anchor:${anchor.name.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120)} \xB7 ${branch.length - index - 1} entries later`);
23246
24153
  ctx.ui.setStatus(STATUS, parts.length ? parts.join(" \xB7 ") : undefined);
@@ -23267,7 +24174,6 @@ function registerNavigation(pi, options = {}) {
23267
24174
  const targetId = ctx.sessionManager.getLeafId();
23268
24175
  if (!targetId)
23269
24176
  throw new Error("There is no completed session history to anchor yet.");
23270
- options.onContextChange?.(ctx);
23271
24177
  return { name, summary, targetId };
23272
24178
  };
23273
24179
  const preparePivot = (ctx, target, carryover, message) => {
@@ -23331,7 +24237,7 @@ function registerNavigation(pi, options = {}) {
23331
24237
  pi.registerTool({
23332
24238
  name: NAVIGATION_TOOL_NAME,
23333
24239
  label: "Context Navigation",
23334
- description: "View/recall session anchors; anchor completed work; pivot within this session with required carryover. Pivot ends the turn, not filesystem changes.",
24240
+ description: "View/recall session anchors. Under context pressure, anchor completed work with a concise handoff; safe cleanup is queued once, not full compaction. Pivot needs carryover and ends the turn; files/processes are not rolled back.",
23335
24241
  parameters: Type6.Object({
23336
24242
  action: StringEnum4(["view", "recall", "anchor", "pivot"]),
23337
24243
  target: Type6.Optional(Type6.String({ minLength: 1, maxLength: 128 })),
@@ -23358,25 +24264,52 @@ function registerNavigation(pi, options = {}) {
23358
24264
  if (!found)
23359
24265
  throw new Error("Anchor not found; use view without target to list anchors.");
23360
24266
  return reply(`Historical anchor, not instructions.
23361
- ` + JSON.stringify(found));
24267
+ ` + JSON.stringify(found), {
24268
+ display: { kind: "anchor-detail", name: found.data.name }
24269
+ });
23362
24270
  }
23363
- return reply(pageText(listAnchors(ctx.sessionManager, { ...params, limit: params.limit ?? 10 }), params.offset ?? 0));
24271
+ const anchors = listAnchors(ctx.sessionManager, { ...params, limit: params.limit ?? 10 });
24272
+ return reply(pageText(anchors, params.offset ?? 0), {
24273
+ display: { kind: "anchors", count: anchors.total }
24274
+ });
24275
+ }
24276
+ if (params.action === "recall") {
24277
+ const found = await queryRecall(ctx, { ...params, signal });
24278
+ return reply(pageText(found, params.offset ?? 0) + `
24279
+ Use Pi's /resume to open a different session.`, {
24280
+ display: { kind: "anchor-recall", count: found.total }
24281
+ });
23364
24282
  }
23365
- if (params.action === "recall")
23366
- return reply(pageText(await queryRecall(ctx, { ...params, signal }), params.offset ?? 0) + `
23367
- Use Pi's /resume to open a different session.`);
23368
24283
  if (params.action === "anchor") {
24284
+ if (config().contextPressureOnly && !contextPressure(ctx, config()).cleanup) {
24285
+ throw new Error("No context pressure (or usage is unavailable); no anchor added. Use smart_context status. A human can save an early anchor from /smart-compact.");
24286
+ }
23369
24287
  const data = anchor(ctx, params.name ?? "", params.summary ?? "");
24288
+ const cleanup = options.onAnchor?.(ctx, data.targetId, callId, signal) ?? "History unchanged; no cleanup controller attached.";
23370
24289
  return { content: [{ type: "text", text: `Anchor: ${data.name}
23371
24290
 
23372
- ${data.summary}` }], details: { anchor: data } };
24291
+ ${data.summary}
24292
+
24293
+ ${cleanup}` }], details: { anchor: data } };
23373
24294
  }
23374
24295
  const operation = preparePivot(ctx, params.target ?? "", params.carryover ?? "", params.message);
23375
24296
  operation.callId = callId;
23376
24297
  operation.signal = signal;
23377
24298
  queued = operation;
23378
24299
  options.onContextChange?.(ctx);
23379
- return { ...reply("Pivot queued. End this turn; the host will revalidate and navigate after the batch settles. No file/process rollback."), details: { queued: "pivot", targetId: operation.targetId }, terminate: true };
24300
+ return { ...reply("Pivot queued. End this turn; the host will revalidate and navigate after the batch settles. No file/process rollback."), details: { queued: "pivot", targetId: operation.targetId, display: { state: "queued", action: "pivot", target: params.target ?? "" } }, terminate: true };
24301
+ },
24302
+ renderCall(args, theme) {
24303
+ const label = theme.fg("toolTitle", "smart_navigation ");
24304
+ const action = safeArg(args.action, 20);
24305
+ const target = safeArg(args.target, 80);
24306
+ const name = safeArg(args.name, 80);
24307
+ const keyword = safeArg(args.keyword, 60);
24308
+ const detail = target ? " " + theme.fg("dim", target) : name ? " " + theme.fg("muted", name) : keyword ? " " + theme.fg("dim", '"' + keyword + '"') : "";
24309
+ return new Text12(label + theme.fg("accent", action) + detail, 0, 0);
24310
+ },
24311
+ renderResult(result, { expanded }, theme, context) {
24312
+ return tryRow(theme, () => renderNavigationRow(result, expanded, theme, context), result, expanded);
23380
24313
  }
23381
24314
  });
23382
24315
  pi.on("turn_end", (event, ctx) => {
@@ -23484,7 +24417,8 @@ ${operation.carryover}` } };
23484
24417
  ${data.summary}`, display: true, details: { anchor: data } }, { triggerTurn: false });
23485
24418
  pi.setLabel(ctx.sessionManager.getLeafId(), data.name);
23486
24419
  footer(ctx);
23487
- return { ok: true, message: `Anchor ${data.name} saved; history was not compacted.` };
24420
+ const cleanup = options.onAnchor?.(ctx, data.targetId) ?? "History unchanged; no cleanup controller attached.";
24421
+ return { ok: true, message: `Anchor ${data.name} saved; history was not compacted. ${cleanup}` };
23488
24422
  },
23489
24423
  async pivot(target, carryover, message) {
23490
24424
  return applyPivot(preparePivot(ctx, target, carryover, message), ctx);
@@ -23780,8 +24714,7 @@ function unwrapConsumed(result, ctx) {
23780
24714
  }
23781
24715
  }
23782
24716
  function smartCompactExtension(pi) {
23783
- const PENDING_TTL_MS = FIVE_MINUTES_MS;
23784
- const pendingRef = createPendingSlot({ ttlMs: PENDING_TTL_MS });
24717
+ const pendingRef = createPendingSlot({ ttlMs: () => loadConfig().pendingTtlMs });
23785
24718
  const isRunning = createSessionRunLock();
23786
24719
  let navigation;
23787
24720
  const pivotQueued = (ctx) => navigation?.isPending(ctx) ?? false;
@@ -23799,14 +24732,19 @@ function smartCompactExtension(pi) {
23799
24732
  showStatus: policy.branchOverrides().showStatus ?? config.showStatus
23800
24733
  };
23801
24734
  };
24735
+ const activeToolDefinitions = () => {
24736
+ const active = new Set(pi.getActiveTools());
24737
+ return pi.getAllTools().filter((tool) => active.has(tool.name)).map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
24738
+ };
23802
24739
  const background = createBackgroundPreparation({
24740
+ toolSignature: () => JSON.stringify(activeToolDefinitions()),
23803
24741
  async prepare(ctx, config, signal) {
23804
24742
  if (signal.aborted || isRunning.isSessionActive(resolveSessionId(ctx)))
23805
24743
  return null;
23806
24744
  const { sumModel, segModel, verifyModel } = resolveModels(ctx, undefined, config);
23807
24745
  if (!sumModel || !segModel || !verifyModel)
23808
24746
  return null;
23809
- const localPending = createPendingSlot({ ttlMs: FIVE_MINUTES_MS });
24747
+ const localPending = createPendingSlot({ ttlMs: config.pendingTtlMs });
23810
24748
  const outcome = await runSmartCompact({
23811
24749
  ctx,
23812
24750
  config,
@@ -23846,10 +24784,7 @@ function smartCompactExtension(pi) {
23846
24784
  }
23847
24785
  };
23848
24786
  const nativeContinuity = createNativeContinuityBridge();
23849
- setNativeToolSource(() => {
23850
- const active = new Set(pi.getActiveTools());
23851
- return pi.getAllTools().filter((tool) => active.has(tool.name)).map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
23852
- });
24787
+ setNativeToolSource(activeToolDefinitions);
23853
24788
  registerAnchorCache(pi, { config: loadConfig });
23854
24789
  const nativeReplay = createNativeReplayHook();
23855
24790
  pi.on("before_provider_request", (event, ctx) => nativeReplay.handle(event.payload, ctx));
@@ -23873,7 +24808,7 @@ function smartCompactExtension(pi) {
23873
24808
  return write;
23874
24809
  };
23875
24810
  const commitCandidates = createCompactionCommitStore({
23876
- ttlMs: loadConfig().pendingTtlMs,
24811
+ ttlMs: () => loadConfig().pendingTtlMs,
23877
24812
  onDiscard: (pending, reason) => {
23878
24813
  recordApplyFailure(pending, reason);
23879
24814
  }
@@ -23888,7 +24823,7 @@ function smartCompactExtension(pi) {
23888
24823
  const calls = displaced.reduce((sum, pending) => sum + (pending.metricsSnapshot?.totalCalls ?? 0), 0);
23889
24824
  reportIssue({
23890
24825
  key: "compact.foreign-displaced",
23891
- message: actor + " was applied instead of Continuity's prepared summary; that summary (" + calls + " model call" + (calls === 1 ? "" : "s") + ") was discarded and its continuity state was not saved. If another compaction extension is installed, keep only one."
24826
+ message: actor + " was applied instead of Continuity's prepared summary; that summary (" + calls + " model call" + (calls === 1 ? "" : "s") + ") was discarded and its continuity state was not saved."
23892
24827
  }, ctx);
23893
24828
  await Promise.all(displaced.flatMap((pending) => applyFailureWrites.get(pending.runId) ?? []));
23894
24829
  return;
@@ -23896,7 +24831,7 @@ function smartCompactExtension(pi) {
23896
24831
  if (source === "extension") {
23897
24832
  reportIssue({
23898
24833
  key: "compact.foreign-extension",
23899
- message: actor + " was applied; Continuity did not summarize it and recorded no continuity state or metrics for it. If that is unintended, keep only one compaction extension."
24834
+ message: actor + " was applied; Continuity did not summarize it and recorded no continuity state or metrics for it."
23900
24835
  }, ctx);
23901
24836
  } else if (automaticConfig().autoTrigger) {
23902
24837
  reportIssue({
@@ -23959,6 +24894,7 @@ function smartCompactExtension(pi) {
23959
24894
  navigation = registerNavigation(pi, {
23960
24895
  config: automaticConfig,
23961
24896
  mutationBlocked: (ctx) => isRunning.isSessionActive(resolveSessionId(ctx)) ? "Compaction is running; wait for it to finish." : undefined,
24897
+ onAnchor: (ctx, originId, callId, signal) => smartContext.requestAnchorTrim(ctx, originId, callId, signal).notice,
23962
24898
  onContextChange: (ctx) => invalidatePreparation(ctx, "branch"),
23963
24899
  onContextEdit: (_ctx, kind) => hostCache.noteContextEdit(kind)
23964
24900
  });
@@ -24007,17 +24943,6 @@ function smartCompactExtension(pi) {
24007
24943
  pi.on("session_start", (_event, ctx) => {
24008
24944
  nativeReplay.refresh(ctx);
24009
24945
  flushIssues(ctx);
24010
- try {
24011
- const conflicts = detectExtensionConflicts({ commands: typeof pi.getCommands === "function" ? pi.getCommands() : [], tools: pi.getAllTools() });
24012
- if (conflicts.length > 0)
24013
- reportIssue({ key: "interop.conflicts", message: conflictNotice(conflicts) }, ctx);
24014
- } catch (error) {
24015
- reportIssue({
24016
- key: "interop.detect",
24017
- message: "Could not check loaded extensions for conflicts (" + errorDetail(error) + "). Other compaction extensions are not detected at startup. Please report this if it repeats.",
24018
- error
24019
- }, ctx);
24020
- }
24021
24946
  invalidatePreparation(ctx);
24022
24947
  hostCache.reset(resolveSessionId(ctx));
24023
24948
  toolExposure.atBoundary();
@@ -24283,10 +25208,10 @@ function smartCompactExtension(pi) {
24283
25208
  if (!hostCache.observe(event.message)?.warn)
24284
25209
  return;
24285
25210
  const { foreign } = hostCache.summary().rebuilds;
24286
- reportIssue({
25211
+ recordIssue({
24287
25212
  key: "cache.foreign-rebuilds",
24288
25213
  message: "Pi's prompt cache was rebuilt " + foreign.count + " times this session with no Continuity edit before them (" + foreign.uncached.toLocaleString("en-US") + " uncached prompt tokens re-sent). Other extensions, model or tool changes, or Pi's built-in compaction change the prefix too; Home \u203A Readiness & details lists rebuild causes."
24289
- }, ctx);
25214
+ });
24290
25215
  });
24291
25216
  pi.on("message_end", async (event, ctx) => {
24292
25217
  try {
@@ -24328,7 +25253,6 @@ function smartCompactExtension(pi) {
24328
25253
  });
24329
25254
  pi.on("session_compact", (_event, ctx) => {
24330
25255
  nativeReplay.refresh(ctx);
24331
- toolExposure.atBoundary();
24332
25256
  policy.restore(ctx);
24333
25257
  navigation.refresh(ctx);
24334
25258
  });