pi-smart-compact 10.2.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2.0";
8
+ var VERSION = "10.3.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" },
@@ -2157,6 +2157,8 @@ function extractToolPath(args) {
2157
2157
  return;
2158
2158
  }
2159
2159
  function isInstructionSource(args) {
2160
+ if (typeof args.qualified_name === "string" && /(?:^|[./])(?:AGENTS|CLAUDE|GEMINI|SKILL|copilot-instructions|skills?)(?:[./]|$)/i.test(args.qualified_name))
2161
+ return true;
2160
2162
  return PATH_KEYS.some((key) => {
2161
2163
  const source = typeof args[key] === "string" ? args[key].replaceAll("\\", "/") : "";
2162
2164
  return /(?:^|\/)(?:AGENTS|CLAUDE|GEMINI|SKILL|copilot-instructions)\.md$/i.test(source) || /(?:^|\/)skills?\//i.test(source);
@@ -2176,7 +2178,24 @@ var READ_ONLY_TOOLS = new Set([
2176
2178
  "read_url_content",
2177
2179
  "find_text_in_url_content",
2178
2180
  "summarize_url_content",
2179
- "get_search_content"
2181
+ "get_search_content",
2182
+ "fetch_content",
2183
+ "lens_diagnostics",
2184
+ "effective_config",
2185
+ "ast_grep_search",
2186
+ "search_graph",
2187
+ "trace_path",
2188
+ "get_code_snippet",
2189
+ "get_file_outline",
2190
+ "query_graph",
2191
+ "get_architecture",
2192
+ "search_code",
2193
+ "get_graph_schema",
2194
+ "index_status",
2195
+ "check_index_coverage",
2196
+ "detect_changes",
2197
+ "compare_graphs",
2198
+ "list_projects"
2180
2199
  ]);
2181
2200
  function isReadOnlyResearchTool(toolName, input) {
2182
2201
  if (isInstructionSource(input))
@@ -9304,6 +9323,7 @@ async function attemptNativeCompaction(rc, priorAttempts) {
9304
9323
  };
9305
9324
  }
9306
9325
  const prefix = rc.msgs.slice(0, cut);
9326
+ const messages = prefix.map((entry) => entry.message);
9307
9327
  const prefixTokens = prefix.reduce((sum, entry) => sum + rc.estimator.message(entry.message), 0);
9308
9328
  const totalEstimate = rc.msgs.reduce((sum, entry) => sum + rc.estimator.message(entry.message), 0);
9309
9329
  const retainedTailTokens = Math.max(0, totalEstimate - prefixTokens);
@@ -9337,9 +9357,11 @@ async function attemptNativeCompaction(rc, priorAttempts) {
9337
9357
  nestedRequests++;
9338
9358
  try {
9339
9359
  const stream = rc.ctx.modelRegistry.streamSimple(model, {
9340
- systemPrompt: rc.ctx.getSystemPrompt(),
9341
- messages: prefix.map((entry) => entry.message),
9342
- tools: toolSource()
9360
+ messages,
9361
+ ...messages[0]?.role === "system" ? {} : {
9362
+ systemPrompt: rc.ctx.getSystemPrompt(),
9363
+ tools: toolSource()
9364
+ }
9343
9365
  }, {
9344
9366
  signal: rc.cancellation.signal,
9345
9367
  fetch: wrapper.fetch,
@@ -13232,7 +13254,7 @@ function eligible(event) {
13232
13254
  if (event.isError || !event.content.length || event.content.some((block) => block.type !== "text") || Array.isArray(event.details) || event.details !== undefined && event.details !== null && !isRecord2(event.details) || isRecord2(event.details) && Object.hasOwn(event.details, ARTIFACT_DETAILS_KEY))
13233
13255
  return false;
13234
13256
  const tool = normalizeToolName(event.toolName);
13235
- if (["read", "read_symbol", "read_enclosing", "smart_context"].includes(tool) || !isReadOnlyResearchTool(tool, event.input))
13257
+ if (["read", "read_symbol", "read_enclosing", "get_code_snippet", "smart_context"].includes(tool) || !isReadOnlyResearchTool(tool, event.input))
13236
13258
  return false;
13237
13259
  return true;
13238
13260
  }
@@ -13310,6 +13332,7 @@ function branchToolArtifacts(branch, ownedReferences) {
13310
13332
  // src/app/context-operations.ts
13311
13333
  var CONTEXT_CONTROL_TYPE = "smart-compact-context";
13312
13334
  var CONTEXT_REPORT_TYPE = "smart-compact-rewind";
13335
+ var ATTENTION_CUSTOM_TYPE = "smart-compact-attention";
13313
13336
  var MAX_CONTEXT_EDITS = 512;
13314
13337
  var MAX_TRIM_EDITS = 32;
13315
13338
  var MIN_TRIM_CHARS = 4096;
@@ -13659,8 +13682,8 @@ function planContextRewind(branch, sessionId, checkpointId, report, readerApi) {
13659
13682
  if (!state.checkpoint || state.checkpoint.id !== checkpointId) {
13660
13683
  throw new Error(state.invalidReason ?? "No matching active checkpoint; create one before research.");
13661
13684
  }
13662
- const suffixIds = new Set(branch.slice(state.checkpointIndex + 1).map((entry) => entry.id));
13663
- const targets = [...removableResearch(branch)].filter((id) => suffixIds.has(id));
13685
+ const research = removableResearch(branch);
13686
+ const targets = branch.slice(state.checkpointIndex + 1).filter((entry) => research.has(entry.id) || entry.type === "custom_message" && entry.customType === ATTENTION_CUSTOM_TYPE).map((entry) => entry.id);
13664
13687
  if (targets.length > MAX_CONTEXT_EDITS)
13665
13688
  throw new Error("Research exceeds the rewind edit limit; compact instead.");
13666
13689
  const targetSet = new Set(targets);
@@ -16808,8 +16831,8 @@ function createSettledAutoTrigger(options = {}) {
16808
16831
  }
16809
16832
 
16810
16833
  // src/app/context-attention.ts
16811
- var ATTENTION_CUSTOM_TYPE = "smart-compact-attention";
16812
16834
  var CONTEXT_TOOL_NAME = "smart_context";
16835
+ var COMPACTION_TOOL_NAME = "smart_compact";
16813
16836
  var LOADER_TOOL_NAME = "smart_tools";
16814
16837
  function registerContextAttention(pi, deps) {
16815
16838
  let noted = null;
@@ -16820,12 +16843,12 @@ function registerContextAttention(pi, deps) {
16820
16843
  pi.on("session_before_switch", clear);
16821
16844
  pi.on("session_before_fork", clear);
16822
16845
  pi.on("session_tree", clear);
16823
- pi.on("message_end", (event, ctx) => {
16824
- const message = event.message;
16825
- if (message.role !== "assistant")
16826
- return;
16846
+ pi.on("session_compact", clear);
16847
+ pi.on("model_select", clear);
16848
+ pi.on("session_shutdown", clear);
16849
+ const attention = (ctx) => {
16827
16850
  const settings = deps.config();
16828
- if (!settings.contextNavigationEnabled || !settings.contextGuidanceEnabled || settings.toolLoading === "off")
16851
+ if (!settings.contextGuidanceEnabled || settings.toolLoading === "off")
16829
16852
  return;
16830
16853
  const sessionId = resolveSessionId(ctx);
16831
16854
  const pressure = contextPressure(ctx, settings);
@@ -16837,51 +16860,84 @@ function registerContextAttention(pi, deps) {
16837
16860
  }
16838
16861
  if (noted?.sessionId === sessionId && (noted.band === band || noted.band === "compaction"))
16839
16862
  return;
16840
- if (message.content.some((block) => block.type === "toolCall" && (block.name === NAVIGATION_TOOL_NAME || block.name === CONTEXT_TOOL_NAME)))
16841
- return;
16842
- const midTurn = message.stopReason === "toolUse";
16843
- if (!midTurn && band === "compaction" && settings.autoTrigger)
16844
- return;
16845
16863
  if (!deps.canAgentAct(ctx))
16846
16864
  return;
16847
16865
  const active = new Set(pi.getActiveTools());
16848
- const content = renderAttention(band, pressure.percent, settings, ctx, active, deps.reachable);
16866
+ const content = renderAttention(band, pressure.percent, settings, ctx, active, deps.reachable, deps.canCleanup(ctx));
16849
16867
  if (!content)
16850
16868
  return;
16851
16869
  noted = { sessionId, band };
16852
- pi.sendMessage({ customType: ATTENTION_CUSTOM_TYPE, content, display: true, details: { band, percent: pressure.percent } }, { deliverAs: midTurn ? "steer" : "nextTurn" });
16870
+ return { customType: ATTENTION_CUSTOM_TYPE, content, display: true, details: { band, percent: pressure.percent } };
16871
+ };
16872
+ pi.on("before_agent_start", (_event, ctx) => {
16873
+ const message = attention(ctx);
16874
+ if (message)
16875
+ return { message };
16876
+ });
16877
+ pi.on("message_end", (event, ctx) => {
16878
+ const message = event.message;
16879
+ if (message.role !== "assistant" || message.stopReason !== "toolUse")
16880
+ return;
16881
+ if (message.content.some((block) => block.type === "toolCall" && [NAVIGATION_TOOL_NAME, CONTEXT_TOOL_NAME, COMPACTION_TOOL_NAME].includes(block.name)))
16882
+ return;
16883
+ const note = attention(ctx);
16884
+ if (note)
16885
+ pi.sendMessage(note, { deliverAs: "steer" });
16853
16886
  });
16854
16887
  }
16855
- function renderAttention(band, percent, settings, ctx, active, reachable) {
16888
+ function renderAttention(band, percent, settings, ctx, active, reachable, canCleanup) {
16856
16889
  const lines = [];
16857
16890
  const load = (group, tool) => active.has(tool) ? "" : reachable(group) && active.has(LOADER_TOOL_NAME) ? ` Load it first: ${LOADER_TOOL_NAME} load ${group}.` : null;
16858
- const navigation = load("navigation", NAVIGATION_TOOL_NAME);
16891
+ const navigation = settings.contextNavigationEnabled ? load("navigation", NAVIGATION_TOOL_NAME) : null;
16859
16892
  const history = load("history", CONTEXT_TOOL_NAME);
16860
- if (navigation !== null) {
16861
- lines.push((band === "compaction" ? `- Anchor now: ${NAVIGATION_TOOL_NAME} anchor with a name and a concise handoff (done / in progress / next step). The summary starts from it and the prompt cache stays warm up to it.` : `- Finishing a unit of work? ${NAVIGATION_TOOL_NAME} anchor with a concise handoff; safe cleanup is queued automatically and the prefix up to the anchor stays cached.`) + navigation);
16862
- }
16893
+ let checkpointLabel;
16894
+ let trimmable = 0;
16863
16895
  if (history !== null) {
16864
- const branch = ctx.sessionManager.getBranch();
16865
- let trimmable = 0;
16866
- let blocked = false;
16867
16896
  try {
16868
- const plan = planContextTrim(branch, inspectContext(branch, resolveSessionId(ctx)).checkpoint?.originId, { readerApi: ctx.model?.api });
16869
- blocked = Boolean(plan.blockedReason);
16870
- trimmable = plan.entries.filter((entry) => entry.type === "context_edit").length;
16871
- } catch {
16872
- blocked = true;
16873
- }
16874
- if (!blocked) {
16897
+ const branch = ctx.sessionManager.getBranch();
16898
+ const checkpoint = inspectContext(branch, resolveSessionId(ctx)).checkpoint;
16899
+ checkpointLabel = checkpoint?.label;
16900
+ if (canCleanup && !checkpoint) {
16901
+ const plan = planContextTrim(branch, undefined, { readerApi: ctx.model?.api });
16902
+ if (!plan.blockedReason)
16903
+ trimmable = plan.entries.filter((entry) => entry.type === "context_edit").length;
16904
+ }
16905
+ } catch {}
16906
+ }
16907
+ if (navigation !== null && checkpointLabel === undefined) {
16908
+ lines.push(`- Finishing a unit of work? ${NAVIGATION_TOOL_NAME} anchor with a concise handoff (done / in progress / next step). An anchor alone does not reduce context; cleanup depends on safety and preparation gates.${navigation}`);
16909
+ }
16910
+ if (history !== null) {
16911
+ if (checkpointLabel !== undefined) {
16912
+ const next = canCleanup ? `Finish with ${CONTEXT_TOOL_NAME} rewind(report) before implementation or your final answer. No pressure required; safety checks still apply.` : "Prepared/running compaction or unavailable recovery takes priority. Preserve findings and check status; do not replace the checkpoint or retry in a loop.";
16913
+ lines.push(`- Research checkpoint ${JSON.stringify(checkpointLabel)} is active. ${next}${history}`);
16914
+ } else {
16875
16915
  if (trimmable > 0)
16876
16916
  lines.push(`- ${CONTEXT_TOOL_NAME} trim: ${trimmable} archived tool output${trimmable === 1 ? "" : "s"} eligible now.${history}`);
16877
- lines.push(`- Starting a large read-only detour? ${CONTEXT_TOOL_NAME} checkpoint first, then rewind(report) when done keeps the findings and drops the research.${trimmable > 0 ? "" : history}`);
16917
+ lines.push(`- Starting a large read-only detour? ${CONTEXT_TOOL_NAME} checkpoint first; rewind(report) afterward before implementation or your final answer. No pressure required; safety checks still apply. Keep findings and evidence in the report.${trimmable > 0 ? "" : history}`);
16878
16918
  }
16879
16919
  }
16920
+ if (band === "compaction" && !settings.autoTrigger) {
16921
+ const compaction = load("compaction", COMPACTION_TOOL_NAME);
16922
+ if (compaction !== null)
16923
+ lines.push(`- ${COMPACTION_TOOL_NAME} stages a summary (or reports an existing one); the user runs /compact within its staging TTL to apply it. Staging does not shrink context.${compaction}`);
16924
+ }
16880
16925
  if (!lines.length)
16881
16926
  return null;
16882
16927
  const where = percent === null ? "" : ` ${Math.round(percent)}% of the policy window`;
16883
- const head = band === "compaction" ? `Context attention:${where} (compaction band). ` + (settings.autoTrigger ? "Compaction prepares now and applies when the turn settles." : "Automatic compaction is off here; the user runs /smart-compact.") : `Context attention:${where} (cleanup band). ` + (settings.autoTrigger ? `Automatic compaction prepares at ${settings.minContextPercent}%.` : "Automatic compaction is off here.");
16884
- return `${head}
16928
+ let automatic;
16929
+ if (!settings.autoTrigger)
16930
+ automatic = "Automatic compaction is off here; the user can run /smart-compact.";
16931
+ else if (settings.autoTriggerStrategy === "native-hook")
16932
+ automatic = "Preparation runs when Pi requests compaction; this strategy has no settled trigger.";
16933
+ else if (band === "compaction")
16934
+ automatic = "Automatic compaction is requested when the turn settles if pressure remains.";
16935
+ else {
16936
+ automatic = `Automatic compaction is requested at ${settings.minContextPercent}% when the turn settles.`;
16937
+ if (settings.autoTriggerStrategy === "background")
16938
+ automatic = "Background preparation can start in the cleanup band. " + automatic;
16939
+ }
16940
+ return `Context attention:${where} (${band} band). ${automatic}
16885
16941
  ${lines.join(`
16886
16942
  `)}`;
16887
16943
  }
@@ -16890,19 +16946,26 @@ ${lines.join(`
16890
16946
  var FOREIGN_REBUILD_WARN_COUNT = 3;
16891
16947
  var num = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
16892
16948
  var tallies = (keys) => Object.fromEntries(keys.map((key) => [key, { count: 0, uncached: 0 }]));
16893
- var cacheLifetimeMs = (usage) => num(usage?.cacheWrite1h) > 0 ? ONE_HOUR_MS : FIVE_MINUTES_MS;
16949
+ function cacheLifetimeMs(usage, promptCache) {
16950
+ const tiers = [promptCache?.short, promptCache?.long].filter((value) => value !== undefined);
16951
+ if (!tiers.length || tiers.some((value) => !Number.isFinite(value) || value <= 0 || !Number.isFinite(value * 1000)))
16952
+ return null;
16953
+ return Math.max(...tiers.map((value) => value * 1000), num(usage?.cacheWrite1h) > 0 ? ONE_HOUR_MS : 0);
16954
+ }
16894
16955
  function createHostCacheLedger() {
16895
16956
  let sessionId = null;
16896
16957
  let summary;
16897
16958
  let previousAt;
16898
16959
  let warmedAt;
16899
- let lifetimeMs = FIVE_MINUTES_MS;
16960
+ let lifetimeMs = null;
16961
+ let previousRoute;
16900
16962
  let pendingEdit;
16901
16963
  const reset = (id) => {
16902
16964
  sessionId = id;
16903
16965
  previousAt = undefined;
16904
16966
  warmedAt = undefined;
16905
- lifetimeMs = FIVE_MINUTES_MS;
16967
+ lifetimeMs = null;
16968
+ previousRoute = undefined;
16906
16969
  pendingEdit = undefined;
16907
16970
  summary = {
16908
16971
  sessionId: id,
@@ -16925,7 +16988,7 @@ function createHostCacheLedger() {
16925
16988
  noteCacheWarm(at) {
16926
16989
  warmedAt = Math.max(warmedAt ?? at, at);
16927
16990
  },
16928
- observe(message, at) {
16991
+ observe(message, at, promptCache) {
16929
16992
  const usage = message.usage;
16930
16993
  if (!usage || typeof usage.input !== "number" || !Number.isFinite(usage.input))
16931
16994
  return null;
@@ -16946,12 +17009,15 @@ function createHostCacheLedger() {
16946
17009
  cacheRead,
16947
17010
  rebuild: false
16948
17011
  };
16949
- if (previousAt !== undefined) {
17012
+ const route = JSON.stringify([message.provider, message.model]);
17013
+ const sameRoute = route === previousRoute;
17014
+ const currentLifetime = cacheLifetimeMs(usage, promptCache);
17015
+ if (previousAt !== undefined && sameRoute) {
16950
17016
  entry.gapMs = Math.max(0, time - previousAt);
16951
17017
  if (uncached >= Math.max(REBUILD_MIN_TOKENS, 0.5 * prompt)) {
16952
17018
  entry.rebuild = true;
16953
17019
  const alive = Math.max(previousAt, warmedAt ?? previousAt);
16954
- entry.cause = pendingEdit ? "continuity" : time - alive > lifetimeMs ? "idle-expiry" : "foreign";
17020
+ entry.cause = pendingEdit ? "continuity" : lifetimeMs !== null && currentLifetime !== null && time - alive > Math.max(lifetimeMs, currentLifetime) ? "idle-expiry" : "foreign";
16955
17021
  const cause = summary.rebuilds[entry.cause];
16956
17022
  cause.count++;
16957
17023
  cause.uncached += uncached;
@@ -16975,8 +17041,8 @@ function createHostCacheLedger() {
16975
17041
  if (entry.rebuild)
16976
17042
  summary.cost.rebuildUncached += num(cost.input) + num(cost.cacheWrite);
16977
17043
  }
16978
- if (cacheWrite > 0)
16979
- lifetimeMs = cacheLifetimeMs(usage);
17044
+ lifetimeMs = sameRoute && lifetimeMs !== null && currentLifetime !== null ? Math.max(lifetimeMs, currentLifetime) : currentLifetime;
17045
+ previousRoute = route;
16980
17046
  previousAt = time;
16981
17047
  warmedAt = undefined;
16982
17048
  pendingEdit = undefined;
@@ -16993,7 +17059,7 @@ function formatCacheLedgerSummary(summary) {
16993
17059
  const readPercent = prompt ? Math.round(summary.cacheRead / prompt * 100) : 0;
16994
17060
  const { continuity, "idle-expiry": idle, foreign } = summary.rebuilds;
16995
17061
  const total = continuity.count + idle.count + foreign.count;
16996
- const lines = [`Host prompt cache: ${summary.requests} requests \xB7 ${readPercent}% of prompt tokens read from cache \xB7 ${total} rebuilds`];
17062
+ const lines = [`Host prompt cache: ${summary.requests} requests \xB7 ${readPercent}% of prompt tokens read from cache \xB7 ${total} estimated rebuilds`];
16997
17063
  if (!total)
16998
17064
  return lines;
16999
17065
  const parts = [];
@@ -17002,10 +17068,10 @@ function formatCacheLedgerSummary(summary) {
17002
17068
  parts.push(`${continuity.count} after Continuity edits (${kinds}): ${tokens(continuity.uncached)} uncached`);
17003
17069
  }
17004
17070
  if (idle.count)
17005
- parts.push(`${idle.count} idle-expiry: ${tokens(idle.uncached)} uncached`);
17071
+ parts.push(`${idle.count} possible idle-expiry: ${tokens(idle.uncached)} uncached`);
17006
17072
  if (foreign.count)
17007
- parts.push(`${foreign.count} without a Continuity edit: ${tokens(foreign.uncached)} uncached`);
17008
- lines.push("Rebuilds: " + parts.join(" \xB7 "));
17073
+ parts.push(`${foreign.count} without a Continuity edit (cause unknown): ${tokens(foreign.uncached)} uncached`);
17074
+ lines.push("Rebuild estimates: " + parts.join(" \xB7 "));
17009
17075
  if (summary.cost.total > 0)
17010
17076
  lines.push(`Cost (Pi model pricing): $${summary.cost.total.toFixed(4)} total \xB7 $${summary.cost.rebuildUncached.toFixed(4)} uncached input+cache writes on rebuilds`);
17011
17077
  return lines;
@@ -19277,24 +19343,31 @@ async function loadLineage(ctx, options = {}) {
19277
19343
  // src/app/register-smart-context-tool.ts
19278
19344
  var TOOL_NAME = "smart_context";
19279
19345
  var MAX_REPORT_CHARS = 8000;
19346
+ var ACTIVE_CHECKPOINT_ERROR = "A checkpoint is already active. Finish it with rewind(report) before starting another.";
19280
19347
  function formatDeferredTrim(deferred) {
19281
19348
  const tokens = (value) => value >= 1000 ? `${Math.round(value / 1000)}k` : String(value);
19282
19349
  const why = deferred.breakEvenRequests === null ? "the model's cache price is unknown" : `it pays back its cache rewrite only after ${Math.ceil(deferred.breakEvenRequests)} requests (limit ${AUTO_TRIM_BREAK_EVEN_REQUESTS})`;
19283
19350
  const warming = deferred.warmingStopped ? " Prompt-cache warming was stopped so the cache can go cold." : "";
19284
19351
  return `Saves ~${tokens(deferred.savedTokens)} tokens, but ${why}, so it waits for a cold prompt cache.${warming}`;
19285
19352
  }
19286
- function cachedPrefix(branch) {
19353
+ function cachedPrefix(branch, model) {
19354
+ let lifetimeMs = cacheLifetimeMs(undefined, model?.promptCache);
19355
+ if (!model || lifetimeMs === null)
19356
+ return null;
19287
19357
  let since;
19288
19358
  for (let index = branch.length - 1;index >= 0; index--) {
19289
19359
  const entry = branch[index];
19360
+ if (entry.type === "compaction" || entry.type === "context_edit" || entry.type === "branch_summary")
19361
+ break;
19290
19362
  if (entry.type !== "message" || entry.message.role !== "assistant")
19291
19363
  continue;
19292
19364
  const message = entry.message;
19365
+ if (message.provider !== model.provider || message.model !== model.id)
19366
+ break;
19293
19367
  since ??= message.timestamp;
19294
- if ((message.usage?.cacheWrite ?? 0) > 0)
19295
- return { since, lifetimeMs: cacheLifetimeMs(message.usage) };
19368
+ lifetimeMs = Math.max(lifetimeMs, cacheLifetimeMs(message.usage, model.promptCache) ?? lifetimeMs);
19296
19369
  }
19297
- return since === undefined ? null : { since, lifetimeMs: FIVE_MINUTES_MS };
19370
+ return since === undefined || !Number.isFinite(since) ? null : { since, lifetimeMs };
19298
19371
  }
19299
19372
  function unchangedSince(branch, leafId) {
19300
19373
  const leaf = branch.findIndex((entry) => entry.id === leafId);
@@ -19334,11 +19407,11 @@ function registerSmartContextTool(pi, options = {}) {
19334
19407
  pi.registerTool({
19335
19408
  name: TOOL_NAME,
19336
19409
  label: "Session Context",
19337
- 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.",
19410
+ description: "For bounded read-only research: checkpoint before exploring, then rewind(report) before implementation or your final answer. Rewind replaces safe exploration with your findings; no pressure required, one active checkpoint. status shows gates; plan/trim follow cleanup policy; search/read recover archived evidence (scope=lineage includes parents). Changes apply after the batch; safety guards remain, files/processes are never rolled back.",
19338
19411
  parameters: Type4.Object({
19339
19412
  action: StringEnum2(["status", "plan", "checkpoint", "rewind", "trim", "read", "search"]),
19340
19413
  label: Type4.Optional(Type4.String({ maxLength: 120, description: "Checkpoint label." })),
19341
- report: Type4.Optional(Type4.String({ minLength: 1, maxLength: MAX_REPORT_CHARS, description: "Rewind: findings, decisions, failures, next step." })),
19414
+ report: Type4.Optional(Type4.String({ minLength: 1, maxLength: MAX_REPORT_CHARS, description: "Rewind handoff: findings and evidence paths, constraints/decisions, failed attempts, next step." })),
19342
19415
  id: Type4.Optional(Type4.String({ minLength: 1, maxLength: 128, description: "Source ID." })),
19343
19416
  query: Type4.Optional(Type4.String({ minLength: 1, maxLength: 200, description: "Literal text." })),
19344
19417
  line: Type4.Optional(Type4.Integer({ minimum: 1, description: "Read from line." })),
@@ -19460,7 +19533,7 @@ ${page.text}`);
19460
19533
  if (options.canAgentMutate?.(ctx) === false) {
19461
19534
  throw new Error("Agent-requested context changes are disabled by policy; status, plan, search and read stay available.");
19462
19535
  }
19463
- if (params.action !== "checkpoint" && config().contextPressureOnly && !contextPressure(ctx, config()).cleanup) {
19536
+ if (params.action === "trim" && config().contextPressureOnly && !contextPressure(ctx, config()).cleanup) {
19464
19537
  throw new Error("No context pressure (or usage is unavailable); history left unchanged. Check status. Human commands can request early cleanup.");
19465
19538
  }
19466
19539
  if (params.action !== "checkpoint" && options.canAutoTrim?.(ctx) === false) {
@@ -19470,6 +19543,8 @@ ${page.text}`);
19470
19543
  throw new Error("Context changes are paused while a navigation pivot is pending.");
19471
19544
  if (queued)
19472
19545
  throw new Error("A context change is already queued; wait for the next turn boundary.");
19546
+ if (params.action === "checkpoint" && state.checkpoint)
19547
+ throw new Error(ACTIVE_CHECKPOINT_ERROR);
19473
19548
  if (params.action === "rewind" && !state.checkpoint)
19474
19549
  throw new Error(state.invalidReason ?? "No active checkpoint.");
19475
19550
  const report = params.report?.trim() ?? "";
@@ -19492,7 +19567,8 @@ ${page.text}`);
19492
19567
  text: params.action === "rewind" ? text : text.slice(0, 120),
19493
19568
  signal
19494
19569
  };
19495
- 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."}`, {
19570
+ const next = params.action === "checkpoint" ? "Explore read-only, then call rewind(report) with findings, evidence, constraints and the next step before implementation or your final answer. Do not nest checkpoints. No pressure required." : params.action === "rewind" ? "Stop research here. After the batch, continue the original task from the retained report. Files and processes are not reverted." : "Use status to inspect the committed state.";
19571
+ return reply(`${params.action} queued for the completed tool batch. ${next}`, {
19496
19572
  display: { state: "queued", action: params.action, label: text.slice(0, 120) }
19497
19573
  });
19498
19574
  },
@@ -19558,7 +19634,7 @@ ${page.text}`);
19558
19634
  mark = null;
19559
19635
  return;
19560
19636
  }
19561
- const prefix = cachedPrefix(branch);
19637
+ const prefix = cachedPrefix(branch, ctx.model);
19562
19638
  const since = Math.max(prefix?.since ?? 0, warm?.sessionId === sessionId ? warm.at : 0);
19563
19639
  if (!prefix || now() - since <= prefix.lifetimeMs)
19564
19640
  return;
@@ -19579,7 +19655,7 @@ ${page.text}`);
19579
19655
  return changed ? { messages } : undefined;
19580
19656
  };
19581
19657
  const vetoWarming = (event, ctx, sessionId) => {
19582
- if (config().contextPressureOnly || mark?.sessionId !== sessionId || !unchangedSince(ctx.sessionManager.getBranch(), mark.leafId) || thinkingEditReason(ctx.sessionManager.getBranch(), mark.entries, ctx.model?.api))
19658
+ if (config().contextPressureOnly || mark?.sessionId !== sessionId || !cachedPrefix(ctx.sessionManager.getBranch(), ctx.model) || !unchangedSince(ctx.sessionManager.getBranch(), mark.leafId) || thinkingEditReason(ctx.sessionManager.getBranch(), mark.entries, ctx.model?.api))
19583
19659
  return false;
19584
19660
  const cost = ctx.model?.cost;
19585
19661
  const price = cost ? cost.cacheWrite > 0 ? cost.cacheWrite : cost.input : NaN;
@@ -19613,7 +19689,7 @@ ${page.text}`);
19613
19689
  }
19614
19690
  const settings = config();
19615
19691
  const pressure = contextPressure(ctx, settings).cleanup;
19616
- if (request && !request.manual && request.action !== "checkpoint" && settings.contextPressureOnly && !pressure) {
19692
+ if (request && !request.manual && !request.userConfirmed && request.action === "trim" && settings.contextPressureOnly && !pressure) {
19617
19693
  return cancelled(event, "Context pressure cleared; history left unchanged.");
19618
19694
  }
19619
19695
  if (!request) {
@@ -19647,6 +19723,8 @@ ${page.text}`);
19647
19723
  let entries;
19648
19724
  try {
19649
19725
  if (request?.action === "checkpoint") {
19726
+ if (state.checkpoint)
19727
+ throw new Error(ACTIVE_CHECKPOINT_ERROR);
19650
19728
  entries = [contextControlEntry({
19651
19729
  version: 1,
19652
19730
  action: "checkpoint",
@@ -19669,7 +19747,7 @@ ${page.text}`);
19669
19747
  if (plan.blockedReason)
19670
19748
  return request ? cancelled(event, plan.blockedReason) : undefined;
19671
19749
  if (request) {
19672
- entries = trimEntries(plan, request.manual ? "manual" : "agent");
19750
+ entries = trimEntries(plan, request.manual || request.userConfirmed ? "manual" : "agent");
19673
19751
  } else {
19674
19752
  mark = null;
19675
19753
  if (plan.automatic !== "ready")
@@ -19709,6 +19787,7 @@ ${page.text}`);
19709
19787
  return { entries: [...event.entries, ...entries] };
19710
19788
  });
19711
19789
  const requestTrim = (ctx, anchor) => {
19790
+ const userConfirmed = anchor?.userConfirmed === true;
19712
19791
  const sessionId = resolveSessionId(ctx);
19713
19792
  if (isUnresolvedSessionId(sessionId)) {
19714
19793
  return { state: "unavailable", notice: "Context control needs an identifiable session." };
@@ -19722,7 +19801,7 @@ ${page.text}`);
19722
19801
  if (anchor && options.canAutoTrim?.(ctx) === false) {
19723
19802
  return { state: "busy", notice: "Cleanup deferred: prepared/running compaction or unavailable recovery tools take priority." };
19724
19803
  }
19725
- if (anchor?.callId && (options.canAgentMutate?.(ctx) === false || config().contextPressureOnly && !contextPressure(ctx, config()).cleanup)) {
19804
+ if (anchor?.callId && (options.canAgentMutate?.(ctx) === false || !userConfirmed && config().contextPressureOnly && !contextPressure(ctx, config()).cleanup)) {
19726
19805
  return { state: "unavailable", notice: "Cleanup not requested: no context pressure or agent changes are disabled." };
19727
19806
  }
19728
19807
  const branch = ctx.sessionManager.getBranch();
@@ -19731,7 +19810,7 @@ ${page.text}`);
19731
19810
  return { state: "unavailable", notice: "Anchor origin is no longer on this branch." };
19732
19811
  const anchorBoundary = anchor ? lastAnchorBoundary(branch.slice(0, origin + 1)) ?? null : undefined;
19733
19812
  const plan = planContextTrim(branch, inspectContext(branch, sessionId).checkpoint?.originId, { anchorBoundary, readerApi: ctx.model?.api });
19734
- if (!plan.entries.length || anchor?.callId && plan.automatic !== "ready") {
19813
+ if (!plan.entries.length || anchor?.callId && !userConfirmed && plan.automatic !== "ready") {
19735
19814
  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.") };
19736
19815
  }
19737
19816
  mark = null;
@@ -19743,17 +19822,18 @@ ${page.text}`);
19743
19822
  checkpointId: "",
19744
19823
  text: "",
19745
19824
  manual: !anchor?.callId,
19825
+ userConfirmed,
19746
19826
  anchorBoundary,
19747
19827
  signal: anchor?.signal
19748
19828
  };
19749
19829
  return {
19750
19830
  state: "queued",
19751
- 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."
19831
+ notice: anchor?.callId ? `${userConfirmed ? "User-confirmed anchor" : "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."
19752
19832
  };
19753
19833
  };
19754
19834
  return {
19755
19835
  requestManualTrim: (ctx) => requestTrim(ctx),
19756
- requestAnchorTrim: (ctx, originId, callId, signal) => requestTrim(ctx, { originId, callId, signal }),
19836
+ requestAnchorTrim: (ctx, originId, callId, signal, userConfirmed) => requestTrim(ctx, { originId, callId, signal, userConfirmed }),
19757
19837
  deferredTrim: deferred
19758
19838
  };
19759
19839
  }
@@ -21178,7 +21258,7 @@ var CATEGORIES = [
21178
21258
  setting: {
21179
21259
  id: "contextPressureOnly",
21180
21260
  label: "Cleanup timing",
21181
- 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.",
21261
+ description: "Pressure only (default) gates automatic cleanup and autonomous agent trim/anchor requests. A host-confirmed anchor can request one early cleanup without changing this setting. Checkpoint/rewind research is pressure-independent. Economic opts into early cleanup. Safety checks always apply.",
21182
21262
  values: BOOLEAN_VALUES,
21183
21263
  labels: { true: "Pressure only", false: "Economic (opt-in)" }
21184
21264
  }
@@ -24298,6 +24378,11 @@ function registerNavigation(pi, options = {}) {
24298
24378
  let queued;
24299
24379
  let applying;
24300
24380
  let timer;
24381
+ let epoch = 0;
24382
+ const agentAllowed = () => {
24383
+ const settings = config();
24384
+ return settings.toolLoading !== "off" && pi.getActiveTools().includes(NAVIGATION_TOOL_NAME);
24385
+ };
24301
24386
  const scrub = (text) => {
24302
24387
  const settings = config();
24303
24388
  return new SecretScrubber(settings.scrubSecrets, settings.scrubPii).scrubText(text).value;
@@ -24418,7 +24503,7 @@ function registerNavigation(pi, options = {}) {
24418
24503
  pi.registerTool({
24419
24504
  name: NAVIGATION_TOOL_NAME,
24420
24505
  label: "Context Navigation",
24421
- 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.",
24506
+ description: "View/recall session anchors. Anchor completed work under pressure. In pressure-only mode, an explicitly user-requested early anchor requires one-time host confirmation. Safe cleanup is queued once, not full compaction; never claim approval or retry a refusal. Pivot needs carryover and ends the turn; files/processes are not rolled back.",
24422
24507
  parameters: Type6.Object({
24423
24508
  action: StringEnum4(["view", "recall", "anchor", "pivot"]),
24424
24509
  target: Type6.Optional(Type6.String({ minLength: 1, maxLength: 128 })),
@@ -24436,8 +24521,8 @@ function registerNavigation(pi, options = {}) {
24436
24521
  enabled();
24437
24522
  if (signal?.aborted)
24438
24523
  throw new Error("Navigation cancelled.");
24439
- if (config().toolLoading === "off" || !pi.getActiveTools().includes(NAVIGATION_TOOL_NAME))
24440
- throw new Error("Load navigation with smart_tools first; respect the user's /tools selection.");
24524
+ if (!agentAllowed())
24525
+ throw new Error("Navigation tool access is disabled or hidden; respect the user's policy and /tools selection.");
24441
24526
  if (params.action === "view") {
24442
24527
  if (params.target) {
24443
24528
  const target = resolveAnchorTarget(ctx.sessionManager, params.target);
@@ -24462,16 +24547,34 @@ Use Pi's /resume to open a different session.`, {
24462
24547
  });
24463
24548
  }
24464
24549
  if (params.action === "anchor") {
24465
- if (config().contextPressureOnly && !contextPressure(ctx, config()).cleanup) {
24466
- 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.");
24550
+ const early = config().contextPressureOnly && !contextPressure(ctx, config()).cleanup;
24551
+ if (early && !ctx.hasUI)
24552
+ throw new Error("No context pressure (or usage is unavailable); early anchor requires host confirmation. No anchor or cleanup requested. A human can use /smart-compact.");
24553
+ let data = anchor(ctx, params.name ?? "", params.summary ?? "");
24554
+ let userConfirmed = false;
24555
+ if (early) {
24556
+ if (ctx.hasPendingMessages())
24557
+ throw new Error("Queued user input takes priority; no anchor or cleanup requested.");
24558
+ const approvalEpoch = epoch;
24559
+ const sessionId = resolveSessionId(ctx);
24560
+ const approved = await ctx.ui.confirm("Save anchor and request early cleanup?", `Anchor: ${data.name}
24561
+
24562
+ ${data.summary}
24563
+
24564
+ Allow this anchor and one safe cleanup without context pressure? Only eligible old tool outputs may be shortened; recent turns, protected prefixes and safety checks stay. No full compaction or file/process rollback. Cleanup can be blocked by prepared work or safety checks. This does not change your settings or authorize future calls.`, { signal });
24565
+ if (approved !== true || signal?.aborted)
24566
+ throw new Error("Anchor not approved or cancelled; no anchor or cleanup requested. Do not retry without a new user request.");
24567
+ if (approvalEpoch !== epoch || sessionId !== resolveSessionId(ctx) || data.targetId !== ctx.sessionManager.getLeafId() || ctx.hasPendingMessages() || !agentAllowed())
24568
+ throw new Error("Anchor confirmation expired: session, history, pending input or tool permissions changed. Nothing was queued.");
24569
+ data = anchor(ctx, data.name, data.summary);
24570
+ userConfirmed = true;
24467
24571
  }
24468
- const data = anchor(ctx, params.name ?? "", params.summary ?? "");
24469
- const cleanup = options.onAnchor?.(ctx, data.targetId, callId, signal) ?? "History unchanged; no cleanup controller attached.";
24572
+ const cleanup = options.onAnchor?.(ctx, data.targetId, callId, signal, userConfirmed) ?? "History unchanged; no cleanup controller attached.";
24470
24573
  return { content: [{ type: "text", text: `Anchor: ${data.name}
24471
24574
 
24472
24575
  ${data.summary}
24473
24576
 
24474
- ${cleanup}` }], details: { anchor: data } };
24577
+ ${cleanup}` }], details: { anchor: data, ...userConfirmed ? { userConfirmed: true } : {} } };
24475
24578
  }
24476
24579
  const operation = preparePivot(ctx, params.target ?? "", params.carryover ?? "", params.message);
24477
24580
  operation.callId = callId;
@@ -24548,6 +24651,7 @@ ${operation.carryover}` } };
24548
24651
  footer(ctx);
24549
24652
  });
24550
24653
  const reset = (_event, ctx) => {
24654
+ epoch++;
24551
24655
  cancel();
24552
24656
  applying = undefined;
24553
24657
  footer(ctx);
@@ -24556,17 +24660,23 @@ ${operation.carryover}` } };
24556
24660
  pi.on("session_before_switch", reset);
24557
24661
  pi.on("session_before_fork", reset);
24558
24662
  pi.on("session_before_compact", (_event, ctx) => {
24663
+ epoch++;
24559
24664
  if (queued?.sessionId === resolveSessionId(ctx) || applying?.sessionId === resolveSessionId(ctx))
24560
24665
  return { cancel: true };
24561
24666
  });
24562
24667
  pi.on("session_shutdown", (_event, ctx) => {
24668
+ epoch++;
24563
24669
  cancel();
24564
24670
  applying = undefined;
24565
24671
  ctx.ui.setStatus(STATUS, undefined);
24566
24672
  });
24567
24673
  pi.on("session_tree", (_event, ctx) => {
24674
+ epoch++;
24568
24675
  footer(ctx);
24569
24676
  });
24677
+ pi.on("model_select", () => {
24678
+ epoch++;
24679
+ });
24570
24680
  return {
24571
24681
  isPending: (ctx) => queued?.sessionId === resolveSessionId(ctx) || applying?.sessionId === resolveSessionId(ctx),
24572
24682
  refresh,
@@ -25085,16 +25195,17 @@ function smartCompactExtension(pi) {
25085
25195
  navigation = registerNavigation(pi, {
25086
25196
  config: automaticConfig,
25087
25197
  mutationBlocked: (ctx) => isRunning.isSessionActive(resolveSessionId(ctx)) ? "Compaction is running; wait for it to finish." : undefined,
25088
- onAnchor: (ctx, originId, callId, signal) => smartContext.requestAnchorTrim(ctx, originId, callId, signal).notice,
25198
+ onAnchor: (ctx, originId, callId, signal, userConfirmed) => smartContext.requestAnchorTrim(ctx, originId, callId, signal, userConfirmed).notice,
25089
25199
  onContextChange: (ctx) => invalidatePreparation(ctx, "branch"),
25090
25200
  onContextEdit: (_ctx, kind) => hostCache.noteContextEdit(kind)
25091
25201
  });
25092
25202
  registerArtifactOffload(pi, automaticConfig, () => toolExposure.reachable("history"));
25203
+ const canAutoTrim = (ctx) => toolExposure.reachable("history") && !background.hasWork() && !pendingRef.isPresent(resolveSessionId(ctx)) && !isRunning.isSessionActive(resolveSessionId(ctx));
25093
25204
  const smartContext = registerSmartContextTool(pi, {
25094
25205
  config: automaticConfig,
25095
25206
  isPaused: pivotQueued,
25096
25207
  canAgentMutate: () => policy.snapshot().agentToolAccess !== "disabled",
25097
- canAutoTrim: (ctx) => toolExposure.reachable("history") && !background.hasWork() && !pendingRef.isPresent(resolveSessionId(ctx)) && !isRunning.isSessionActive(resolveSessionId(ctx)),
25208
+ canAutoTrim,
25098
25209
  onContextChange: (ctx) => invalidatePreparation(ctx, "branch"),
25099
25210
  onContextEdit: (_ctx, kind) => hostCache.noteContextEdit(kind),
25100
25211
  onCacheWarm: (ctx, at) => {
@@ -25105,6 +25216,7 @@ function smartCompactExtension(pi) {
25105
25216
  registerContextAttention(pi, {
25106
25217
  config: automaticConfig,
25107
25218
  canAgentAct: (ctx) => policy.snapshot().agentToolAccess !== "disabled" && !pivotQueued(ctx) && !isRunning.isSessionActive(resolveSessionId(ctx)),
25219
+ canCleanup: canAutoTrim,
25108
25220
  reachable: (group) => toolExposure.reachable(group)
25109
25221
  });
25110
25222
  registerSmartCompactCommand(pi, {
@@ -25401,12 +25513,13 @@ function smartCompactExtension(pi) {
25401
25513
  const sessionId = resolveSessionId(ctx);
25402
25514
  if (hostCache.sessionId() !== sessionId)
25403
25515
  hostCache.reset(sessionId);
25404
- if (!hostCache.observe(event.message)?.warn)
25516
+ const promptCache = ctx.model && ctx.model.provider === event.message.provider && ctx.model.id === event.message.model ? ctx.model.promptCache : undefined;
25517
+ if (!hostCache.observe(event.message, undefined, promptCache)?.warn)
25405
25518
  return;
25406
25519
  const { foreign } = hostCache.summary().rebuilds;
25407
25520
  recordIssue({
25408
25521
  key: "cache.foreign-rebuilds",
25409
- 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."
25522
+ message: "Pi reported " + foreign.count + " large uncached prompts this session with no Continuity edit before them (" + foreign.uncached.toLocaleString("en-US") + " uncached prompt tokens). Their cause is unknown; model/tool changes and Pi's built-in compaction can also change the prefix. Home \u203A Readiness & details lists rebuild estimates."
25410
25523
  });
25411
25524
  });
25412
25525
  pi.on("message_end", async (event, ctx) => {