pi-smart-compact 10.0.1 → 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 (51) hide show
  1. package/ARCHITECTURE.md +67 -19
  2. package/CHANGELOG.md +75 -0
  3. package/README.md +12 -7
  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 +1340 -339
  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/configuration.md +95 -32
  49. package/docs/evaluation.md +15 -2
  50. package/docs/guide.md +57 -31
  51. package/package.json +1 -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.1";
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
  };
@@ -16016,89 +16368,6 @@ function createNativeContinuityBridge(opts = {}) {
16016
16368
  };
16017
16369
  }
16018
16370
 
16019
- // src/app/settled-auto-trigger.ts
16020
- function createSettledAutoTrigger(options = {}) {
16021
- const now = options.now ?? Date.now;
16022
- const cooldownMs = Math.max(0, options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS);
16023
- const active = new Map;
16024
- const lastCompactionAt = new Map;
16025
- const noteCompaction = (sessionId) => {
16026
- if (!isUnresolvedSessionId(sessionId))
16027
- lastCompactionAt.set(sessionId, now());
16028
- };
16029
- const clear = (sessionId) => {
16030
- active.delete(sessionId);
16031
- lastCompactionAt.delete(sessionId);
16032
- };
16033
- const request = async (ctx, config) => {
16034
- if (!config.autoTrigger || !["settled", "background"].includes(config.autoTriggerStrategy))
16035
- return;
16036
- const sessionId = resolveSessionId(ctx);
16037
- if (isUnresolvedSessionId(sessionId) || active.has(sessionId))
16038
- return;
16039
- const usage = ctx.getContextUsage();
16040
- const totalTokens = usage?.tokens;
16041
- if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < MIN_TOKEN_THRESHOLD || !ctx.model)
16042
- return;
16043
- const contextPercent = safeContextPercent(totalTokens, effectiveContextWindow(ctx.model, config));
16044
- if (contextPercent < config.minContextPercent)
16045
- return;
16046
- const lastCompaction = lastCompactionAt.get(sessionId);
16047
- if (lastCompaction !== undefined && now() - lastCompaction < cooldownMs)
16048
- return;
16049
- if (!ctx.isIdle() || ctx.hasPendingMessages())
16050
- return;
16051
- const requestToken = Symbol(sessionId);
16052
- active.set(sessionId, requestToken);
16053
- await new Promise((resolve) => {
16054
- let finished = false;
16055
- const watchdogMs = options.watchdogMs ?? AUTO_TRIGGER_TIMEOUT_CAP_MS + config.autoTriggerTimeoutMs;
16056
- const watchdog = setTimeout(() => {
16057
- reportIssue({
16058
- key: "auto.settled-no-callback",
16059
- message: "Pi did not report the automatic compaction result within " + Math.round(watchdogMs / 1000) + "s. The next settled turn may request it again."
16060
- }, ctx);
16061
- finish();
16062
- }, watchdogMs);
16063
- watchdog.unref?.();
16064
- const finish = () => {
16065
- if (finished)
16066
- return;
16067
- finished = true;
16068
- clearTimeout(watchdog);
16069
- if (active.get(sessionId) === requestToken)
16070
- active.delete(sessionId);
16071
- resolve();
16072
- };
16073
- try {
16074
- ctx.compact({
16075
- onComplete: () => {
16076
- if (active.get(sessionId) === requestToken)
16077
- noteCompaction(sessionId);
16078
- finish();
16079
- },
16080
- onError: (error) => {
16081
- reportIssue({
16082
- key: "auto.settled-apply",
16083
- message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16084
- error
16085
- }, ctx);
16086
- finish();
16087
- }
16088
- });
16089
- } catch (error) {
16090
- reportIssue({
16091
- key: "auto.settled-apply",
16092
- message: "Pi could not run the automatic compaction (" + errorDetail(error) + "). Conversation unchanged. Run /smart-compact manually if context is high.",
16093
- error
16094
- }, ctx);
16095
- finish();
16096
- }
16097
- });
16098
- };
16099
- return { request, noteCompaction, clear };
16100
- }
16101
-
16102
16371
  // src/app/background-preparation.ts
