pi-tuck 1.0.17 → 1.0.19

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 (49) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +24 -7
  3. package/dist/index.js +7 -2
  4. package/dist/src/budget.d.ts +14 -17
  5. package/dist/src/budget.js +22 -29
  6. package/dist/src/build-id.d.ts +1 -1
  7. package/dist/src/build-id.js +1 -1
  8. package/dist/src/compaction.js +118 -40
  9. package/dist/src/diagnostics.d.ts +1 -1
  10. package/dist/src/history-tools.d.ts +16 -0
  11. package/dist/src/history-tools.js +59 -0
  12. package/dist/src/ledger.d.ts +0 -2
  13. package/dist/src/ledger.js +0 -2
  14. package/dist/src/omissions.d.ts +1 -1
  15. package/dist/src/omissions.js +88 -6
  16. package/dist/src/status.d.ts +19 -0
  17. package/dist/src/status.js +36 -0
  18. package/dist/src/tuck/core/brief.d.ts +6 -3
  19. package/dist/src/tuck/core/brief.js +27 -9
  20. package/dist/src/tuck/core/filter-noise.d.ts +1 -1
  21. package/dist/src/tuck/core/filter-noise.js +1 -3
  22. package/dist/src/tuck/core/normalize.d.ts +2 -5
  23. package/dist/src/tuck/core/normalize.js +15 -33
  24. package/dist/src/tuck/core/shrink.d.ts +6 -14
  25. package/dist/src/tuck/core/shrink.js +162 -102
  26. package/dist/src/tuck/core/summarize.d.ts +3 -14
  27. package/dist/src/tuck/core/summarize.js +9 -20
  28. package/dist/src/tuck/core/transcript.d.ts +5 -0
  29. package/dist/src/tuck/core/transcript.js +27 -4
  30. package/dist/src/tuck/types.d.ts +1 -1
  31. package/dist/src/tuck.d.ts +9 -17
  32. package/dist/src/tuck.js +384 -212
  33. package/index.ts +4 -2
  34. package/package.json +1 -1
  35. package/src/budget.ts +24 -26
  36. package/src/build-id.ts +1 -1
  37. package/src/compaction.ts +107 -38
  38. package/src/history-tools.ts +57 -0
  39. package/src/ledger.ts +0 -2
  40. package/src/omissions.ts +64 -7
  41. package/src/status.ts +43 -0
  42. package/src/tuck/core/brief.ts +25 -10
  43. package/src/tuck/core/filter-noise.ts +1 -3
  44. package/src/tuck/core/normalize.ts +11 -27
  45. package/src/tuck/core/shrink.ts +141 -91
  46. package/src/tuck/core/summarize.ts +9 -24
  47. package/src/tuck/core/transcript.ts +26 -4
  48. package/src/tuck/types.ts +3 -3
  49. package/src/tuck.ts +336 -185
@@ -6,15 +6,17 @@ import { isDeepStrictEqual } from "node:util";
6
6
  import { assertNativeToolRounds } from "./protocol.js";
7
7
  import { projectedMessages } from "./omissions.js";
8
8
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
9
- import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt } from "./budget.js";
10
- import { isContextOverflow } from "@earendil-works/pi-ai";
11
- import { convertToLlm, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
9
+ import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
10
+ import { getCurrentSystemMessage, isContextOverflow } from "@earendil-works/pi-ai";
11
+ import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
  import { Check } from "typebox/value";
14
14
  import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
15
15
  import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
16
+ import { LOADED_VERSION, statusText } from "./status.js";
16
17
  import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
17
18
  import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache.js";
19
+ import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
18
20
  const query = Type.String({ minLength: 1, maxLength: MAX_RECALL_QUERY_CHARACTERS });
19
21
  const entryId = Type.String({ minLength: 1, maxLength: MAX_RECALL_ENTRY_ID_CHARACTERS });
20
22
  const paging = {
@@ -208,6 +210,10 @@ export async function installCompaction(pi, options = {}) {
208
210
  }
209
211
  ctx.ui.notify(JSON.stringify({ ...semanticStatus(), ...(errors.length ? { errors } : {}) }), errors.length ? "warning" : "info");
210
212
  } });
213
+ pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
214
+ const { state, lastError } = semantic.status();
215
+ ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: { state, lastError } }), "info");
216
+ } });
211
217
  // Pi's native start/settled events bracket the run and its post-turn cuts.
212
218
  let admittedRun = false;
