pi-tuck 1.0.21 → 1.0.23

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.
@@ -0,0 +1,14 @@
1
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent';
2
+ import type { Messages } from './budget.js';
3
+ /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
+ * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
+ */
6
+ export declare function piContextSafetyMargin(): Promise<number>;
7
+ /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
8
+ export declare const REPLY_FLOOR_MIN = 3584;
9
+ /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
10
+ export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
11
+ /** Admission never promises more output than a model with a positive reply cap can produce. */
12
+ export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
13
+ /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
14
+ export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
@@ -0,0 +1,57 @@
1
+ import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
+ import { parseRecords, splitSummary } from './tuck/core/transcript.js';
3
+ /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
+ * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
+ */
6
+ export async function piContextSafetyMargin() {
7
+ try {
8
+ const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
9
+ import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
10
+ ]);
11
+ const window = 1_000_000, context = normalizeContext({ messages: [] });
12
+ const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window }, context, window);
13
+ if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window)
14
+ throw new Error('Invalid Pi context margin');
15
+ return margin;
16
+ }
17
+ catch {
18
+ return 4_096;
19
+ }
20
+ }
21
+ /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
22
+ export const REPLY_FLOOR_MIN = 3_584;
23
+ /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
24
+ export function branchReplyFloor(branch, reserve) {
25
+ let largest = REPLY_FLOOR_MIN;
26
+ for (const entry of branch) {
27
+ if (entry.type !== 'message' || entry.message.role !== 'assistant')
28
+ continue;
29
+ const message = entry.message;
30
+ if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message))
31
+ return reserve;
32
+ const output = message.usage?.output;
33
+ if (typeof output === 'number' && Number.isFinite(output) && output > largest)
34
+ largest = output;
35
+ }
36
+ return Math.min(reserve, Math.ceil(largest));
37
+ }
38
+ /** Admission never promises more output than a model with a positive reply cap can produce. */
39
+ export function requestReplyFloor(branch, reserve, maxTokens) {
40
+ const floor = branchReplyFloor(branch, reserve);
41
+ return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
42
+ }
43
+ /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
44
+ export function carriesPrompt(messages, prompt) {
45
+ if (!prompt.length)
46
+ return true;
47
+ return messages.some(message => {
48
+ if (message.role === 'user') {
49
+ const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
50
+ return text.includes(prompt);
51
+ }
52
+ if (message.role !== 'compactionSummary')
53
+ return false;
54
+ const split = splitSummary(message.summary);
55
+ return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
56
+ });
57
+ }
@@ -129,7 +129,9 @@ export declare function healDelayMs(attempts: number, integrity: boolean, delays
129
129
  export type SemanticState = 'disabled' | 'unavailable' | 'preparing' | 'ready' | 'waiting';
130
130
  /** Where the current view starts, as a branch position: the latest compaction entry's first kept entry. Earlier entries were compacted away. With no compaction, nothing is out of view. */
131
131
  export declare function viewStart(branch: SessionEntry[]): number;
132
- export declare function semanticEligibleSources(ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>, policy: RecallPolicy, access: () => void): Snapshot;
132
+ /** `pendingKeptId`: the first kept entry of a cut pi-tuck has applied to the requests but Pi has not yet saved (a prompt's cut is saved at the end of
133
+ * the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
134
+ export declare function semanticEligibleSources(ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>, policy: RecallPolicy, access: () => void, pendingKeptId?: string): Snapshot;
133
135
  export declare class SemanticRecallController {
134
136
  private readonly timing;
135
137
  private readonly ownerId;
@@ -294,6 +296,9 @@ export declare class SemanticRecallController {
294
296
  /** A plan for this snapshot that is fully indexed or, for a search, has all its out-of-view conversation indexed. The tool output behind it and the in-view tail are not worth waiting for: they keep indexing in the background and the coverage statement says how far they got.
295
297
  * With no out-of-view conversation (no cut) nothing waits: the search returns at once. Only a window whose out-of-view part is tool output alone still waits for the whole window. */
296
298
  private readyFor;
299
+ /** A search may rank a plan its own snapshot has grown from (every source in the plan still eligible, unchanged), as long as the session has not
300
+ * moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
301
+ private recheckGrown;
297
302
  private recheck;
298
303
  private joinPreparation;
299
304
  search(query: string, provider: () => Snapshot, signal?: AbortSignal, prior?: PriorResult[], words?: any): Promise<SemanticPage>;
@@ -427,6 +427,15 @@ export function viewStart(branch) {
427
427
  }
428
428
  const TOOL_OUTPUT = new Set(['toolResult', 'bashExecution']);
429
429
  const isToolOutput = (source) => TOOL_OUTPUT.has(source.row.role);
430
+ /** Whether `now` only grew from `plan`: the same session and project, and every source the plan ranks still eligible with the same hash. */
431
+ function grownFrom(now, plan) {
432
+ if (now.session !== plan.session || now.project !== plan.project)
433
+ return false;
434
+ if (now.id === plan.id)
435
+ return true;
436
+ const shas = new Map(now.sources.map(source => [source.entry.id, source.sha]));
437
+ return plan.sources.every(source => shas.get(source.entry.id) === source.sha);
438
+ }
430
439
  /** Sources in the order they are covered: out-of-view conversation (user messages, assistant text, tool-call names and arguments), then out-of-view tool output, then what is in view.
431
440
  * Each part is newest first, extending backward. */
432
441
  function windowOrder(snapshot) {
@@ -464,7 +473,9 @@ function windowCoverage(snapshot, prefix = indexedPrefix(snapshot)) {
464
473
  return { indexedCharacters, windowCharacters: window.windowCharacters, outOfViewCharacters: window.conversationOutOfView + window.toolOutOfView, eligibleCharacters: window.eligibleCharacters, oldestCoveredEntryId: oldest?.entry.id ?? null, oldestCoveredTime: time(oldest),
465
474
  conversation: { indexedCharacters: conversation, outOfViewCharacters: window.conversationOutOfView }, toolResults: { indexedCharacters: tools, outOfViewCharacters: window.toolOutOfView, oldestCoveredTime: time(oldestTool) } };
466
475
  }
467
- export function semanticEligibleSources(ctx, policy, access) {
476
+ /** `pendingKeptId`: the first kept entry of a cut pi-tuck has applied to the requests but Pi has not yet saved (a prompt's cut is saved at the end of
477
+ * the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
478
+ export function semanticEligibleSources(ctx, policy, access, pendingKeptId) {
468
479
  try {
469
480
  access();
470
481
  }
@@ -486,8 +497,9 @@ export function semanticEligibleSources(ctx, policy, access) {
486
497
  const orders = new Map(branch.map((entry, order) => [entry.id, order]));
487
498
  const sources = eligible.flatMap(entry => { const order = orders.get(entry.id); const row = recall.discoveryEntry(entry, attempts, links); if (!row)
488
499
  return []; const { sourceText: unusedText, ranges: unusedRanges, ...readable } = row; return [{ entry, row: readable, sourceText: row.sourceText, ranges: row.ranges, order, sha: hash({ entry, order, row: readable, ranges: row.ranges, index: indices.get(entry.id) }) }]; });
489
- const session = ctx.sessionManager.getSessionId(), id = hash({ session, project: resolve(ctx.cwd), branch: branch.map(e => [e.id, e.parentId]), sources: sources.map(s => [s.entry.id, s.sha]) });
490
- return { id, session, project: ctx.cwd, sources, units: [], complete: false, throughEntryId: sources.at(-1)?.entry.id ?? null, viewStart: viewStart(branch), indexed: new Set() };
500
+ const pending = pendingKeptId === undefined ? -1 : branch.findIndex(e => e.id === pendingKeptId), start = Math.max(viewStart(branch), pending);
501
+ const session = ctx.sessionManager.getSessionId(), id = hash({ session, project: resolve(ctx.cwd), branch: branch.map(e => [e.id, e.parentId]), sources: sources.map(s => [s.entry.id, s.sha]), ...(pending >= 0 ? { pendingKeptId } : {}) });
502
+ return { id, session, project: ctx.cwd, sources, units: [], complete: false, throughEntryId: sources.at(-1)?.entry.id ?? null, viewStart: start, indexed: new Set() };
491
503
  }
492
504
  export class SemanticRecallController {
493
505
  timing;
@@ -647,11 +659,19 @@ export class SemanticRecallController {
647
659
  return this.worker;
648
660
  }
649
661
  path(snapshot) { const directory = this.config.indexDirectory; outsideProject(snapshot.project, directory, 'index'); return join(directory, hash({ owner: this.ownerId, session: snapshot.session, project: resolve(snapshot.project) }) + '.sqlite'); }
650
- async index(action, snapshot, extra = {}) { const path = this.path(snapshot), generation = this.generation, worker = await this.runtime(snapshot.project); if (!this.enabled || generation !== this.generation)
651
- throw abort(); if (this.provider)
652
- this.recheck(this.provider, snapshot); this.indexFiles.add(path); const result = await worker.call('index', { path, identity: this.identity, action, projectDirectory: snapshot.project, ...extra }); if (!this.enabled || generation !== this.generation)
653
- throw abort(); if (this.provider)
654
- this.recheck(this.provider, snapshot); return result; }
662
+ async index(action, snapshot, extra = {}, boundId) { const path = this.path(snapshot), generation = this.generation, worker = await this.runtime(snapshot.project); if (!this.enabled || generation !== this.generation)
663
+ throw abort(); if (this.provider) {
664
+ if (boundId !== undefined)
665
+ this.recheckGrown(this.provider, snapshot, boundId);
666
+ else
667
+ this.recheck(this.provider, snapshot);
668
+ } /* a search's scoring may read a plan its snapshot grew from */ /* a search's scoring may read a plan its snapshot grew from */ this.indexFiles.add(path); const result = await worker.call('index', { path, identity: this.identity, action, projectDirectory: snapshot.project, ...extra }); if (!this.enabled || generation !== this.generation)
669
+ throw abort(); if (this.provider) {
670
+ if (boundId !== undefined)
671
+ this.recheckGrown(this.provider, snapshot, boundId);
672
+ else
673
+ this.recheck(this.provider, snapshot);
674
+ } return result; }
655
675
  async prepare(provider, full = true) {
656
676
  if (!this.enabled)
657
677
  return;
@@ -801,6 +821,10 @@ export class SemanticRecallController {
801
821
  /** A plan for this snapshot that is fully indexed or, for a search, has all its out-of-view conversation indexed. The tool output behind it and the in-view tail are not worth waiting for: they keep indexing in the background and the coverage statement says how far they got.
802
822
  * With no out-of-view conversation (no cut) nothing waits: the search returns at once. Only a window whose out-of-view part is tool output alone still waits for the whole window. */
803
823
  readyFor(id, forSearch) { const plan = this.current; return Boolean(plan && plan.id === id && (plan.complete || (forSearch && plan.window !== undefined && plan.window.searchUnits > 0 && plan.units.slice(0, plan.window.searchUnits).every(unit => plan.indexed.has(unit.key))))); }
824
+ /** A search may rank a plan its own snapshot has grown from (every source in the plan still eligible, unchanged), as long as the session has not
825
+ * moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
826
+ recheckGrown(provider, snapshot, boundId) { const now = provider(); if (now.id !== boundId || !grownFrom(now, snapshot))
827
+ throw new Error('Eligible branch or source changed before semantic admission'); }
804
828
  recheck(provider, snapshot) { const now = provider(); if (now.id !== snapshot.id || now.session !== snapshot.session || now.project !== snapshot.project)
805
829
  throw new Error('Eligible branch or source changed before semantic admission'); }
806
830
  async joinPreparation(deadline, signal) {
@@ -868,22 +892,25 @@ export class SemanticRecallController {
868
892
  const prepared = !this.task || await this.joinPreparation(deadline, signal);
869
893
  if (signal?.aborted || queryGeneration !== this.generation)
870
894
  throw abort();
871
- if (!prepared || performance.now() >= deadline)
872
- return incomplete();
873
- this.error = null;
874
- this.schedule(provider);
895
+ if (prepared && performance.now() < deadline) {
896
+ this.error = null;
897
+ this.schedule(provider);
898
+ await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
899
+ }
875
900
  }
876
- else if (!this.current.complete) {
877
- this.error = null;
878
- this.schedule(provider);
901
+ else {
902
+ if (!this.current.complete) {
903
+ this.error = null;
904
+ this.schedule(provider);
905
+ }
906
+ await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
879
907
  }
880
- await this.waitForReady(Math.max(0, deadline - performance.now()), signal, bound.id, true);
881
908
  if (signal?.aborted || queryGeneration !== this.generation)
882
909
  throw abort();
883
910
  }
884
911
  // Ranking needs a plan for the bound snapshot and an indexed prefix; the coverage statement describes exactly that prefix and nothing else is ranked, so a search that reaches its wait limit with part of the window indexed ranks that part. Only an empty prefix is `indexing_incomplete`.
885
- const snapshot = this.current;
886
- if (!snapshot || snapshot.id !== bound.id || (snapshot.units.length ? indexedPrefix(snapshot) === 0 : !snapshot.complete)) {
912
+ const snapshot = this.current, older = snapshot !== undefined && snapshot.id !== bound.id && grownFrom(bound, snapshot);
913
+ if (!snapshot || (snapshot.id !== bound.id && !older) || (snapshot.units.length ? indexedPrefix(snapshot) === 0 : !snapshot.complete)) {
887
914
  const failure = this.error ?? this.healReason;
888
915
  return failure && !CHURN.test(failure) ? unavailable() : incomplete();
889
916
  }
@@ -891,15 +918,15 @@ export class SemanticRecallController {
891
918
  return { status: 'no_matches', notice: 'No eligible original source units in this snapshot.', coverage: this.status(), method: 'embedding', groups: [] };
892
919
  try {
893
920
  const worker = await this.runtime(snapshot.project);
894
- this.recheck(provider, snapshot);
921
+ this.recheckGrown(provider, snapshot, bound.id);
895
922
  const encoded = await worker.call('embed', { texts: [query], kind: 'query' }, signal);
896
923
  this.operations.queryEmbeddings++;
897
- this.recheck(provider, snapshot);
924
+ this.recheckGrown(provider, snapshot, bound.id);
898
925
  const prefix = indexedPrefix(snapshot), stored = snapshot.units.slice(0, prefix), byKey = new Map(stored.map(u => [u.key, u])), covered = windowCoverage(snapshot, prefix);
899
926
  let ranked = [], observed = 0;
900
927
  for (let offset = 0; offset < stored.length; offset += SCORE_CHUNK) {
901
- this.recheck(provider, snapshot);
902
- const scored = await this.index('score', snapshot, { keys: stored.slice(offset, offset + SCORE_CHUNK).map(u => u.key), query: encoded.vectors[0] });
928
+ this.recheckGrown(provider, snapshot, bound.id);
929
+ const scored = await this.index('score', snapshot, { keys: stored.slice(offset, offset + SCORE_CHUNK).map(u => u.key), query: encoded.vectors[0] }, bound.id);
903
930
  observed += scored.observed;
904
931
  ranked.push(...scored.ranked.map(row => ({ unit: byKey.get(row.key), score: row.score })));
905
932
  ranked.sort((a, b) => b.score - a.score);
@@ -912,7 +939,7 @@ export class SemanticRecallController {
912
939
  throw new Error('Snapshot index coverage changed');
913
940
  if (signal?.aborted || queryGeneration !== this.generation || !this.enabled)
914
941
  throw abort();
915
- this.recheck(provider, snapshot);
942
+ this.recheckGrown(provider, snapshot, bound.id);
916
943
  const final = provider();
917
944
  const finalSources = new Map(final.sources.map(source => [source.entry.id, source.sha]));
918
945
  if (snapshot.sources.some(source => finalSources.get(source.entry.id) !== source.sha))
@@ -920,7 +947,7 @@ export class SemanticRecallController {
920
947
  // What the caller already read only spares it a repeat. A reference that does not match this history spares nothing, and it must not fail the search.
921
948
  const usable = prior.filter(reference => { const source = snapshot.sources.find(s => s.entry.id === reference.entryId); return Number.isSafeInteger(reference.start) && Number.isSafeInteger(reference.end) && reference.start <= reference.end && source !== undefined && reference.sourceSha === source.sha && reference.end <= source.sourceText.length; }), ignored = prior.length - usable.length;
922
949
  const previous = [...usable, ...(words?.hits ?? []).map((hit) => ({ entryId: hit.entryId, start: hit.read.offset, end: hit.read.offset + hit.read.limit, sourceSha: snapshot.sources.find(s => s.entry.id === hit.entryId)?.sha }))];
923
- return { status: 'ok', notice: 'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.' + (ignored ? ` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.` : '') + ` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime ?? covered.oldestCoveredTime ?? 'an unknown time'}); use tuck_recall for the rest.`, coverage: { ...this.status(), window: covered }, method: 'embedding', groups: this.assemble(ranked.map(hit => hit.unit), snapshot, previous) };
950
+ return { status: 'ok', notice: 'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.' + (ignored ? ` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.` : '') + ` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime ?? covered.oldestCoveredTime ?? 'an unknown time'}); use tuck_recall for the rest.` + (older ? ` This ranking covers the history through entry ${snapshot.throughEntryId}: later entries are still being indexed, so search them by keyword.` : ''), coverage: { ...this.status(), window: covered }, method: 'embedding', groups: this.assemble(ranked.map(hit => hit.unit), snapshot, previous) };
924
951
  }
925
952
  catch (error) {
926
953
  if (error instanceof Error && error.name === 'SemanticAccessError') {
@@ -16,8 +16,8 @@ const startBudget = () => {
16
16
  const deadline = Date.now() + SEARCH_BUDGET_MS;
17
17
  return () => {
18
18
  if (Date.now() > deadline) {
19
- throw new Error(`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms. Simplify the pattern — ` +
20
- "nested quantifiers such as (a+)+ can make matching blow up.");
19
+ throw new Error(`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms on this history. ` +
20
+ "Use more specific words, or read an entry by index.");
21
21
  }
22
22
  };
23
23
  };
@@ -74,7 +74,7 @@ export interface Tuck {
74
74
  import { compileDialogue } from "./tuck/core/summarize.js";
75
75
  export declare const tuck: Tuck;
76
76
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
77
- export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
77
+ export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean, protectedPromptEntryId?: string): Draft;
78
78
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
79
79
  /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
80
80
  * a run a custom message starts); the session has moved on from an ended run.
@@ -86,6 +86,7 @@ export declare function tuckObservedDraft(tuck: Tuck, input: {
86
86
  operatorManual?: boolean;
87
87
  newPrompt?: boolean;
88
88
  reRenderSummary?: boolean;
89
+ protectedPromptEntryId?: string;
89
90
  }, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
90
91
  /** Numeric references use the original session index, then intersect with readable current-branch entries. */
91
92
  export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
package/dist/src/tuck.js CHANGED
@@ -19,8 +19,8 @@ import { prepareKeywordQuery } from "./tuck/core/keyword-query.js";
19
19
  import { searchEntriesDetailed } from "./tuck/core/search-entries.js";
20
20
  export const tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
21
21
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
22
- export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
23
- return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
22
+ export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false, protectedPromptEntryId) {
23
+ return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt, protectedPromptEntryId }, ctx, reserveTokens, measure, policy, retainCurrentInput);
24
24
  }
25
25
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
26
26
  /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
@@ -115,6 +115,9 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
115
115
  const current = entries[userPosition];
116
116
  const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
117
117
  const textOnly = isTextOnly(userContent);
118
+ // Steering can become the latest user source while the run still owns its original prompt.
119
+ const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
120
+ const starting = entries[startingPosition];
118
121
  // Anchor the actual first source only; a native omission never promotes a later prompt.
119
122
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
120
123
  const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
@@ -277,8 +280,10 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
277
280
  };
278
281
  if (userPosition >= 0 && userPosition < boundary && textOnly)
279
282
  add(current, Infinity);
283
+ if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id)
284
+ add(starting, Infinity);
280
285
  for (const entry of anchors)
281
- if (entry.id !== current?.id && positions.get(entry.id) < boundary)
286
+ if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id) < boundary)
282
287
  add(entry, length);
283
288
  const omissions = [];
284
289
  for (const role of ["user", "assistant", "toolResult"]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
1
+ export const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";