16103
16372
  function recordPreparationDiscard(sessionId, snapshot, reason) {
16104
16373
  return appendMetricsSnapshot(sessionId, {
@@ -16120,11 +16389,31 @@ function preparationWindow(config, window) {
16120
16389
  const startTokens = explicit ? Math.max(MIN_TOKEN_THRESHOLD, window * prepare / 100) : preparationStartTokens(applyTokens);
16121
16390
  return { startTokens, applyTokens };
16122
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
+ }
16123
16411
  function snapshotContext(ctx) {
16124
16412
  const branch = structuredClone(ctx.sessionManager.getBranch());
16125
16413
  const sessionId = resolveSessionId(ctx);
16126
16414
  const sessionFile = ctx.sessionManager.getSessionFile?.();
16127
16415
  const usage = structuredClone(ctx.getContextUsage());
16416
+ const systemPrompt = ctx.getSystemPrompt?.() ?? "";
16128
16417
  const sessionManager = new Proxy(ctx.sessionManager, {
16129
16418
  get(target, key) {
16130
16419
  if (key === "getBranch")
@@ -16143,20 +16432,21 @@ function snapshotContext(ctx) {
16143
16432
  model: ctx.model && { ...ctx.model },
16144
16433
  hasUI: false,
16145
16434
  getContextUsage: () => usage,
16435
+ getSystemPrompt: () => systemPrompt,
16146
16436
  ui: { ...ctx.ui, notify() {}, setStatus() {}, setWidget() {} }
16147
16437
  };
16148
16438
  return Object.assign(snapshot, { silentBackground: true });
16149
16439
  }
16150
16440
  function signature(ctx, config) {
16151
16441
  const model = ctx.model;
16152
- 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]);
16153
16443
  }
16154
16444
  function enabled(config) {
16155
16445
  return config.autoTrigger && config.autoTriggerStrategy === "background";
16156
16446
  }
16157
16447
  function createBackgroundPreparation(options) {
16158
16448
  const now = options.now ?? Date.now;
16159
- const ttlMs = options.ttlMs ?? FIVE_MINUTES_MS;
16449
+ const signatureOf = (ctx, config) => signature(ctx, config) + (options.toolSignature?.() ?? "");
16160
16450
  const cooldownMs = options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS;
16161
16451
  let current = null;
16162
16452
  let lastAttempt;
@@ -16203,9 +16493,9 @@ function createBackgroundPreparation(options) {
16203
16493
  return "config";
16204
16494
  if (task.sessionId !== resolveSessionId(ctx))
16205
16495
  return "session";
16206
- if (task.signature !== signature(ctx, config))
16496
+ if (task.signature !== signatureOf(ctx, config))
16207
16497
  return "config";
16208
- if (now() - (task.readyAt ?? task.createdAt) > ttlMs)
16498
+ if (now() - (task.readyAt ?? task.createdAt) > (options.ttlMs ?? config.pendingTtlMs))
16209
16499
  return "ttl";
16210
16500
  const branch = ctx.sessionManager.getBranch();
16211
16501
  const origin = branch.findIndex((entry) => entry.id === task.originId);
@@ -16237,7 +16527,7 @@ function createBackgroundPreparation(options) {
16237
16527
  const task = {
16238
16528
  sessionId,
16239
16529
  originId,
16240
- signature: signature(ctx, config),
16530
+ signature: signatureOf(ctx, config),
16241
16531
  controller: new AbortController,
16242
16532
  createdAt: now(),
16243
16533
  ctx
@@ -16245,7 +16535,15 @@ function createBackgroundPreparation(options) {
16245
16535
  current = task;
16246
16536
  lastAttempt = { sessionId, at: now() };
16247
16537
  const preparationConfig = { ...config, minContextPercent: startTokens / window * 100 };
16248
- 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) => {
16249
16547
  if (pending) {
16250
16548
  task.pending = pending;
16251
16549
  task.readyAt = now();
@@ -16330,6 +16628,83 @@ function createBackgroundPreparation(options) {
16330
16628
  };
16331
16629
  }
16332
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
+
16333
16708
  // src/app/host-cache-ledger.ts
16334
16709
  var FOREIGN_REBUILD_WARN_COUNT = 3;
16335
16710
  var num = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
@@ -16493,8 +16868,106 @@ function compactionUsage(snapshot, resolveModel) {
16493
16868
 
16494
16869
  // src/app/register-context-tools.ts
16495
16870
  import { StringEnum } from "@earendil-works/pi-ai";
16871
+ import { Text as Text4 } from "@earendil-works/pi-tui";
16496
16872
  import { Type as Type3 } from "typebox";
16497
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
+
16498
16971
  // src/app/mnemopi-memory.ts
16499
16972
  import { execFile as execFile2 } from "child_process";
16500
16973
  import { existsSync as existsSync5 } from "fs";
@@ -16782,6 +17255,15 @@ function registerContextTools(pi) {
16782
17255
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes("smart_recall"))
16783
17256
  throw new Error("smart_recall is disabled in Pi settings or /tools.");
16784
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);
16785
17267
  }
16786
17268
  });
16787
17269
  pi.registerTool({
@@ -16816,9 +17298,197 @@ function registerContextTools(pi) {
16816
17298
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes("smart_save_memory"))
16817
17299
  throw new Error("smart_save_memory is disabled in Pi settings or /tools.");
16818
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);
16819
17313
  }
16820
17314
  });
16821
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
+ }
16822
17492
  function textResult(text, details = undefined) {
16823
17493
  return { content: [{ type: "text", text }], details };
16824
17494
  }
@@ -16892,7 +17562,10 @@ Not yet searchable remotely: ` + pending.map((receipt) => receipt.documentId + "
16892
17562
  }
16893
17563
  const kinds = params.kinds?.length ? new Set(params.kinds) : null;
16894
17564
  const facts = outcome.facts.filter((fact) => !kinds || !fact.metadata.kind || kinds.has(fact.metadata.kind)).slice(0, params.limit ?? 5);
16895
- 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 : ""), {
16896
17569
  remote: {
16897
17570
  state: "ok",
16898
17571
  target: describeHindsightTarget(hindsight.target),
@@ -16900,7 +17573,8 @@ Not yet searchable remotely: ` + pending.map((receipt) => receipt.documentId + "
16900
17573
  id: fact.id,
16901
17574
  documentId: fact.documentId,
16902
17575
  type: fact.type,
16903
- mentionedAt: fact.mentionedAt
17576
+ mentionedAt: fact.mentionedAt,
17577
+ preview: fact.text.replace(/\s+/g, " ").slice(0, 160)
16904
17578
  })),
16905
17579
  redactions: scrubber.count(),
16906
17580
  receipts: refreshed.map((receipt) => ({
@@ -17019,13 +17693,20 @@ Destination:
17019
17693
  ref: "hindsight:" + memoryId + "@" + digest,
17020
17694
  remote: "receipt" in outcome && outcome.receipt ? {
17021
17695
  state: outcome.state,
17696
+ title,
17022
17697
  documentId: outcome.receipt.documentId,
17023
17698
  operationId: outcome.receipt.operationId,
17024
17699
  target: describeHindsightTarget(hindsight.target),
17025
17700
  ..."reason" in outcome ? { reason: outcome.reason } : {}
17026
17701
  } : outcome
17027
17702
  };
17028
- 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);
17029
17710
  }
17030
17711
  async function resolveMemoryByRef(params, signal, ctx, config, projectId) {
17031
17712
  const ref = params.ref ? parseMemoryRef(params.ref) : null;
@@ -17144,6 +17825,9 @@ function resolveModels(ctx, primary, config, explicit = false) {
17144
17825
  return { segModel, sumModel, verifyModel };
17145
17826
  }
17146
17827
 
17828
+ // src/app/register-smart-compact-tool.ts
17829
+ import { Text as Text5 } from "@earendil-works/pi-tui";
17830
+
17147
17831
  // src/ui/dashboard-format.ts
17148
17832
  function dashboardPageSize(rows = process.stdout.rows) {
17149
17833
  if (!rows)
@@ -17489,16 +18173,16 @@ function statusClass(status) {
17489
18173
  return "warn";
17490
18174
  return "good";
17491
18175
  }
17492
- function statusLabel(status) {
18176
+ function statusLabel2(status) {
17493
18177
  return status ?? "success";
17494
18178
  }
17495
18179
  function badge(status) {
17496
- const label = statusLabel(status);
18180
+ const label = statusLabel2(status);
17497
18181
  return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
17498
18182
  }
17499
18183
  function summarizeDashboard(entries) {
17500
18184
  const durations = entries.map(metricDuration).filter(Boolean);
17501
- const success = entries.filter((e) => statusLabel(e.status) === "success").length;
18185
+ const success = entries.filter((e) => statusLabel2(e.status) === "success").length;
17502
18186
  const timeout = entries.filter((e) => e.status === "timeout").length;
17503
18187
  const error = entries.filter((e) => e.status === "error" && e.failureKind !== "cancelled").length;
17504
18188
  const dryRun = entries.filter((e) => e.status === "dry-run").length;
@@ -18009,7 +18693,7 @@ function registerSmartCompactTool(pi, dependencies) {
18009
18693
  pi.registerTool({
18010
18694
  name: "smart_compact",
18011
18695
  label: "Smart Compact",
18012
- 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.",
18013
18697
  parameters: {
18014
18698
  type: "object",
18015
18699
  properties: {
@@ -18055,6 +18739,19 @@ function registerSmartCompactTool(pi, dependencies) {
18055
18739
  }
18056
18740
  }
18057
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
+ },
18058
18755
  async execute(_id, params, signal, _onUpdate, ctx) {
18059
18756
  if (!policy.isAgentToolEnabled()) {
18060
18757
  return textResult2("smart_compact is hidden from the agent for this session. The user can still run /smart-compact manually.");
@@ -18078,13 +18775,13 @@ function registerSmartCompactTool(pi, dependencies) {
18078
18775
  const dashboard = action === "dashboard" ? writeMetricsDashboard() : null;
18079
18776
  return textResult2(report + (dashboard ? `
18080
18777
 
18081
- Dashboard: ` + dashboard : ""));
18778
+ Dashboard: ` + dashboard : ""), { display: { kind: "metrics", dashboard: dashboard !== null } });
18082
18779
  }
18083
18780
  const config = loadConfig();
18084
18781
  const resolvedMode = mode ?? config.mode;
18085
18782
  const sessionId = resolveSessionId(ctx);
18086
18783
  if (!dryRun && pendingRef.peek(sessionId)) {
18087
- 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.");
18088
18785
  }
18089
18786
  const usage = ctx.getContextUsage?.();
18090
18787
  const totalTokens = usage?.tokens ?? 0;
@@ -18128,10 +18825,19 @@ Dashboard: ` + dashboard : ""));
18128
18825
  content: [
18129
18826
  {
18130
18827
  type: "text",
18131
- 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."
18132
18829
  }
18133
18830
  ],
18134
- 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
+ }
18135
18841
  };
18136
18842
  }
18137
18843
  if (outcome.kind === "dry-run") {
@@ -18143,13 +18849,16 @@ Dashboard: ` + dashboard : ""));
18143
18849
  text: "Dry run finished (" + resolvedMode + ", " + seconds + "s). Pipeline ran successfully; no summary was staged."
18144
18850
  }
18145
18851
  ],
18146
- details: outcome.details
18852
+ details: {
18853
+ ...outcome.details,
18854
+ display: { state: "dry-run", mode: resolvedMode, seconds: Number(seconds) }
18855
+ }
18147
18856
  };
18148
18857
  }
18149
18858
  if (outcome.kind === "cancelled") {
18150
- 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 } });
18151
18860
  }
18152
- 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" } });
18153
18862
  } catch (error) {
18154
18863
  recordIssue({ key: "tool.smart-compact", message: "smart_compact failed: " + errorDetail(error) + ".", error });
18155
18864
  throw new Error(formatCompactErrorForUi(error));
@@ -18157,13 +18866,42 @@ Dashboard: ` + dashboard : ""));
18157
18866
  }
18158
18867
  });
18159
18868
  }
18160
- function textResult2(text) {
18161
- 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);
18162
18899
  }