213
219
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -319,12 +325,20 @@ export async function installCompaction(pi, options = {}) {
319
325
  const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
320
326
  // The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
321
327
  // (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
322
- const plannerMeasureFor = (ctx) => (messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
328
+ const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
323
329
  const hostTokens = (ctx) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
324
330
  const toolShapes = (names) => {
325
331
  const selected = new Set(names);
326
332
  return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
327
333
  };
334
+ // Every decision prices the declaration set a cut must carry, even before the first cut enables our tools on the wire.
335
+ const requestSystemPart = (observedPrompt, names, saved, guardPrompt) => {
336
+ const shapes = toolShapes(historyToolNames(pi, names, true));
337
+ // Keep the guard's observed schemas (Pi may enrich a host tool's schema), and add our future declarations to that same view.
338
+ // Taking max(current guard, future raw shapes) alone could lose the enrichment when the future shapes are larger.
339
+ const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
340
+ return systemPart(observedPrompt, shapes, future, guardPrompt);
341
+ };
328
342
  // Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
329
343
  const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
330
344
  // A missing model is configuration, not capacity.
@@ -385,7 +399,9 @@ export async function installCompaction(pi, options = {}) {
385
399
  forgetFailure();
386
400
  phase = "decided";
387
401
  try {
402
+ syncHistoryTools(ctx);
388
403
  await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
404
+ syncHistoryTools(ctx);
389
405
  }
390
406
  catch (error) {
391
407
  fail(error, ctx);
@@ -466,18 +482,17 @@ export async function installCompaction(pi, options = {}) {
466
482
  if (operatorManual && !admittedRun)
467
483
  forgetFailure();
468
484
  access();
469
- if (event.customInstructions?.trim()) {
470
- recorder.stop("no-cut-custom-instructions");
471
- ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
472
- return { cancel: true };
473
- }
485
+ // pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
486
+ // plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
487
+ if (event.customInstructions?.trim())
488
+ ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
474
489
  const { response, room } = limits(ctx);
475
490
  const retain = operatorManual || admittedRun;
476
491
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
477
492
  // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
478
493
  const saved = ctx.sessionManager.buildSessionProjection().messages;
479
494
  const host = hostTokens(ctx);
480
- const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
495
+ const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
481
496
  const mandatory = system + host, band = decisionBand(ctx, room, system);
482
497
  const before = plannerMeasureFor(ctx)(saved);
483
498
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
@@ -528,6 +543,15 @@ export async function installCompaction(pi, options = {}) {
528
543
  });
529
544
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
530
545
  pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
546
+ const historyToolsEnabled = (ctx) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
547
+ const syncHistoryTools = (ctx) => { if (active())
548
+ applyHistoryTools(pi, historyToolsEnabled(ctx)); };
549
+ pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
550
+ pi.on("session_tree", (_event, ctx) => syncHistoryTools(ctx));
551
+ pi.on("session_compact", (_event, ctx) => { if (!admittedRun)
552
+ syncHistoryTools(ctx); });
553
+ pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
554
+ pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
531
555
  // The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
532
556
  const cutSession = (ctx, cut) => {
533
557
  const header = ctx.sessionManager.getHeader();
@@ -542,7 +566,7 @@ export async function installCompaction(pi, options = {}) {
542
566
  // custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
543
567
  // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
544
568
  // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
545
- const preflight = (ctx, incomingPart, observedPrompt, tools) => {
569
+ const preflight = (ctx, incomingPart, observedPrompt, tools, hostSnapshot) => {
546
570
  promptCut = undefined;
547
571
  promptCutAt = ctx.sessionManager.getBranch().length;
548
572
  if (!active())
@@ -555,9 +579,9 @@ export async function installCompaction(pi, options = {}) {
555
579
  const { response, room } = limits(ctx);
556
580
  // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
557
581
  const saved = ctx.sessionManager.buildSessionProjection().messages;
558
- const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
582
+ const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
559
583
  const band = decisionBand(ctx, room, system);
560
- const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
584
+ const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
561
585
  const incoming = incomingPart + system, mandatory = incoming + host;
562
586
  if (!fits(incoming, room, ctx))
563
587
  throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
@@ -594,10 +618,43 @@ export async function installCompaction(pi, options = {}) {
594
618
  const prompted = phase === "decided";
595
619
  phase = "starting";
596
620
  try {
621
+ syncHistoryTools(ctx);
597
622
  if (prompted)
598
623
  access(); // decided on input; a failure latched there still stops the run
599
624
  else
600
- await preflight(ctx, incomingEventTokens(event), event.systemPrompt, event.systemPromptOptions.selectedTools);
625
+ await preflight(ctx, incomingEventTokens(event), event.systemPrompt, historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
626
+ // Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
627
+ // the request projection: recording an activation message here would shift every later session-global recall index.
628
+ if (historyToolsEnabled(ctx))
629
+ stageHistoryTools(pi, ctx.sessionManager.buildSessionProjection().messages);
630
+ else
631
+ applyHistoryTools(pi, false);
632
+ const staged = pi.getActiveTools();
633
+ event.systemPromptOptions.selectedTools = historyToolNames(pi, event.systemPromptOptions.selectedTools, true).filter(name => name !== "tuck_recall" && name !== "tuck_search" || staged.includes(name));
634
+ }
635
+ catch (error) {
636
+ fail(error, ctx);
637
+ }
638
+ });
639
+ // The incoming user message is about to be appended (including steering inside a run), and later before_agent_start/turn_start
640
+ // handlers have run. Pi's prepareRequest has not yet snapshotted executable tools. A first cut discovered in context_with_system
641
+ // would be too late for that snapshot, even if its declarations were repaired there, so make the late prompt decision here.
642
+ pi.on("message_end", (event, ctx) => {
643
+ if (event.message.role !== "user" || !active() || !admittedRun || promptCut)
644
+ return;
645
+ try {
646
+ access();
647
+ if (!ctx.model)
648
+ throw missingModel();
649
+ const message = event.message;
650
+ const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof message.content === "string" ? [] : message.content.filter((b) => b.type === "image"));
651
+ const saved = ctx.sessionManager.buildSessionProjection().messages;
652
+ const host = hostTokens(ctx);
653
+ const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
654
+ if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx))
655
+ return;
656
+ preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(), host);
657
+ syncHistoryTools(ctx);
601
658
  }
602
659
  catch (error) {
603
660
  fail(error, ctx);
@@ -620,11 +677,28 @@ export async function installCompaction(pi, options = {}) {
620
677
  // One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
621
678
  // saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
622
679
  // request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
623
- const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
680
+ const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
624
681
  const band = decisionBand(ctx, room, system);
625
682
  const host = hostTokens(ctx);
626
683
  const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
627
684
  const mandatory = system + host + queued;
685
+ // Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
686
+ // Our declarations are request-only, so Pi can miss that threshold. Preserve the same density input at settlement; every
687
+ // budget decision below still uses `system` and the complete conversation measure, including the summary envelope.
688
+ let densityTokens;
689
+ const lastAssistant = saved.findLast(message => message.role === "assistant");
690
+ const lastCut = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
691
+ if (reason === "run-end" && lastAssistant && (!lastCut || lastAssistant.timestamp > Date.parse(lastCut.timestamp)) &&
692
+ !saved.some(message => message.role === "assistant" &&
693
+ !["error", "aborted"].includes(message.stopReason) && message.usage && calculateContextTokens(message.usage) > 0)) {
694
+ const forecast = historyToolDeclarations(saved, toolShapes(historyToolNames(pi, pi.getActiveTools(), true)), true);
695
+ if (forecast !== saved) {
696
+ const head = getCurrentSystemMessage(convertToLlm(forecast));
697
+ const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
698
+ if (shouldCompact(native, ctx.model.contextWindow, settingsFor().getCompactionSettings(ctx.model)))
699
+ densityTokens = native;
700
+ }
701
+ }
628
702
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
629
703
  // The cut previews this decision has built, in the order it built them. The clip retry below measures them for what a cut leaves the
630
704
  // request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
@@ -634,18 +708,22 @@ export async function installCompaction(pi, options = {}) {
634
708
  previews = [];
635
709
  floorKept = undefined;
636
710
  const before = measure(saved);
637
- return decideCut({
638
- total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
711
+ const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
712
+ if (gate.kind !== "plan")
713
+ return gate;
714
+ return chooseCut({
715
+ opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
639
716
  unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
640
717
  plan: (target) => {
641
718
  const reserve = planReserve(target, room, band, mandatory, ctx);
642
- const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
719
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
643
720
  if (!draft.compaction) {
644
721
  // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
645
722
  if (draft.floorMessages && event.toolResults) {
646
723
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
647
724
  const kept = measure(draft.floorMessages.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId))));
648
- floorKept = Math.min(floorKept ?? kept, kept);
725
+ // Both targets must leave their protected openings beside the clip.
726
+ floorKept = Math.max(floorKept ?? kept, kept);
649
727
  }
650
728
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
651
729
  }
@@ -671,7 +749,7 @@ export async function installCompaction(pi, options = {}) {
671
749
  const branch = ctx.sessionManager.getBranch();
672
750
  const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
673
751
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
674
- const kept = previews.length ? Math.min(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
752
+ const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
675
753
  for (const budget of clipBudgets(room, factor, mandatory, kept)) {
676
754
  const round = clipFor(event.toolResults, entryIdOf, budget);
677
755
  if (!round.length)
@@ -720,13 +798,24 @@ export async function installCompaction(pi, options = {}) {
720
798
  if (!promptCut && !decide)
721
799
  return;
722
800
  const result = atBoundary(event, ctx, "tool-loop", decide);
723
- if (result)
801
+ if (result) {
724
802
  pendingBoundaryCommit = true;
803
+ }
725
804
  return result;
726
805
  }
727
806
  catch (error) {
728
807
  fail(error, ctx);
729
808
  }
809
+ finally {
810
+ // Keep our part of Pi's next declaration snapshot unchanged; turn_start restores the branch-derived executable set before
811
+ // prepareRequest. This avoids an extra persisted system message at the first cut and on later rounds/runs.
812
+ if (active()) {
813
+ if (historyToolsEnabled(ctx))
814
+ stageHistoryTools(pi, ctx.sessionManager.buildSessionProjection().messages);
815
+ else
816
+ applyHistoryTools(pi, false);
817
+ }
818
+ }
730
819
  });
731
820
  // A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
732
821
  // the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
@@ -743,6 +832,9 @@ export async function installCompaction(pi, options = {}) {
743
832
  try {
744
833
  if (!active())
745
834
  return;
835
+ // Before a cut, Pi owns the whole request, including its object identities and system-message serialization.
836
+ if (!historyToolsEnabled(ctx))
837
+ return clips.size ? { messages: applyClips(event.messages, clips) } : undefined;
746
838
  access();
747
839
  return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
748
840
  }
@@ -751,7 +843,7 @@ export async function installCompaction(pi, options = {}) {
751
843
  fail(error, ctx);
752
844
  }
753
845
  finally {
754
- return { messages: [] };
846
+ return historyToolsEnabled(ctx) ? { messages: [] } : undefined;
755
847
  }
756
848
  }
757
849
  });
@@ -807,24 +899,10 @@ export async function installCompaction(pi, options = {}) {
807
899
  throw missingModel();
808
900
  // The complete request, counted after Pi's conversion (summary/custom/bash envelopes), with the effective system once.
809
901
  // Pi applies a forced prompt after this hook; the public prompt already observes it.
810
- let messages = event.messages, tokens = requestTokens(messages, ctx.getSystemPrompt());
811
- // The run's first request is over the room although the prompt's decision found it fit: something changed the request after that decision
812
- // (another extension patching the system prompt in before_agent_start, whatever the load order). Reduce here rather than refuse: plan the
813
- // prompt's cut against the request as it now is, carry it in memory, and save it at the first boundary, as any prompt's cut is saved.
814
- if (!fits(tokens, limits(ctx).room, ctx) && !promptCut) {
815
- const branch = ctx.sessionManager.getBranch(), last = branch.at(-1);
816
- if (last?.type === "message" && last.message.role === "user") {
817
- const header = ctx.sessionManager.getHeader();
818
- const before = header && SessionManager.inMemory(ctx.cwd, undefined, [header, ...branch.slice(0, -1)]);
819
- if (before) {
820
- preflight(Object.create(ctx, { sessionManager: { value: before } }), incomingTokens(typeof last.message.content === "string" ? last.message.content : last.message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof last.message.content === "string" ? [] : last.message.content.filter((b) => b.type === "image")), ctx.getSystemPrompt(), pi.getActiveTools());
821
- if (promptCut) {
822
- messages = cutRequest(event.messages, ctx, promptCut);
823
- tokens = requestTokens(messages, ctx.getSystemPrompt());
824
- }
825
- }
826
- }
827
- }
902
+ syncHistoryTools(ctx);
903
+ const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
904
+ const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
905
+ const tokens = system + conversationTokens(messages);
828
906
  if (!fits(tokens, limits(ctx).room, ctx))
829
907
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
830
908
  if (recorder.enabled && !recorder.waitingForCut)
@@ -1,6 +1,6 @@
1
1
  import { type RecallPolicy } from './recall.js';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
- export declare const DIAGNOSTIC_BUILD = "27bb99fa3ca595dac73089d18c4c3506073c5d3e3f778ea572708f6e7a49ffc3";
3
+ export declare const DIAGNOSTIC_BUILD = "3bbefa84bf1600296d12131a405f9feaa8fb1ea26ca1a85f5292b40a0a57333f";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -0,0 +1,16 @@
1
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { Messages, ToolShape } from "./budget.js";
3
+ /** Native Pi compactions do not make pi-tuck history available. No session-wide activation state. */
4
+ export declare const hasTuckCut: (branch: readonly {
5
+ type: string;
6
+ details?: unknown;
7
+ }[]) => boolean;
8
+ /** Preserve the host's loadout and its order; only our registered tools may change. */
9
+ export declare function historyToolNames(pi: ExtensionAPI, names: readonly string[], enabled: boolean): string[];
10
+ export declare function applyHistoryTools(pi: ExtensionAPI, enabled: boolean): void;
11
+ /** Match only our part of Pi's saved declaration snapshot, so its loop records no activation/removal message. Legacy sessions can
12
+ * already declare our tools; new sessions declare them only in the request projection. Executable tools are restored at turn_start. */
13
+ export declare function stageHistoryTools(pi: ExtensionAPI, messages: Messages): string[];
14
+ /** Pi has already declared the request's tools before queued user messages are appended. A prompt cut planned on such a message needs its declarations
15
+ * in that very request. Change only our declarations, leaving all other system content and tools in their original places. */
16
+ export declare function historyToolDeclarations(messages: Messages, tools: readonly ToolShape[], enabled: boolean): Messages;
@@ -0,0 +1,59 @@
1
+ import { getCurrentTools } from "@earendil-works/pi-ai";
2
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
3
+ const own = (name) => name === "tuck_recall" || name === "tuck_search";
4
+ /** Native Pi compactions do not make pi-tuck history available. No session-wide activation state. */
5
+ export const hasTuckCut = (branch) => branch.some(entry => entry.type === "compaction" && entry.details?.compactor === "pi-tuck");
6
+ /** Preserve the host's loadout and its order; only our registered tools may change. */
7
+ export function historyToolNames(pi, names, enabled) {
8
+ if (!enabled)
9
+ return names.filter(name => !own(name));
10
+ const registered = new Set(pi.getAllTools().map(tool => tool.name));
11
+ return [...names, ...["tuck_recall", "tuck_search"].filter(name => registered.has(name) && !names.includes(name))];
12
+ }
13
+ export function applyHistoryTools(pi, enabled) {
14
+ const current = pi.getActiveTools(), next = historyToolNames(pi, current, enabled);
15
+ if (current.length !== next.length || current.some((name, index) => name !== next[index]))
16
+ pi.setActiveTools(next);
17
+ }
18
+ /** Match only our part of Pi's saved declaration snapshot, so its loop records no activation/removal message. Legacy sessions can
19
+ * already declare our tools; new sessions declare them only in the request projection. Executable tools are restored at turn_start. */
20
+ export function stageHistoryTools(pi, messages) {
21
+ const declared = new Set(getCurrentTools(convertToLlm(messages)).map(tool => tool.name));
22
+ const current = pi.getActiveTools();
23
+ const next = historyToolNames(pi, current, true).filter(name => !own(name) || declared.has(name));
24
+ if (current.length !== next.length || current.some((name, index) => name !== next[index]))
25
+ pi.setActiveTools(next);
26
+ return next;
27
+ }
28
+ /** Pi has already declared the request's tools before queued user messages are appended. A prompt cut planned on such a message needs its declarations
29
+ * in that very request. Change only our declarations, leaving all other system content and tools in their original places. */
30
+ export function historyToolDeclarations(messages, tools, enabled) {
31
+ let changed = false;
32
+ const filtered = messages.map(message => {
33
+ if (message.role !== "system")
34
+ return message;
35
+ const added = message.toolsAdded?.filter(tool => !own(tool.name));
36
+ const removed = message.toolsRemoved?.filter(tool => !own(tool.name));
37
+ if (added?.length === message.toolsAdded?.length && removed?.length === message.toolsRemoved?.length)
38
+ return message;
39
+ changed = true;
40
+ const { toolsAdded: _, toolsRemoved: __, ...rest } = message;
41
+ return { ...rest, ...(added?.length ? { toolsAdded: added } : {}), ...(removed?.length ? { toolsRemoved: removed } : {}) };
42
+ });
43
+ // Leave an already-correct transcript byte-identical, including the original placement of our declarations.
44
+ const declared = getCurrentTools(convertToLlm(messages));
45
+ const wanted = enabled ? tools.filter(tool => own(tool.name)) : [];
46
+ if (enabled && wanted.every(tool => declared.some(current => own(current.name) && current.name === tool.name)) &&
47
+ declared.filter(tool => own(tool.name)).length === wanted.length)
48
+ return messages;
49
+ if (!enabled || !wanted.length)
50
+ return changed ? filtered : messages;
51
+ const at = filtered.findLastIndex(message => message.role === "system");
52
+ if (at < 0)
53
+ throw new Error("Missing Pi system message for history tool declarations");
54
+ const system = filtered[at];
55
+ if (system.role !== "system")
56
+ throw new Error("Missing Pi system message");
57
+ filtered[at] = { ...system, toolsAdded: [...(system.toolsAdded ?? []), ...wanted] };
58
+ return filtered;
59
+ }
@@ -1,7 +1,5 @@
1
1
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import type { TranscriptRecord } from "./tuck/core/transcript.js";
3
- /** The initiating prompt is quoted whole up to this many characters; the rest stays readable through tuck_recall. */
4
- export declare const MAX_TASK_CONTRACT_CHARACTERS = 12000;
5
3
  /** Owner default; older distinct paths are counted, not listed. */
6
4
  export declare const MAX_LEDGER_LINES = 60;
7
5
  export declare const LEDGER_HEADING = "Files touched by read, write and edit before this summary, newest first, with the last operation:";
@@ -1,8 +1,6 @@
1
1
  import { isLookupTool } from "./tuck/core/recall-operation.js";
2
2
  import { isDiscardedResponse } from "./protocol.js";
3
3
  import { extractPath } from "./tuck/core/tool-args.js";
4
- /** The initiating prompt is quoted whole up to this many characters; the rest stays readable through tuck_recall. */
5
- export const MAX_TASK_CONTRACT_CHARACTERS = 12000;
6
4
  /** Owner default; older distinct paths are counted, not listed. */
7
5
  export const MAX_LEDGER_LINES = 60;
8
6
  const LEDGER_TITLE = "Files";
@@ -3,7 +3,7 @@ import { type RecallPolicy } from './recall.js';
3
3
  /** Native branch edits supply exclusion; original global indices supply provenance. */
4
4
  export declare function omittedSourceIndices(entries: readonly SessionEntry[], branch: readonly SessionEntry[]): ReadonlySet<number>;
5
5
  /** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
6
- export declare function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>): string;
6
+ export declare function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>, sources?: readonly SessionEntry[], rangeOmitted?: ReadonlySet<number>): string;
7
7
  /** Custom quotes carry native entry identity; no text matching or legacy fallback. */
8
8
  export declare function filterCustomQuotes(summary: string, entries: readonly SessionEntry[], branch: readonly SessionEntry[], policy?: RecallPolicy): string;
9
9
  type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
@@ -1,9 +1,10 @@
1
1
  import { buildGlobalIndexById } from './tuck/core/global-indices.js';
2
- import { parseRecords, splitSummary, transcriptHeader } from './tuck/core/transcript.js';
2
+ import { parseRecords, serializeRecord, splitSummary, transcriptHeader } from './tuck/core/transcript.js';
3
+ import { textOf } from "./tuck/core/content.js";
4
+ import { isDiscardedResponse } from "./protocol.js";
3
5
  import { createRecall } from './recall.js';
4
6
  /** Native branch edits supply exclusion; original global indices supply provenance. */
5
7
  export function omittedSourceIndices(entries, branch) {
6
- const indices = buildGlobalIndexById(entries);
7
8
  const edits = new Set();
8
9
  for (const entry of branch)
9
10
  if (entry.type === 'context_edit') {
@@ -11,10 +12,13 @@ export function omittedSourceIndices(entries, branch) {
11
12
  throw new Error('pi-tuck does not support replacement context edits');
12
13
  edits.add(entry.targetId);
13
14
  }
15
+ if (!edits.size)
16
+ return new Set();
17
+ const indices = buildGlobalIndexById(entries);
14
18
  return new Set([...edits].flatMap(id => indices.has(id) ? [indices.get(id)] : []));
15
19
  }
16
20
  /** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
17
- export function filterOmittedSummary(summary, omitted) {
21
+ export function filterOmittedSummary(summary, omitted, sources, rangeOmitted = omitted) {
18
22
  if (!omitted.size)
19
23
  return summary;
20
24
  const split = splitSummary(summary);
@@ -22,7 +26,71 @@ export function filterOmittedSummary(summary, omitted) {
22
26
  throw new Error('Cannot apply native source omission to an unsupported compaction summary');
23
27
  const { body, closing, version } = split;
24
28
  const records = parseRecords(body, version), lines = body ? body.split('\n') : [];
25
- const retained = records.flatMap((record, i) => (record.sourceIndex !== undefined && omitted.has(record.sourceIndex)) || record.sourceIndices?.some(index => omitted.has(index)) ? [] : [lines[i]]);
29
+ // A broad range does not identify the role of an intervening source. Without
30
+ // original sources there is no evidence that an edit removes a counted unit.
31
+ const indices = sources && rangeOmitted.size && records.some(record => record.sourceRange) ? buildGlobalIndexById(sources) : undefined;
32
+ const sourceUnits = new Map();
33
+ const shownBySource = new Map();
34
+ for (const record of records)
35
+ if (record.kind === 'excerpt' && record.sourceIndex !== undefined && !record.span) {
36
+ const key = `${record.role}:${record.sourceIndex}`, shown = shownBySource.get(key) ?? [];
37
+ shown.push(record.text);
38
+ shownBySource.set(key, shown);
39
+ }
40
+ if (indices && sources)
41
+ for (const entry of sources) {
42
+ if (entry.type !== 'message')
43
+ continue;
44
+ const index = indices.get(entry.id);
45
+ if (index === undefined)
46
+ continue;
47
+ const message = entry.message;
48
+ const shown = shownBySource.get(`${message.role}:${index}`) ?? [];
49
+ if (message.role === 'user') {
50
+ const text = textOf(message.content);
51
+ sourceUnits.set(index, { role: 'user', missing: Number(Boolean(text.length) && !shown.includes(text)) });
52
+ }
53
+ else if (message.role === 'assistant' && !isDiscardedResponse(message)) {
54
+ const available = [...shown];
55
+ let missing = 0;
56
+ for (const part of message.content)
57
+ if (part.type === 'text' && part.text.trim()) {
58
+ const at = available.indexOf(part.text);
59
+ if (at < 0)
60
+ missing++;
61
+ else
62
+ available.splice(at, 1);
63
+ }
64
+ sourceUnits.set(index, { role: 'assistant', missing });
65
+ }
66
+ else if (message.role === 'toolResult')
67
+ sourceUnits.set(index, { role: 'toolResult', missing: 1 });
68
+ }
69
+ const retained = records.flatMap((record, at) => {
70
+ if ((record.sourceIndex !== undefined && omitted.has(record.sourceIndex)) || record.sourceIndices?.some(index => omitted.has(index)))
71
+ return [];
72
+ if (!record.sourceRange || !indices || record.count === undefined)
73
+ return [lines[at]];
74
+ const range = record.sourceRange;
75
+ let removed = 0;
76
+ const remaining = [];
77
+ for (const [index, unit] of sourceUnits) {
78
+ if (index < range.start || index > range.end || unit.role !== record.role || !unit.missing)
79
+ continue;
80
+ if (rangeOmitted.has(index))
81
+ removed += unit.missing;
82
+ else if (!omitted.has(index))
83
+ remaining.push(index);
84
+ }
85
+ if (!removed)
86
+ return [lines[at]];
87
+ const count = record.count - removed;
88
+ if (count <= 0)
89
+ return [];
90
+ const sourceRange = remaining.length ? { start: Math.min(...remaining), end: Math.max(...remaining) } : range;
91
+ const adjusted = { ...record, count, sourceRange };
92
+ return [version === 1 ? JSON.stringify(adjusted) : serializeRecord(adjusted)];
93
+ });
26
94
  return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
27
95
  }
28
96
  /** Custom quotes carry native entry identity; no text matching or legacy fallback. */
@@ -31,6 +99,9 @@ export function filterCustomQuotes(summary, entries, branch, policy = {}) {
31
99
  if (!split)
32
100
  throw new Error('Cannot resolve custom sources in an unsupported compaction summary');
33
101
  const { body, closing, version } = split;
102
+ const records = parseRecords(body, version);
103
+ if (!records.some(record => record.role === "custom"))
104
+ return summary;
34
105
  const omitted = new Set(branch.flatMap(entry => entry.type === 'context_edit' && entry.replacement === null ? [entry.targetId] : []));
35
106
  const eligible = createRecall(policy).isHistorySource, indices = buildGlobalIndexById(entries);
36
107
  const globalCounts = new Map(), branchSources = new Map();
@@ -42,7 +113,6 @@ export function filterCustomQuotes(summary, entries, branch, policy = {}) {
42
113
  branchSources.set(entry.id, found);
43
114
  }
44
115
  const lines = body ? body.split('\n') : [];
45
- const records = parseRecords(body, version);
46
116
  const retained = records.flatMap((record, i) => {
47
117
  if (record.role !== 'custom')
48
118
  return [lines[i]];
@@ -62,8 +132,20 @@ export function filterCustomQuotes(summary, entries, branch, policy = {}) {
62
132
  function projectMessages(messages, ctx, policy, allowForeign) {
63
133
  const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
64
134
  const omitted = omittedSourceIndices(entries, branch);
135
+ const lastCut = branch.findLastIndex(entry => entry.type === 'compaction');
136
+ const cut = branch[lastCut];
137
+ const afterCut = new Set();
138
+ if (omitted.size && cut?.type === 'compaction') {
139
+ const indices = buildGlobalIndexById(entries);
140
+ for (const entry of branch.slice(lastCut + 1))
141
+ if (entry.type === 'context_edit' && entry.replacement === null) {
142
+ const index = indices.get(entry.targetId);
143
+ if (index !== undefined)
144
+ afterCut.add(index);
145
+ }
146
+ }
65
147
  return messages.map(message => message.role === 'compactionSummary' && (!allowForeign || splitSummary(message.summary) !== undefined)
66
- ? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
148
+ ? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted, entries, cut?.type === 'compaction' && cut.summary === message.summary ? afterCut : new Set()), entries, branch, policy) } : message);
67
149
  }
68
150
  export function projectedMessages(messages, ctx, policy) {
69
151
  return projectMessages(messages, ctx, policy, false);
@@ -0,0 +1,19 @@
1
+ import type { SemanticState } from "./semantic-recall.js";
2
+ /** The version of the package this code was loaded from: the open session keeps it until Pi restarts, whatever is installed since. */
3
+ export declare const LOADED_VERSION: string;
4
+ /** `/tuck`: what an operator needs to know in words, from what is already recorded. It reads; it changes nothing the model sees. */
5
+ export declare function statusText(input: {
6
+ version: string;
7
+ active: boolean;
8
+ branch: readonly {
9
+ type: string;
10
+ tokensBefore?: unknown;
11
+ details?: unknown;
12
+ }[];
13
+ cutAt: number | undefined;
14
+ search: boolean;
15
+ semantic: {
16
+ state: SemanticState;
17
+ lastError: string | null;
18
+ };
19
+ }): string;
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /** The version of the package this code was loaded from: the open session keeps it until Pi restarts, whatever is installed since. */
4
+ export const LOADED_VERSION = (() => {
5
+ try {
6
+ const path = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../package.json" : "../../package.json", import.meta.url));
7
+ const version = JSON.parse(readFileSync(path, "utf8")).version;
8
+ return typeof version === "string" ? version : "unknown";
9
+ }
10
+ catch {
11
+ return "unknown";
12
+ }
13
+ })();
14
+ /** `/tuck`: what an operator needs to know in words, from what is already recorded. It reads; it changes nothing the model sees. */
15
+ export function statusText(input) {
16
+ if (!input.active)
17
+ return `pi-tuck ${input.version} is loaded but inactive in this host; Pi's own compaction runs.`;
18
+ const cuts = input.branch.filter(e => e.type === "compaction"), ours = cuts.filter(e => e.details?.compactor === "pi-tuck");
19
+ const last = ours.at(-1), before = last?.tokensBefore, after = last?.details?.tokensAfter;
20
+ const count = (n) => n.toLocaleString("en-US");
21
+ const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
22
+ : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
23
+ const native = cuts.length - ours.length;
24
+ const searchLine = !input.search ? "Search: by keyword (search by meaning needs macOS on Apple silicon)."
25
+ : input.semantic.state === "ready" || input.semantic.state === "waiting" ? "Search: by meaning and keyword."
26
+ : input.semantic.state === "preparing" ? "Search: by meaning and keyword; indexing history in the background."
27
+ : `Search: by keyword; ${lower(input.semantic.lastError ?? "semantic recall is off in this session (/tuck-semantic on)")}.`.replace(/\.\.$/, ".");
28
+ return [
29
+ `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
30
+ cutLine,
31
+ ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
32
+ input.cutAt === undefined ? "Cut point: adapts by itself." : `Cut point: fixed at cutAt ${input.cutAt} of the input room. Remove cutAt from pi-tuck.json to let it adapt.`,
33
+ searchLine,
34
+ ].join("\n");
35
+ }
36
+ const lower = (text) => text.charAt(0).toLowerCase() + text.slice(1);