18163
18900
 
18164
18901
  // src/app/register-smart-context-tool.ts
18165
18902
  import { randomUUID as randomUUID4 } from "crypto";
18166
18903
  import { StringEnum as StringEnum2 } from "@earendil-works/pi-ai";
18904
+ import { Text as Text6 } from "@earendil-works/pi-tui";
18167
18905
  import { Type as Type4 } from "typebox";
18168
18906
 
18169
18907
  // src/app/context-evidence.ts
@@ -18411,11 +19149,11 @@ function registerSmartContextTool(pi, options = {}) {
18411
19149
  const current = config();
18412
19150
  return new SecretScrubber(current.scrubSecrets, current.scrubPii).scrubText(text).value;
18413
19151
  };
18414
- const reply = (text) => ({ content: [{ type: "text", text }], details: undefined });
19152
+ const reply = (text, details = undefined) => ({ content: [{ type: "text", text }], details });
18415
19153
  pi.registerTool({
18416
19154
  name: TOOL_NAME,
18417
19155
  label: "Session Context",
18418
- 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.",
18419
19157
  parameters: Type4.Object({
18420
19158
  action: StringEnum2(["status", "plan", "checkpoint", "rewind", "trim", "read", "search"]),
18421
19159
  label: Type4.Optional(Type4.String({ maxLength: 120, description: "Checkpoint label." })),
@@ -18439,15 +19177,28 @@ function registerSmartContextTool(pi, options = {}) {
18439
19177
  const branch = ctx.sessionManager.getBranch();
18440
19178
  const state = inspectContext(branch, sessionId);
18441
19179
  if (params.action === "plan") {
18442
- const plan = planContextTrim(branch, state.checkpoint?.originId);
18443
- 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,
18444
19184
  outputs: plan.references.length,
18445
19185
  superseded: plan.superseded,
18446
19186
  savedChars: plan.savedChars,
18447
19187
  batch: plan.automatic,
18448
19188
  cooldownTurns: plan.cooldownTurns,
18449
- 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."
18450
- }));
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
+ });
18451
19202
  }
18452
19203
  const offset = params.offset ?? 0;
18453
19204
  if (params.action === "status" || params.action === "read" || params.action === "search") {
@@ -18464,7 +19215,20 @@ function registerSmartContextTool(pi, options = {}) {
18464
19215
  const evidence = contextEvidence(branch, sessionId, new SecretScrubber(settings.scrubSecrets, settings.scrubPii), lineage);
18465
19216
  if (params.action === "status") {
18466
19217
  const sources = evidence.list.slice(offset, offset + limit);
18467
- 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,
18468
19232
  checkpoint: state.checkpoint ? { id: state.checkpoint.id, label: state.checkpoint.label } : null,
18469
19233
  rewind: Boolean(state.checkpoint),
18470
19234
  reason: state.invalidReason,
@@ -18480,7 +19244,21 @@ function registerSmartContextTool(pi, options = {}) {
18480
19244
  sources: evidence.list.filter((source) => source.session === parent.sessionId).length
18481
19245
  })) } : {},
18482
19246
  nextOffset: offset + limit < evidence.list.length ? offset + limit : null
18483
- })));
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
+ });
18484
19262
  }
18485
19263
  if (params.action === "search") {
18486
19264
  if (!params.query)
@@ -18501,6 +19279,12 @@ ${page.text}`);
18501
19279
  if (options.canAgentMutate?.(ctx) === false) {
18502
19280
  throw new Error("Agent-requested context changes are disabled by policy; status, plan, search and read stay available.");
18503
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
+ }
18504
19288
  if (options.isPaused?.(ctx))
18505
19289
  throw new Error("Context changes are paused while a navigation pivot is pending.");
18506
19290
  if (queued)
@@ -18527,7 +19311,21 @@ ${page.text}`);
18527
19311
  text: params.action === "rewind" ? text : text.slice(0, 120),
18528
19312
  signal
18529
19313
  };
18530
- 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);
18531
19329
  }
18532
19330
  });
18533
19331
  const clear = () => {
@@ -18574,7 +19372,8 @@ ${page.text}`);
18574
19372
  if (!current) {
18575
19373
  if (mark?.sessionId !== sessionId)
18576
19374
  return;
18577
- 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)) {
18578
19377
  mark = null;
18579
19378
  return;
18580
19379
  }
@@ -18599,7 +19398,7 @@ ${page.text}`);
18599
19398
  return changed ? { messages } : undefined;
18600
19399
  };
18601
19400
  const vetoWarming = (event, ctx, sessionId) => {
18602
- 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))
18603
19402
  return false;
18604
19403
  const cost = ctx.model?.cost;
18605
19404
  const price = cost ? cost.cacheWrite > 0 ? cost.cacheWrite : cost.input : NaN;
@@ -18617,15 +19416,25 @@ ${page.text}`);
18617
19416
  if (event.outcome !== "completed" || request?.signal?.aborted) {
18618
19417
  return request ? cancelled(event, "The turn did not complete.") : undefined;
18619
19418
  }
18620
- 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)))
18621
19421
  return cancelled(event, "Agent tool access was revoked.");
19422
+ if (request?.anchorBoundary !== undefined && !config().contextNavigationEnabled)
19423
+ return cancelled(event, "Navigation was disabled.");
18622
19424
  if (options.isPaused?.(ctx)) {
18623
19425
  return request ? cancelled(event, "A pending navigation pivot takes priority.") : undefined;
18624
19426
  }
18625
19427
  if (request && !request.manual && options.canAgentMutate?.(ctx) === false) {
18626
19428
  return cancelled(event, "Agent-requested context changes are disabled by policy.");
18627
19429
  }
18628
- 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
+ }
18629
19438
  if (!request) {
18630
19439
  const configNow = config();
18631
19440
  const hygiene = configNow.contextHygieneEnabled;
@@ -18634,11 +19443,11 @@ ${page.text}`);
18634
19443
  const enabled = hygiene || configNow.autoTrigger && configNow.autoTriggerStrategy === "background";
18635
19444
  if (!enabled || options.canAutoTrim?.(ctx) === false)
18636
19445
  return;
18637
- const usage = ctx.getContextUsage()?.tokens;
18638
- const window = effectiveContextWindow(ctx.model, configNow);
18639
- pressure = typeof usage === "number" && Number.isFinite(usage) && typeof window === "number" && Number.isFinite(window) && window > 0 && usage >= preparationWindow(configNow, window).startTokens;
18640
- if (!hygiene && !pressure)
18641
- return;
19446
+ if ((configNow.contextPressureOnly || !hygiene) && !pressure) {
19447
+ mark = null;
19448
+ if (!applied)
19449
+ return;
19450
+ }
18642
19451
  }
18643
19452
  const branch = ctx.sessionManager.getBranch();
18644
19453
  const sessionId = resolveSessionId(ctx);
@@ -18669,13 +19478,15 @@ ${page.text}`);
18669
19478
  }
18670
19479
  })];
18671
19480
  } else if (request?.action === "rewind") {
18672
- entries = planContextRewind(branch, sessionId, request.checkpointId, request.text).entries;
19481
+ entries = planContextRewind(branch, sessionId, request.checkpointId, request.text, ctx.model?.api).entries;
18673
19482
  } else if (!request && applied?.sessionId === sessionId && unchangedSince(branch, applied.leafId)) {
18674
19483
  mark = null;
18675
19484
  entries = applied.entries;
18676
19485
  applied = null;
18677
19486
  } else {
18678
- 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;
18679
19490
  if (request) {
18680
19491
  entries = trimEntries(plan, request.manual ? "manual" : "agent");
18681
19492
  } else {
@@ -18716,41 +19527,54 @@ ${page.text}`);
18716
19527
  }
18717
19528
  return { entries: [...event.entries, ...entries] };
18718
19529
  });
18719
- const controller = {
18720
- requestManualTrim(ctx) {
18721
- const sessionId = resolveSessionId(ctx);
18722
- if (isUnresolvedSessionId(sessionId)) {
18723
- return { state: "unavailable", notice: "Context control needs an identifiable session." };
18724
- }
18725
- if (options.isPaused?.(ctx)) {
18726
- return { state: "paused", notice: "A navigation pivot is pending; try again after it finishes." };
18727
- }
18728
- if (queued) {
18729
- return { state: "busy", notice: "Another context change is already queued; it applies at the next turn boundary." };
18730
- }
18731
- const branch = ctx.sessionManager.getBranch();
18732
- const plan = planContextTrim(branch, inspectContext(branch, sessionId).checkpoint?.originId);
18733
- if (!plan.entries.length) {
18734
- return { state: "no-eligible", notice: "No eligible archived output to trim." };
18735
- }
18736
- mark = null;
18737
- queued = {
18738
- action: "trim",
18739
- callId: "",
18740
- sessionId,
18741
- originId: branch.at(-1)?.id ?? "",
18742
- checkpointId: "",
18743
- text: "",
18744
- manual: true
18745
- };
18746
- return {
18747
- state: "queued",
18748
- 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."
18749
- };
18750
- },
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 }),
18751
19576
  deferredTrim: deferred
18752
19577
  };
18753
- return controller;
18754
19578
  }
18755
19579
  function cancelled(event, reason) {
18756
19580
  return {
@@ -18762,6 +19586,48 @@ function cancelled(event, reason) {
18762
19586
  }]
18763
19587
  };
18764
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
+ }
18765
19631
 
18766
19632
  // src/app/model-feasibility.ts
18767
19633
  async function createModelFeasibilityResolver(ctx, config, requestedMode = config.mode, steering = {}) {
@@ -19175,7 +20041,7 @@ import {
19175
20041
  getKeybindings as getKeybindings2,
19176
20042
  Key as Key4,
19177
20043
  matchesKey as matchesKey4,
19178
- Text as Text5,
20044
+ Text as Text9,
19179
20045
  wrapTextWithAnsi as wrapTextWithAnsi3
19180
20046
  } from "@earendil-works/pi-tui";
19181
20047
 
@@ -19184,7 +20050,7 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
19184
20050
  import {
19185
20051
  getKeybindings,
19186
20052
  SettingsList,
19187
- truncateToWidth as truncateToWidth3,
20053
+ truncateToWidth as truncateToWidth4,
19188
20054
  visibleWidth as visibleWidth2,
19189
20055
  wrapTextWithAnsi as wrapTextWithAnsi2
19190
20056
  } from "@earendil-works/pi-tui";
@@ -19230,8 +20096,8 @@ class SmartSettingsList extends SettingsList {
19230
20096
  internals.items.forEach((item, index) => {
19231
20097
  const original = saved[index];
19232
20098
  const shown = (this.inactive.has(item.id) ? INACTIVE_PREFIX : "") + original.currentValue;
19233
- item.label = truncateToWidth3(original.label, labelWidth, "\u2026");
19234
- item.currentValue = truncateToWidth3(shown, valueWidth, "\u2026");
20099
+ item.label = truncateToWidth4(original.label, labelWidth, "\u2026");
20100
+ item.currentValue = truncateToWidth4(shown, valueWidth, "\u2026");
19235
20101
  if (index === internals.selectedIndex) {
19236
20102
  item.description = [
19237
20103
  item.label !== original.label ? original.label : "",
@@ -19272,7 +20138,7 @@ class SmartSettingsList extends SettingsList {
19272
20138
  // src/ui/settings-overlay.ts
19273
20139
  import {
19274
20140
  Container as Container4,
19275
- Text as Text4
20141
+ Text as Text8
19276
20142
  } from "@earendil-works/pi-tui";
19277
20143
 
19278
20144
  // src/ui/settings-complex.ts
@@ -19283,7 +20149,7 @@ import {
19283
20149
  Key as Key3,
19284
20150
  matchesKey as matchesKey3,
19285
20151
  SelectList as SelectList3,
19286
- Text as Text3
20152
+ Text as Text7
19287
20153
  } from "@earendil-works/pi-tui";
19288
20154
  var MODEL_SETTINGS = [
19289
20155
  {
@@ -19331,7 +20197,7 @@ var MIN_CONTEXT_SETTING = {
19331
20197
  var MAX_CONTEXT_SETTING = {
19332
20198
  id: "maxContextTokens",
19333
20199
  label: "Context cap for start % (tokens)",
19334
- 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.",
19335
20201
  placeholder: "0 or 16384\u20132000000; blank = off",
19336
20202
  parse: numberParser(CONFIG_NUMERIC_LIMITS.maxContextTokens),
19337
20203
  format: (value) => value === undefined || value === 0 ? "off" : String(value)
@@ -19339,8 +20205,8 @@ var MAX_CONTEXT_SETTING = {
19339
20205
  var parsePreparePercent = numberParser(CONFIG_NUMERIC_LIMITS.prepareContextPercent);
19340
20206
  var PREPARE_CONTEXT_SETTING = {
19341
20207
  id: "prepareContextPercent",
19342
- label: "Prepare at context %",
19343
- 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.",
19344
20210
  placeholder: "0\u2013100 below Start at; auto or blank = Auto",
19345
20211
  parse: (input) => input.trim().toLowerCase() === "auto" ? undefined : parsePreparePercent(input),
19346
20212
  format: (value) => value === undefined || value === null ? "Auto" : String(value)
@@ -19549,7 +20415,7 @@ class InputSettingEditor extends Container3 {
19549
20415
  save;
19550
20416
  done;
19551
20417
  input = new Input;
19552
- status = new Text3("", 0, 0);
20418
+ status = new Text7("", 0, 0);
19553
20419
  saving = false;
19554
20420
  pending = Promise.resolve();
19555
20421
  get focused() {
@@ -19563,10 +20429,10 @@ class InputSettingEditor extends Container3 {
19563
20429
  this.requestRender = requestRender;
19564
20430
  this.save = save;
19565
20431
  this.done = done;
19566
- this.addChild(new Text3(setting.label, 0, 0));
19567
- this.addChild(new Text3(setting.description, 0, 0));
19568
- this.addChild(new Text3(`Allowed: ${setting.placeholder}`, 0, 0));
19569
- 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));
19570
20436
  this.input.setValue(initial);
19571
20437
  this.input.handleInput("\x1B[F");
19572
20438
  this.input.onSubmit = (value) => {
@@ -19594,9 +20460,9 @@ class InputSettingEditor extends Container3 {
19594
20460
  this.done();
19595
20461
  };
19596
20462
  this.addChild(this.input);
19597
- this.addChild(new Text3("", 0, 0));
20463
+ this.addChild(new Text7("", 0, 0));
19598
20464
  this.addChild(this.status);
19599
- this.addChild(new Text3("Enter save \xB7 Esc cancel", 0, 0));
20465
+ this.addChild(new Text7("Enter save \xB7 Esc cancel", 0, 0));
19600
20466
  }
19601
20467
  handleInput(data) {
19602
20468
  this.input.handleInput(data);
@@ -19611,7 +20477,7 @@ class ModelSettingEditor extends Container3 {
19611
20477
  requestRender;
19612
20478
  search = new Input;
19613
20479
  list;
19614
- status = new Text3("", 0, 0);
20480
+ status = new Text7("", 0, 0);
19615
20481
  saving = false;
19616
20482
  pending = Promise.resolve();
19617
20483
  get focused() {
@@ -19623,9 +20489,9 @@ class ModelSettingEditor extends Container3 {
19623
20489
  constructor(models, selectedValue, requestRender, save, done) {
19624
20490
  super();
19625
20491
  this.requestRender = requestRender;
19626
- this.addChild(new Text3("Type to search models", 0, 0));
20492
+ this.addChild(new Text7("Type to search models", 0, 0));
19627
20493
  this.addChild(this.search);
19628
- this.addChild(new Text3("", 0, 0));
20494
+ this.addChild(new Text7("", 0, 0));
19629
20495
  this.list = new SelectList3(models, 10, {
19630
20496
  selectedPrefix: (text) => text,
19631
20497
  selectedText: (text) => text,
@@ -19660,7 +20526,7 @@ class ModelSettingEditor extends Container3 {
19660
20526
  };
19661
20527
  this.addChild(this.list);
19662
20528
  this.addChild(this.status);
19663
- 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));
19664
20530
  }
19665
20531
  handleInput(data) {
19666
20532
  if (matchesKey3(data, Key3.up) || matchesKey3(data, Key3.down) || matchesKey3(data, Key3.enter) || matchesKey3(data, Key3.escape)) {
@@ -19899,7 +20765,6 @@ var THINKING_VALUES = [
19899
20765
  "max"
19900
20766
  ];
19901
20767
  var needsAutoTrigger = (config) => config.autoTrigger ? undefined : "turn on Automatic compaction first";
19902
- var needsBackgroundTiming = (config) => config.autoTrigger && config.autoTriggerStrategy === "background" ? undefined : "used only when Start when = Prepare in background";
19903
20768
  var needsMnemopi = (config) => config.memoryBackend === "mnemopi" ? undefined : "used only when Memory store = Mnemopi";
19904
20769
  var needsHindsight = (config) => config.memoryBackend === "hindsight" ? undefined : "used only when Memory store = Hindsight server";
19905
20770
  var needsBackups = (config) => config.backupEnabled ? undefined : "turn on Backups first";
@@ -19930,9 +20795,9 @@ var CATEGORIES = [
19930
20795
  labels: { "native-hook": "Before Pi's compaction", settled: "When idle", background: "Prepare in background" }
19931
20796
  }
19932
20797
  },
19933
- { kind: "input", setting: MIN_CONTEXT_SETTING, inactive: needsAutoTrigger },
19934
- { kind: "input", setting: MAX_CONTEXT_SETTING, inactive: needsAutoTrigger },
19935
- { 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 },
19936
20801
  {
19937
20802
  kind: "choice",
19938
20803
  setting: {
@@ -20047,7 +20912,7 @@ var CATEGORIES = [
20047
20912
  setting: {
20048
20913
  id: "toolLoading",
20049
20914
  label: "Agent tools",
20050
- 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.",
20051
20916
  values: ["lazy", "eager", "off"],
20052
20917
  labels: { lazy: "On demand", eager: "Always available", off: "Off" }
20053
20918
  }
@@ -20116,17 +20981,27 @@ var CATEGORIES = [
20116
20981
  {
20117
20982
  id: "hygiene",
20118
20983
  label: "Tool output cleanup",
20119
- 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",
20120
20985
  rows: [
20121
20986
  {
20122
20987
  kind: "choice",
20123
20988
  setting: {
20124
20989
  id: "contextHygieneEnabled",
20125
20990
  label: "Automatic cleanup",
20126
- 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.",
20127
20992
  values: BOOLEAN_VALUES
20128
20993
  }
20129
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
+ },
20130
21005
  {
20131
21006
  kind: "choice",
20132
21007
  setting: {
@@ -20656,16 +21531,16 @@ async function showSmartCompactSettings(ctx, policy, coordinator = new GlobalSet
20656
21531
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
20657
21532
  const root = createSettingsRoot(policy, ctx, done, () => tui.requestRender(), coordinator, onApplied, writeConfig);
20658
21533
  const container = new Container4;
20659
- container.addChild(new Text4(theme.fg("accent", theme.bold("Smart Compact Settings")), 0, 0));
20660
- container.addChild(new Text4(theme.fg("dim", "Changes are saved for all projects; This branch only changes just this branch."), 0, 0));
20661
- 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));
20662
21537
  const invalid = invalidSettingsLine();
20663
21538
  if (invalid)
20664
- container.addChild(new Text4(theme.fg("warning", invalid), 0, 0));
20665
- container.addChild(new Text4("", 0, 0));
21539
+ container.addChild(new Text8(theme.fg("warning", invalid), 0, 0));
21540
+ container.addChild(new Text8("", 0, 0));
20666
21541
  container.addChild(root);
20667
- container.addChild(new Text4("", 0, 0));
20668
- 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));
20669
21544
  return createSettingsController(root, container, () => tui.requestRender());
20670
21545
  });
20671
21546
  }
@@ -20691,7 +21566,7 @@ var BEHAVIOR_PROFILES = [
20691
21566
  {
20692
21567
  id: "cleanup",
20693
21568
  label: "Cleanup only",
20694
- 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.",
20695
21570
  patch: { autoTrigger: false, contextHygieneEnabled: true, agentToolAccess: "disabled" }
20696
21571
  },
20697
21572
  {
@@ -20712,11 +21587,11 @@ var AGENT_WORDS = {
20712
21587
  disabled: "not allowed"
20713
21588
  };
20714
21589
  function deriveBehaviorProfile(config) {
20715
- 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) {
20716
21591
  return {
20717
21592
  id: "custom",
20718
- label: "With Pi (default)",
20719
- 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."
20720
21595
  };
20721
21596
  }
20722
21597
  const found = derive(BEHAVIOR_PROFILES, ({ patch }) => Object.entries(patch).every(([key, value]) => config[key] === value), "Custom");
@@ -20921,7 +21796,7 @@ function createHomeList(options, finish, requestRender) {
20921
21796
  id: "trim",
20922
21797
  label: "Clean up tool output",
20923
21798
  currentValue: "no model call",
20924
- 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."
20925
21800
  };
20926
21801
  const setup = {
20927
21802
  id: "setup",
@@ -21162,7 +22037,7 @@ async function showSmartCompactHome(options) {
21162
22037
  return options.ctx.ui.custom((tui, theme, _keybindings, done) => {
21163
22038
  const list = createHomeList(options, done, () => tui.requestRender());
21164
22039
  const container = new Container5;
21165
- 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));
21166
22041
  container.addChild({
21167
22042
  render(width) {
21168
22043
  const config = loadConfig();
@@ -21178,15 +22053,15 @@ async function showSmartCompactHome(options) {
21178
22053
  });
21179
22054
  const invalid = invalidSettingsLine();
21180
22055
  if (invalid)
21181
- container.addChild(new Text5(theme.fg("warning", invalid), 0, 0));
21182
- container.addChild(new Text5("", 0, 0));
22056
+ container.addChild(new Text9(theme.fg("warning", invalid), 0, 0));
22057
+ container.addChild(new Text9("", 0, 0));
21183
22058
  container.addChild(list);
21184
22059
  return createSettingsController(list, container, () => tui.requestRender());
21185
22060
  });
21186
22061
  }
21187
22062
 
21188
22063
  // src/ui/metrics-dashboard-overlay.ts
21189
- 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";
21190
22065
  async function showMetricsDashboardUI(ctx, opts) {
21191
22066
  const entries = opts.entries;
21192
22067
  const latest = entries.at(-1);
@@ -21281,23 +22156,23 @@ async function showMetricsDashboardUI(ctx, opts) {
21281
22156
  return theme.fg(insights.quality.targetMet ? "success" : "warning", " \u2022 Quality " + insights.quality.healthScore + "/100");
21282
22157
  };
21283
22158
  const renderHeader = (width) => [
21284
- truncateToWidth4(theme.fg("accent", theme.bold(" \uD83D\uDCCA Smart Compact Dashboard")) + theme.fg("dim", " " + entries.length + " recorded run(s)"), width),
21285
- 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),
21286
- 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)
21287
22162
  ];
21288
22163
  return {
21289
22164
  render(width) {
21290
22165
  const lines = renderHeader(width);
21291
22166
  if (view === "menu") {
21292
- lines.push(truncateToWidth4(theme.fg("text", " Choose what to inspect:"), width), "");
22167
+ lines.push(truncateToWidth5(theme.fg("text", " Choose what to inspect:"), width), "");
21293
22168
  for (let index = 0;index < menuItems.length; index++) {
21294
22169
  const item = menuItems[index];
21295
22170
  const active = index === selected;
21296
22171
  const label = active ? theme.fg("accent", theme.bold(item.label)) : theme.fg("text", item.label);
21297
- lines.push(truncateToWidth4((active ? " \u203A " : " ") + label, width));
21298
- 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));
21299
22174
  }
21300
- 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));
21301
22176
  return lines;
21302
22177
  }
21303
22178
  const content = pageLines();
@@ -21310,12 +22185,12 @@ async function showMetricsDashboardUI(ctx, opts) {
21310
22185
  styled = theme.fg("accent", theme.bold(line));
21311
22186
  else if (line.startsWith("-"))
21312
22187
  styled = theme.fg("dim", line);
21313
- lines.push(truncateToWidth4(" " + styled, width));
22188
+ lines.push(truncateToWidth5(" " + styled, width));
21314
22189
  }
21315
22190
  if (content.length > pageSize) {
21316
- 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));
21317
22192
  }
21318
- 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));
21319
22194
  return lines;
21320
22195
  },
21321
22196
  invalidate() {},
@@ -21377,7 +22252,7 @@ import {
21377
22252
  import {
21378
22253
  Container as Container6,
21379
22254
  Input as Input2,
21380
- Text as Text6,
22255
+ Text as Text10,
21381
22256
  wrapTextWithAnsi as wrapTextWithAnsi4
21382
22257
  } from "@earendil-works/pi-tui";
21383
22258
  var PAGE_SIZE = 8;
@@ -21486,7 +22361,7 @@ class Framed {
21486
22361
 
21487
22362
  class Prompt extends Container6 {
21488
22363
  input;
21489
- status = new Text6("", 0, 0);
22364
+ status = new Text10("", 0, 0);
21490
22365
  busy = false;
21491
22366
  get focused() {
21492
22367
  return this.input.focused;
@@ -21498,8 +22373,8 @@ class Prompt extends Container6 {
21498
22373
  super();
21499
22374
  this.input = new Input2;
21500
22375
  for (const line of lines)
21501
- this.addChild(new Text6(line, 0, 0));
21502
- this.addChild(new Text6("", 0, 0));
22376
+ this.addChild(new Text10(line, 0, 0));
22377
+ this.addChild(new Text10("", 0, 0));
21503
22378
  this.input.setValue(initial);
21504
22379
  this.input.handleInput("\x1B[F");
21505
22380
  this.input.onSubmit = (value) => {
@@ -21511,9 +22386,9 @@ class Prompt extends Container6 {
21511
22386
  cancel();
21512
22387
  };
21513
22388
  this.addChild(this.input);
21514
- this.addChild(new Text6("", 0, 0));
22389
+ this.addChild(new Text10("", 0, 0));
21515
22390
  this.addChild(this.status);
21516
- this.addChild(new Text6(hint, 0, 0));
22391
+ this.addChild(new Text10(hint, 0, 0));
21517
22392
  }
21518
22393
  setStatus(text, busy = false) {
21519
22394
  this.status.setText(text);
@@ -21528,7 +22403,7 @@ class TextArea {
21528
22403
  open;
21529
22404
  submit;
21530
22405
  editor;
21531
- status = new Text6("", 0, 0);
22406
+ status = new Text10("", 0, 0);
21532
22407
  busy = false;
21533
22408
  active = false;
21534
22409
  constructor(open, submit) {
@@ -22270,11 +23145,13 @@ async function describeEffectiveState(ctx, config, policy, runtime) {
22270
23145
  lines.push("Agent tool: " + (policy.agentToolEnabled ? "exposed" : "not exposed") + " (policy " + policy.agentToolAccess + ")");
22271
23146
  if (ctx.model && !ctx.model.promptCache)
22272
23147
  lines.push("Reader prompt-cache lifetime: not declared; host cache warming cannot schedule from this metadata. No cache savings are assumed.");
22273
- const window = ctx.model?.contextWindow;
22274
- const gateWindow = effectiveContextWindow(ctx.model, config);
22275
- const tokens = ctx.getContextUsage()?.tokens;
22276
- if (typeof window === "number" && Number.isFinite(window) && window > 0 && typeof gateWindow === "number") {
22277
- 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) + "%"));
22278
23155
  const apply = Math.ceil(Math.max(MIN_TOKEN_THRESHOLD, gates.applyTokens)).toLocaleString("en-US");
22279
23156
  const gate = " (" + config.minContextPercent + "% of " + (gateWindow < window ? "maxContextTokens " : "window ") + gateWindow.toLocaleString("en-US") + ", floor " + MIN_TOKEN_THRESHOLD.toLocaleString("en-US") + ")";
22280
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));
@@ -22286,9 +23163,9 @@ async function describeEffectiveState(ctx, config, policy, runtime) {
22286
23163
  lines.push("Runtime: " + (runtime ? (runtime.running ? "running" : "idle") + "; preparation " + runtime.preparation : "not attached") + "; candidate reuse is revalidated at apply");
22287
23164
  if (runtime?.cacheLedger?.length)
22288
23165
  lines.push(...runtime.cacheLedger);
22289
- 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");
22290
23167
  const backgroundHygiene = policy.autoTrigger && config.autoTriggerStrategy === "background";
22291
- 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" : ""));
22292
23169
  const memory = await describeMemoryBackendReadiness(config);
22293
23170
  lines.push("Memory selected: " + config.memoryBackend + (projectId ? " (project-bound)" : " \u2014 unavailable: no project scope"));
22294
23171
  lines.push("Memory readiness: " + memory.reason);
@@ -22530,7 +23407,8 @@ async function showStorage(ctx) {
22530
23407
  function compactNowState(ctx, dependencies) {
22531
23408
  const config = loadConfig();
22532
23409
  const tokens = ctx.getContextUsage()?.tokens;
22533
- 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" : "");
22534
23412
  const runtime = dependencies.getRuntimeState?.(ctx);
22535
23413
  if (runtime?.running)
22536
23414
  return { value, blocked: "Compaction is already running. Wait for it to finish." };
@@ -22942,6 +23820,7 @@ function createSmartCompactPolicy(pi, syncTools) {
22942
23820
 
22943
23821
  // src/app/lazy-tools.ts
22944
23822
  import { StringEnum as StringEnum3 } from "@earendil-works/pi-ai";
23823
+ import { Text as Text11 } from "@earendil-works/pi-tui";
22945
23824
  import { Type as Type5 } from "typebox";
22946
23825
 
22947
23826
  // src/app/context-guide.ts
@@ -23110,33 +23989,127 @@ function registerContextToolLoader(pi, exposure) {
23110
23989
  if (loadConfig().toolLoading === "off" || !pi.getActiveTools().includes(LOADER))
23111
23990
  throw new Error("Context tools are disabled in Pi settings or /tools.");
23112
23991
  let text;
23992
+ let details = undefined;
23113
23993
  if (params.action === "guide")
23114
23994
  text = await readContextGuide();
23115
- else if (params.action === "status")
23116
- text = JSON.stringify(exposure.status());
23117
- 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 {
23118
24000
  if (!params.group)
23119
24001
  throw new Error(`${params.action} requires a group.`);
23120
- if (params.action === "load")
23121
- text = `Loaded: ${exposure.load(params.group).join(", ")}. Available on the next model turn; no feature instructions were injected.`;
23122
- 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 {
23123
24007
  if (loadConfig().toolLoading === "eager")
23124
24008
  throw new Error("Eager mode keeps tools loaded. Select On demand or Off in /smart-compact settings before unloading a group.");
23125
24009
  exposure.unload(params.group);
23126
24010
  text = `Unloaded ${params.group}. No files or session history changed.`;
24011
+ details = { display: { state: "unloaded", group: params.group } };
23127
24012
  }
23128
24013
  }
23129
- 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);
23130
24024
  }
23131
24025
  });
23132
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
+ }
23133
24061
 
23134
24062
  // src/app/register-navigation.ts
23135
24063
  import { randomUUID as randomUUID5 } from "crypto";
23136
24064
  import path26 from "path";
23137
24065
  import { StringEnum as StringEnum4 } from "@earendil-works/pi-ai";
23138
24066
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
24067
+ import { Text as Text12 } from "@earendil-works/pi-tui";
23139
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
+ }
23140
24113
  var STATUS = "smart-context-navigation";
23141
24114
  var APPLY_PREFIX = "context --apply=";
23142
24115
  function registerNavigation(pi, options = {}) {
@@ -23148,7 +24121,7 @@ function registerNavigation(pi, options = {}) {
23148
24121
  const settings = config();
23149
24122
  return new SecretScrubber(settings.scrubSecrets, settings.scrubPii).scrubText(text).value;
23150
24123
  };
23151
- const reply = (text) => ({ content: [{ type: "text", text: scrub(text) }], details: undefined });
24124
+ const reply = (text, details = undefined) => ({ content: [{ type: "text", text: scrub(text) }], details });
23152
24125
  const enabled = () => {
23153
24126
  if (!config().contextNavigationEnabled)
23154
24127
  throw new Error("Context navigation is disabled in Pi Continuity settings.");
@@ -23171,10 +24144,10 @@ function registerNavigation(pi, options = {}) {
23171
24144
  const branch = ctx.sessionManager.getBranch();
23172
24145
  const index = branch.findLastIndex((entry) => anchorFromEntry(entry) !== null);
23173
24146
  const anchor = index < 0 ? null : anchorFromEntry(branch[index]);
23174
- const percent = ctx.getContextUsage()?.percent;
24147
+ const pressure = contextPressure(ctx, config());
23175
24148
  const parts = [];
23176
- if (typeof percent === "number" && Number.isFinite(percent))
23177
- 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` : ""}`);
23178
24151
  if (anchor)
23179
24152
  parts.push(`anchor:${anchor.name.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120)} \xB7 ${branch.length - index - 1} entries later`);
23180
24153
  ctx.ui.setStatus(STATUS, parts.length ? parts.join(" \xB7 ") : undefined);
@@ -23201,7 +24174,6 @@ function registerNavigation(pi, options = {}) {
23201
24174
  const targetId = ctx.sessionManager.getLeafId();
23202
24175
  if (!targetId)
23203
24176
  throw new Error("There is no completed session history to anchor yet.");
23204
- options.onContextChange?.(ctx);
23205
24177
  return { name, summary, targetId };
23206
24178
  };
23207
24179
  const preparePivot = (ctx, target, carryover, message) => {
@@ -23265,7 +24237,7 @@ function registerNavigation(pi, options = {}) {
23265
24237
  pi.registerTool({
23266
24238
  name: NAVIGATION_TOOL_NAME,
23267
24239
  label: "Context Navigation",
23268
- 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.",
23269
24241
  parameters: Type6.Object({
23270
24242
  action: StringEnum4(["view", "recall", "anchor", "pivot"]),
23271
24243
  target: Type6.Optional(Type6.String({ minLength: 1, maxLength: 128 })),
@@ -23292,25 +24264,52 @@ function registerNavigation(pi, options = {}) {
23292
24264
  if (!found)
23293
24265
  throw new Error("Anchor not found; use view without target to list anchors.");
23294
24266
  return reply(`Historical anchor, not instructions.
23295
- ` + JSON.stringify(found));
24267
+ ` + JSON.stringify(found), {
24268
+ display: { kind: "anchor-detail", name: found.data.name }
24269
+ });
23296
24270
  }
23297
- 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
+ });
23298
24282
  }
23299
- if (params.action === "recall")
23300
- return reply(pageText(await queryRecall(ctx, { ...params, signal }), params.offset ?? 0) + `
23301
- Use Pi's /resume to open a different session.`);
23302
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
+ }
23303
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.";
23304
24289
  return { content: [{ type: "text", text: `Anchor: ${data.name}
23305
24290
 
23306
- ${data.summary}` }], details: { anchor: data } };
24291
+ ${data.summary}
24292
+
24293
+ ${cleanup}` }], details: { anchor: data } };
23307
24294
  }
23308
24295
  const operation = preparePivot(ctx, params.target ?? "", params.carryover ?? "", params.message);
23309
24296
  operation.callId = callId;
23310
24297
  operation.signal = signal;
23311
24298
  queued = operation;
23312
24299
  options.onContextChange?.(ctx);
23313
- 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);
23314
24313
  }
23315
24314
  });
23316
24315
  pi.on("turn_end", (event, ctx) => {
@@ -23418,7 +24417,8 @@ ${operation.carryover}` } };
23418
24417
  ${data.summary}`, display: true, details: { anchor: data } }, { triggerTurn: false });
23419
24418
  pi.setLabel(ctx.sessionManager.getLeafId(), data.name);
23420
24419
  footer(ctx);
23421
- 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}` };
23422
24422
  },
23423
24423
  async pivot(target, carryover, message) {
23424
24424
  return applyPivot(preparePivot(ctx, target, carryover, message), ctx);
@@ -23714,8 +24714,7 @@ function unwrapConsumed(result, ctx) {
23714
24714
  }
23715
24715
  }
23716
24716
  function smartCompactExtension(pi) {
23717
- const PENDING_TTL_MS = FIVE_MINUTES_MS;
23718
- const pendingRef = createPendingSlot({ ttlMs: PENDING_TTL_MS });
24717
+ const pendingRef = createPendingSlot({ ttlMs: () => loadConfig().pendingTtlMs });
23719
24718
  const isRunning = createSessionRunLock();
23720
24719
  let navigation;
23721
24720
  const pivotQueued = (ctx) => navigation?.isPending(ctx) ?? false;
@@ -23733,14 +24732,19 @@ function smartCompactExtension(pi) {
23733
24732
  showStatus: policy.branchOverrides().showStatus ?? config.showStatus
23734
24733
  };
23735
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
+ };
23736
24739
  const background = createBackgroundPreparation({
24740
+ toolSignature: () => JSON.stringify(activeToolDefinitions()),
23737
24741
  async prepare(ctx, config, signal) {
23738
24742
  if (signal.aborted || isRunning.isSessionActive(resolveSessionId(ctx)))
23739
24743
  return null;
23740
24744
  const { sumModel, segModel, verifyModel } = resolveModels(ctx, undefined, config);
23741
24745
  if (!sumModel || !segModel || !verifyModel)
23742
24746
  return null;
23743
- const localPending = createPendingSlot({ ttlMs: FIVE_MINUTES_MS });
24747
+ const localPending = createPendingSlot({ ttlMs: config.pendingTtlMs });
23744
24748
  const outcome = await runSmartCompact({
23745
24749
  ctx,
23746
24750
  config,
@@ -23780,10 +24784,7 @@ function smartCompactExtension(pi) {
23780
24784
  }
23781
24785
  };
23782
24786
  const nativeContinuity = createNativeContinuityBridge();
23783
- setNativeToolSource(() => {
23784
- const active = new Set(pi.getActiveTools());
23785
- return pi.getAllTools().filter((tool) => active.has(tool.name)).map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
23786
- });
24787
+ setNativeToolSource(activeToolDefinitions);
23787
24788
  registerAnchorCache(pi, { config: loadConfig });
23788
24789
  const nativeReplay = createNativeReplayHook();
23789
24790
  pi.on("before_provider_request", (event, ctx) => nativeReplay.handle(event.payload, ctx));
@@ -23807,7 +24808,7 @@ function smartCompactExtension(pi) {
23807
24808
  return write;
23808
24809
  };
23809
24810
  const commitCandidates = createCompactionCommitStore({
23810
- ttlMs: loadConfig().pendingTtlMs,
24811
+ ttlMs: () => loadConfig().pendingTtlMs,
23811
24812
  onDiscard: (pending, reason) => {
23812
24813
  recordApplyFailure(pending, reason);
23813
24814
  }
@@ -23893,6 +24894,7 @@ function smartCompactExtension(pi) {
23893
24894
  navigation = registerNavigation(pi, {
23894
24895
  config: automaticConfig,
23895
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,
23896
24898
  onContextChange: (ctx) => invalidatePreparation(ctx, "branch"),
23897
24899
  onContextEdit: (_ctx, kind) => hostCache.noteContextEdit(kind)
23898
24900
  });
@@ -24206,10 +25208,10 @@ function smartCompactExtension(pi) {
24206
25208
  if (!hostCache.observe(event.message)?.warn)
24207
25209
  return;
24208
25210
  const { foreign } = hostCache.summary().rebuilds;
24209
- reportIssue({
25211
+ recordIssue({
24210
25212
  key: "cache.foreign-rebuilds",
24211
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."
24212
- }, ctx);
25214
+ });
24213
25215
  });
24214
25216
  pi.on("message_end", async (event, ctx) => {
24215
25217
  try {
@@ -24251,7 +25253,6 @@ function smartCompactExtension(pi) {
24251
25253
  });
24252
25254
  pi.on("session_compact", (_event, ctx) => {
24253
25255
  nativeReplay.refresh(ctx);
24254
- toolExposure.atBoundary();
24255
25256
  policy.restore(ctx);
24256
25257
  navigation.refresh(ctx);
24257
25258
  });