pi-tuck 1.0.26 → 1.0.28

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.
@@ -62,20 +62,26 @@ export const thinkingOnlyLength = (message: AgentMessage): boolean => message.ro
62
62
  export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
63
63
  (message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
64
64
 
65
- /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
66
- export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
67
- let largest = REPLY_FLOOR_MIN, thinkingStop = false;
65
+ /** One branch reader for the reserve override and reply floor, including history before cuts. */
66
+ export function replyFit(branch: readonly SessionEntry[]): { failed: boolean; largestOutput: number; thinkingStop: boolean } {
67
+ let failed = false, largestOutput = 0, thinkingStop = false;
68
68
  for (const entry of branch) {
69
69
  if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
70
70
  const message = entry.message;
71
- if (needsFullReplyReserve(message)) return reserve;
71
+ failed ||= needsFullReplyReserve(message);
72
72
  thinkingStop ||= thinkingOnlyLength(message);
73
73
  const output = message.usage?.output;
74
- if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
74
+ if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput) largestOutput = output;
75
75
  }
76
- // Include the exhausted thinking reply itself, even across later cuts/restarts. Its observed output must fit again;
77
- // requestReplyFloor applies the model cap. Ordinary branches retain their existing reserve cap.
78
- return thinkingStop ? Math.ceil(largest) : Math.min(reserve, Math.ceil(largest));
76
+ return { failed, largestOutput, thinkingStop };
77
+ }
78
+
79
+ export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
80
+ const { failed, largestOutput, thinkingStop } = replyFit(branch);
81
+ if (failed) return reserve;
82
+ // Include exhausted thinking output across cuts/restarts; requestReplyFloor applies the model cap.
83
+ const largest = Math.ceil(Math.max(REPLY_FLOOR_MIN, largestOutput));
84
+ return thinkingStop ? largest : Math.min(reserve, largest);
79
85
  }
80
86
 
81
87
  /** Admission never promises more output than a model with a positive reply cap can produce. */
@@ -84,17 +90,17 @@ export function requestReplyFloor(branch: readonly SessionEntry[], reserve: numb
84
90
  return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
85
91
  }
86
92
 
87
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
88
- export function carriesPrompt(messages: Messages, prompt: string): boolean {
93
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
94
+ export function carriesPrompt(messages: Messages, prompt: string, role: 'user' | 'custom' = 'user'): boolean {
89
95
  if (!prompt.length) return true;
90
96
  return messages.some(message => {
91
- if (message.role === 'user') {
97
+ if (message.role === role) {
92
98
  const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
93
99
  return text.includes(prompt);
94
100
  }
95
101
  if (message.role !== 'compactionSummary') return false;
96
102
  const split = splitSummary(message.summary);
97
103
  return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record =>
98
- record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
104
+ record.kind === 'excerpt' && record.role === role && record.text.includes(prompt));
99
105
  });
100
106
  }
package/src/run.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { SessionManager, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft } from '@earendil-works/pi-coding-agent';
2
+ import { hasTuckCut } from './history-tools.js';
3
+ import { viewStart } from './semantic-recall.js';
4
+ import { textOf } from './tuck/core/content.js';
5
+ import type { Clip } from './clip.js';
6
+
7
+ export type CutEntry = { type: 'compaction'; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
8
+ type Context = Pick<ExtensionContext, 'cwd' | 'sessionManager'>;
9
+ export interface CutView { session: ExtensionContext['sessionManager']; keptFromId: string | undefined; viewStart: number; hasTuckCut: boolean; pending: boolean }
10
+
11
+ /** The only owner of a cut that requests carry before Pi saves it. */
12
+ export class PendingCut {
13
+ private cut?: CutEntry;
14
+ private branchLength = 0;
15
+ private preview?: { manager: Context['sessionManager']; cwd: string; leaf: string | null; view: CutView };
16
+ plan(cut: CutEntry, branchLength: number) { this.cut = cut; this.branchLength = branchLength; this.preview = undefined; }
17
+ clear() { this.cut = undefined; this.branchLength = 0; this.preview = undefined; }
18
+ take() { const cut = this.cut; this.clear(); return cut; }
19
+ view(ctx: Context, entries: readonly SessionBoundaryDraft[] = []): CutView {
20
+ const live = ctx.sessionManager, leaf = live.getLeafId?.(), held = this.preview;
21
+ // Native entries are append-only; a leaf move invalidates both the saved and pending views, as in the eligibility cache.
22
+ if (!entries.length && leaf !== undefined && held && held.manager === live && held.cwd === ctx.cwd && held.leaf === leaf) return held.view;
23
+ const branch = live.getBranch(), cut = this.cut;
24
+ const remember = (view: CutView) => {
25
+ if (!entries.length && leaf !== undefined) this.preview = { manager: live, cwd: ctx.cwd, leaf, view };
26
+ return view;
27
+ };
28
+ if (!cut && !entries.length) {
29
+ const last = branch.findLast(entry => entry.type === 'compaction');
30
+ return remember({ session: live, keptFromId: last?.type === 'compaction' ? last.firstKeptEntryId : undefined,
31
+ viewStart: viewStart(branch), hasTuckCut: hasTuckCut(branch), pending: false });
32
+ }
33
+ // A compact-all plan predates the prompt. Keep from the first appended entry, skipping our capacity receipts.
34
+ const keptFromId = cut && (cut.firstKeptEntryId || branch.slice(this.branchLength)
35
+ .find(entry => entry.type !== 'custom' || entry.customType !== 'pi-tuck-capacity')?.id || '');
36
+ const header = live.getHeader();
37
+ if (!header) throw new Error("Missing native session header for the prompt's cut");
38
+ const session = SessionManager.inMemory(ctx.cwd, undefined, [header, ...branch]);
39
+ if (cut) session.appendCompaction(cut.summary, keptFromId!, 0, cut.details, true);
40
+ // Retry/settlement drafts model the same request view, including an omitted failed reply.
41
+ for (const entry of entries) {
42
+ if (entry.type === 'context_edit') session.appendContextEdit(entry.targetId, entry.replacement);
43
+ else if (entry.type === 'compaction') session.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
44
+ else if (entry.type === 'custom') session.appendCustomEntry(entry.customType, entry.data);
45
+ else session.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
46
+ }
47
+ const previewBranch = session.getBranch(), last = previewBranch.findLast(entry => entry.type === 'compaction');
48
+ return remember({ session, keptFromId: last?.type === 'compaction' ? last.firstKeptEntryId : undefined,
49
+ viewStart: viewStart(previewBranch), hasTuckCut: hasTuckCut(previewBranch), pending: Boolean(cut) });
50
+ }
51
+ }
52
+
53
+ /** Text bridges before Pi appends the starting message. Once visible, every reader uses its entry id.
54
+ * Bind on the first request/compaction read after message_end (which Pi emits before appending), before steering can replace it. */
55
+ export class StartingPrompt {
56
+ private entryId?: string;
57
+ private bridge?: string;
58
+ constructor(bridge: string, private readonly branchLength: number, readonly role: 'user' | 'custom' = 'user') { this.bridge = bridge; }
59
+ id(branch: readonly SessionEntry[]) {
60
+ if (!this.entryId && this.bridge !== undefined) {
61
+ const text = this.bridge;
62
+ this.entryId = branch.slice(this.branchLength).find(entry => this.role === 'custom'
63
+ ? entry.type === 'custom_message' && textOf(entry.content) === text
64
+ : entry.type === 'message' && entry.message.role === 'user' && textOf(entry.message.content).includes(text))?.id;
65
+ if (this.entryId) this.bridge = undefined;
66
+ }
67
+ return this.entryId;
68
+ }
69
+ text(branch: readonly SessionEntry[]) {
70
+ const id = this.id(branch), entry = branch.find(entry => entry.id === id);
71
+ if (entry?.type === 'message' && entry.message.role === 'user') return textOf(entry.message.content);
72
+ if (entry?.type === 'custom_message') return textOf(entry.content);
73
+ if (this.bridge !== undefined) return this.bridge;
74
+ throw new Error('The starting prompt entry is no longer on this branch');
75
+ }
76
+ }
77
+
78
+ export interface Run {
79
+ phase: 'decided' | 'starting' | 'admitted' | 'failed';
80
+ claimed: boolean;
81
+ expired: boolean;
82
+ prompt?: StartingPrompt;
83
+ cut: PendingCut;
84
+ clips: Map<string, Clip>;
85
+ pendingBoundaryCommit: boolean;
86
+ thinkingRetryPending: boolean;
87
+ failure?: Error;
88
+ refusalReceipted: boolean;
89
+ }
90
+ export const createRun = (phase: Run['phase']): Run => ({ phase, claimed: false, expired: false,
91
+ cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
@@ -55,7 +55,7 @@ const expandedGroups = Symbol('expanded semantic groups');
55
55
  export interface SemanticEvidenceReference extends PriorResult {basis:'explicit prior or word result'|'earlier source text in this response';}
56
56
  export interface SemanticSourceEvidence extends ReadableEntry {sourceOrder?:number;sourceSha:string;textOffset:number;totalCharacters:number;read:{entryId:string;offset:number;limit:number};complete:boolean;evidenceReference?:SemanticEvidenceReference;notice?:string;}
57
57
  export interface SemanticGroup {sources:SemanticSourceEvidence[];notice:string;}
58
- export interface SemanticPage {status:string;notice:string;coverage:ReturnType<SemanticRecallController["status"]>;groups:SemanticGroup[];method?:string;}
58
+ export interface SemanticPage {status:string;notice:string;coverage:ReturnType<SemanticRecallController["requestCoverage"]>;groups:SemanticGroup[];method?:string;}
59
59
  interface NativeReceipt {op:'settled';activeProcesses:0;exitCode:number|null;}
60
60
  const nativeBase64=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
61
61
  function nativeObject(text:string):Record<string,unknown>{
@@ -122,6 +122,32 @@ export function healDelayMs(attempts:number,integrity:boolean,delays:readonly nu
122
122
  /** Errors that mean "the history moved or a slice ended; try again", not that the runtime failed. They do not make the state `unavailable`. */
123
123
  const CHURN=/indexing incomplete|history changed|source changed before|branch or source changed|cancelled/i;
124
124
  export type SemanticState='disabled'|'unavailable'|'preparing'|'ready'|'waiting';
125
+ export type SemanticSetup = 'off-settings' | 'off-user' | 'not-installed' | 'off-platform' | 'off-project' | 'starting' | 'failed' | 'ready';
126
+ interface SetupDetail { installed?: boolean; reason?: string; stage?: 'setup' | 'start'; notice?: string; announced?: boolean }
127
+ /** Setup and operator wording have one owner; runtime indexing state remains separate. */
128
+ export function semanticSetupMessage(setup: SemanticSetup, detail: SetupDetail = {}): string | null {
129
+ switch (setup) {
130
+ case 'not-installed': return 'Semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works meanwhile';
131
+ case 'starting': return detail.installed ? 'Semantic recall is starting; word recall works meanwhile' : 'Semantic recall is being set up; word recall works meanwhile';
132
+ case 'failed': return detail.stage === 'start' ? detail.reason ?? 'Semantic recall could not start' : `Semantic recall setup failed: ${detail.reason}`;
133
+ default: return null;
134
+ }
135
+ }
136
+ export function semanticSetupNotice(setup: SemanticSetup, detail: SetupDetail = {}): { message: string; level: 'info' | 'warning' } | undefined {
137
+ let message: string | undefined, level: 'info' | 'warning' = 'info';
138
+ switch (setup) {
139
+ case 'off-platform': message = 'pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.'; break;
140
+ case 'off-project': message = "pi-tuck: semantic recall is off here because Pi's agent folder is inside this folder; open Pi in a project folder to use it."; break;
141
+ case 'not-installed': message = 'pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.'; level = 'warning'; break;
142
+ case 'starting': message = detail.notice; break;
143
+ case 'ready': if (detail.announced) message = 'pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.'; break;
144
+ case 'failed': message = detail.stage === 'start'
145
+ ? `pi-tuck: semantic recall could not start (${detail.reason}); it tries again when a session starts.`
146
+ : `pi-tuck: semantic recall setup failed (${detail.reason}). Word recall works; setup retries when a session starts.`; level = 'warning'; break;
147
+ }
148
+ return message ? { message, level } : undefined;
149
+ }
150
+
125
151
  const PROBE_TEXT='pi-tuck semantic recall status probe';
126
152
  class LocalWorker {
127
153
  private nativeHelper=false;private jobId?:string;private helperReceipt?:()=>NativeReceipt|undefined;private nativeExit?:{code:number|null;signal:NodeJS.Signals|null};
@@ -232,12 +258,13 @@ export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManage
232
258
  const eligible=branch.filter(e=>!omitted.has(e.id)&&ids.get(e.id)===1),recall=createRecall(policy),attempts=recall.recallAttempts(eligible),links=recall.producerReads(eligible),indices=buildGlobalIndexById(all);
233
259
  const orders=new Map(branch.map((entry,order)=>[entry.id,order]));
234
260
  const sources=eligible.flatMap(entry=>{const order=orders.get(entry.id)!;const row=recall.discoveryEntry(entry,attempts,links);if(!row)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)})}];});
235
- const pending=pendingKeptId===undefined?-1:branch.findIndex(e=>e.id===pendingKeptId),start=Math.max(viewStart(branch),pending);
261
+ const pending=pendingKeptId===undefined?-1:pendingKeptId===''?branch.length:branch.findIndex(e=>e.id===pendingKeptId),start=Math.max(viewStart(branch),pending);
236
262
  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}:{})});return{id,session,project:ctx.cwd,sources,units:[],complete:false,throughEntryId:sources.at(-1)?.entry.id??null,viewStart:start,indexed:new Set()};
237
263
  }
238
264
  export class SemanticRecallController {
239
265
  private readonly ownerId=randomUUID();private readonly indexFiles=new Set<string>();private closing?:Promise<void>;private joiningSearches=0;private activeSearches=new Set<Promise<SemanticPage>>();private config?:SemanticRecallConfig;private worker?:LocalWorker;private identity='';private provider?:()=>Snapshot;private generation=0;private cleanupError:string|null=null;private dirty=false;private operations={preparationEmbeddings:0,queryEmbeddings:0};
240
266
  // The fields below decide `status().state`; their setters announce a change to `onStateChange` listeners.
267
+ private setup: SemanticSetup = 'off-user'; private setupDetail: SetupDetail = {};
241
268
  private _enabled=false;private _current?:Snapshot;private _task?:Promise<void>;private _timer?:ReturnType<typeof setTimeout>;private _error:string|null=null;
242
269
  private get enabled(){return this._enabled;}private set enabled(value:boolean){this._enabled=value;this.touch();}
243
270
  private get current(){return this._current;}private set current(value:Snapshot|undefined){this._current=value;this.touch();}
@@ -259,14 +286,19 @@ export class SemanticRecallController {
259
286
  onStateChange(listener:(state:SemanticState,reason:string|null)=>void):()=>void{this.listeners.add(listener);return()=>{this.listeners.delete(listener);};}
260
287
  private touch(){if(this.announcing)return;this.announcing=true;queueMicrotask(()=>{this.announcing=false;const {state,lastError}=this.status(),key=state+'\0'+(lastError??'');if(key===this.announced)return;this.announced=key;for(const listener of [...this.listeners])try{listener(state,lastError);}catch{}});}
261
288
  /** A host that could not configure the runtime records why a requested start failed. The controller stays disabled and `lastError` carries the reason until the next `enable`. */
262
- refuse(reason:string){this.error=reason;}
289
+ setSetup(setup: SemanticSetup, detail: SetupDetail = {}) { const announced = setup === 'ready' && this.setupDetail.announced; this.setup = setup; this.setupDetail = announced ? { ...detail, announced: true } : detail; this.touch(); }
290
+ /** Compatibility for hosts that already record an external start failure. */
291
+ refuse(reason:string){this.setSetup('failed', { stage: 'start', reason });}
292
+ setupNotice(){return semanticSetupNotice(this.setup, this.setupDetail);}
263
293
  configure(config:SemanticRecallConfig){if(this.enabled||this.worker||this.task)throw new Error('Disable before changing semantic configuration');for(const [key,max] of [['queryWaitMs',120000],['prepareWorkMs',120000],['maxUnits',MAX_INDEX_UNITS],['maxSourceCharacters',2000000]] as const){const value=config[key];if(value!==undefined&&(!Number.isSafeInteger(value)||value<1||value>max))throw new Error('Invalid bounded semantic configuration: '+key);}if('rerank' in config)throw new Error('Obsolete rerank configuration: this release supports embeddings only');for(const key of ['pythonPath','runtimeRoot','indexDirectory'] as const)if(typeof config[key]!=='string'||!config[key])throw new Error('Invalid semantic runtime configuration');if(config.scratchDirectory!==undefined&&(typeof config.scratchDirectory!=='string'||!config.scratchDirectory||!isAbsolute(config.scratchDirectory)))throw new Error('Invalid semantic scratchDirectory; absolute directory required');if(config.nativeOwnerHelperPath!==undefined&&(typeof config.nativeOwnerHelperPath!=='string'||!config.nativeOwnerHelperPath||!isAbsolute(config.nativeOwnerHelperPath)))throw new Error('Invalid semantic nativeOwnerHelperPath; absolute verified helper required');const normalized={...config,pythonPath:resolve(config.pythonPath),runtimeRoot:resolve(config.runtimeRoot),indexDirectory:resolve(config.indexDirectory),...(config.scratchDirectory===undefined?{}:{scratchDirectory:resolve(config.scratchDirectory)})};outsideRuntime(normalized.runtimeRoot,normalized.indexDirectory,'index');outsideRuntime(normalized.runtimeRoot,scratchParent(normalized),'scratch');this.config=normalized;}
264
294
  /** Ordinary external settings remain caller-selected/outside-project. The
265
295
  * optional override is a current-process verified dependency, not Task data. */
266
296
  async configureExternal(project:string,selectedPath=runtimeConfigPath(defaultRoot()),runtime?:{nativeOwnerHelperPath?:string}){const path=resolve(selectedPath);outsideProject(project,path,'configuration');if(runtime?.nativeOwnerHelperPath!==undefined)outsideProject(project,runtime.nativeOwnerHelperPath,'runtime');const text=await readFile(path,'utf8').catch((error:NodeJS.ErrnoException)=>{throw error.code==='ENOENT'?new Error('Semantic recall is not set up yet: pi-tuck sets it up when a session starts, unless pi-tuck.json turns it off'):error;});
267
297
  this.configure({...JSON.parse(text) as SemanticRecallConfig,...(runtime?.nativeOwnerHelperPath===undefined?{}:{nativeOwnerHelperPath:runtime.nativeOwnerHelperPath})});}
268
- enable(){if(this.closing||this.joiningSearches||this.cleanupError)throw new Error('Semantic cleanup is unresolved; retry close before enable');if(!this.config)throw new Error('Optional local semantic runtime is not configured');this.enabled=true;this.error=null;this.healReason=null;this.healAttempts=0;this.cancelHeal();if(this.provider&&this.active)this.schedule(this.provider);}
269
- status(){return{enabled:this.enabled,configured:Boolean(this.config),cleanupError:this.cleanupError,state:(!this.enabled?'disabled':this.healReason!==null||(this.error!==null&&!CHURN.test(this.error))?'unavailable':this.task||this.timer?'preparing':this.current?.complete?'ready':'waiting') as SemanticState,snapshotId:this.current?.id??null,throughEntryId:this.current?.throughEntryId??null,eligibleSources:this.current?.sources.length??0,totalUnits:this.current?.units.length??0,coveredUnits:this.current?.indexed.size??0,indexingComplete:Boolean(this.current?.complete),lastError:this.error??this.healReason,window:this.current?windowCoverage(this.current):null,recovery:this.healReason===null?null:{attempts:this.healAttempts,integrity:this.healIntegrity,retryAt:this.healRetryAt===null?null:new Date(this.healRetryAt).toISOString()},operations:{...this.operations},limits:{keyword:16000,broad:20000}};}
298
+ enable(){if(this.closing||this.joiningSearches||this.cleanupError)throw new Error('Semantic cleanup is unresolved; retry close before enable');if(!this.config)throw new Error('Optional local semantic runtime is not configured');this.setSetup('ready');this.enabled=true;this.error=null;this.healReason=null;this.healAttempts=0;this.cancelHeal();if(this.provider&&this.active)this.schedule(this.provider);}
299
+ status(){return{setup:this.setup,setupMessage:semanticSetupMessage(this.setup,this.setupDetail),enabled:this.enabled,configured:Boolean(this.config),cleanupError:this.cleanupError,state:(!this.enabled?'disabled':this.healReason!==null||(this.error!==null&&!CHURN.test(this.error))?'unavailable':this.task||this.timer?'preparing':this.current?.complete?'ready':'waiting') as SemanticState,snapshotId:this.current?.id??null,throughEntryId:this.current?.throughEntryId??null,eligibleSources:this.current?.sources.length??0,totalUnits:this.current?.units.length??0,coveredUnits:this.current?.indexed.size??0,indexingComplete:Boolean(this.current?.complete),lastError:this.error??this.healReason??semanticSetupMessage(this.setup,this.setupDetail),window:this.current?windowCoverage(this.current):null,recovery:this.healReason===null?null:{attempts:this.healAttempts,integrity:this.healIntegrity,retryAt:this.healRetryAt===null?null:new Date(this.healRetryAt).toISOString()},operations:{...this.operations},limits:{keyword:16000,broad:20000}};}
300
+ /** The existing model-facing coverage contract: setup metadata belongs to operator status, not the request. */
301
+ requestCoverage(){const {setup,setupMessage,...coverage}=this.status();return coverage;}
270
302
  schedule(provider:()=>Snapshot){this.provider=provider;if(!this.enabled||this.timer||!this.active||this.healTimer)return;this.timer=setTimeout(()=>{this.timer=undefined;if(this.task){this.dirty=true;return;}this.task=this.prepare(provider,this.fullIndexing).catch(async error=>{if(this.quietStop||!this.enabled)return;this.error=error instanceof Error?error.message:'local failure';if(error instanceof Error&&error.name==='SemanticAccessError'){this.enabled=false;this.generation++;this.dirty=false;try{await this.closeResources();}catch(cleanup){this.cleanupError=cleanup instanceof Error?cleanup.message:'Semantic cleanup failed';this.error=this.cleanupError;}}else this.heal(error);}).finally(()=>{this.task=undefined;if(this.enabled&&this.dirty){this.dirty=false;this.error=null;this.schedule(this.provider!);}});},5);}
271
303
  /** A saved cut has moved entries out of view: start indexing them now, for a session that has searched before but is not searching now, or whose worker the idle limit stopped. Awaits nothing; a failure goes to `heal` like any preparation.
272
304
  * Only a session that ran a search earlier (`searched`) indexes at a cut: a host may give every session its own controller and worker, so indexing at every node's cut could run several encoders at once on a 16 GB machine.
@@ -355,27 +387,27 @@ export class SemanticRecallController {
355
387
  finally{if(timer)clearTimeout(timer);if(onAbort)signal?.removeEventListener('abort',onAbort);}
356
388
  }
357
389
  search(query:string,provider:()=>Snapshot,signal?:AbortSignal,prior:PriorResult[]=[],words?:any):Promise<SemanticPage>{const task=this.searchOwned(query,provider,signal,prior,words);this.activeSearches.add(task);const settled=()=>{this.activeSearches.delete(task);this.armIdle();};void task.then(settled,settled);return task;}
358
- private async searchOwned(query:string,provider:()=>Snapshot,signal?:AbortSignal,prior:PriorResult[]=[],words?:any):Promise<SemanticPage>{if(signal?.aborted)throw abort();if(!this.enabled)return{status:'disabled',notice:'Optional semantic recall is disabled; ordinary word recall remains available.',coverage:this.status(),groups:[]};
390
+ private async searchOwned(query:string,provider:()=>Snapshot,signal?:AbortSignal,prior:PriorResult[]=[],words?:any):Promise<SemanticPage>{if(signal?.aborted)throw abort();if(!this.enabled)return{status:'disabled',notice:'Optional semantic recall is disabled; ordinary word recall remains available.',coverage:this.requestCoverage(),groups:[]};
359
391
  this.demand();this.searched=true;
360
- const deadline=performance.now()+(this.config!.queryWaitMs??30000),incomplete=():SemanticPage=>({status:'indexing_incomplete',notice:'No semantic ranking performed: nothing of the requested eligible snapshot is indexed yet.',coverage:this.status(),groups:[]}),
361
- unavailable=():SemanticPage=>({status:'unavailable',notice:'Local semantic preparation unavailable; permitted ordinary recall remains available.',coverage:this.status(),groups:[]});
392
+ const deadline=performance.now()+(this.config!.queryWaitMs??30000),incomplete=():SemanticPage=>({status:'indexing_incomplete',notice:'No semantic ranking performed: nothing of the requested eligible snapshot is indexed yet.',coverage:this.requestCoverage(),groups:[]}),
393
+ unavailable=():SemanticPage=>({status:'unavailable',notice:'Local semantic preparation unavailable; permitted ordinary recall remains available.',coverage:this.requestCoverage(),groups:[]});
362
394
  const queryGeneration=this.generation;let bound:Snapshot;try{bound=provider();}catch(error){await this.revoked(error);throw error;}this.provider=provider;if(this.healTimer)return unavailable(); // a retry is waiting: it owns the next attempt, a search does not bypass the backoff
363
395
  // Nothing out of view (no cut yet, or the cut kept everything): the whole history is in the agent's context, so a search never waits for indexing, and the query never starts a worker itself (background preparation may). It ranks what an already running worker has indexed, else it says so at once; a recorded failure is still reported as unavailable, as on the waiting path.
364
396
  if(bound.sources.every(source=>source.order>=bound.viewStart)){
365
397
  const failure=this.error??this.healReason,failed=failure!==null&&!CHURN.test(failure),planned=this.current?.id===bound.id&&this.current.complete,page=failed?unavailable():null; // built before the retry clears the error, so the page's coverage reports it
366
398
  if(!planned){this.error=null;this.schedule(provider);}
367
- if(!(this.current?.id===bound.id&&((this.current.complete&&!this.current.units.length)||(indexedPrefix(this.current)>0&&this.worker!==undefined&&!this.recycle&&!this.idling)))){if(page)return page;return{status:'indexing_incomplete',notice:'No semantic ranking performed: nothing is out of view, so the whole history is in your context.',coverage:this.status(),groups:[]};}
399
+ if(!(this.current?.id===bound.id&&((this.current.complete&&!this.current.units.length)||(indexedPrefix(this.current)>0&&this.worker!==undefined&&!this.recycle&&!this.idling)))){if(page)return page;return{status:'indexing_incomplete',notice:'No semantic ranking performed: nothing is out of view, so the whole history is in your context.',coverage:this.requestCoverage(),groups:[]};}
368
400
  }else{
369
401
  if(this.current?.id!==bound.id){const prepared=!this.task||await this.joinPreparation(deadline,signal);if(signal?.aborted||queryGeneration!==this.generation)throw abort();if(prepared&&performance.now()<deadline){this.error=null;this.schedule(provider);await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}}else{if(!this.current.complete){this.error=null;this.schedule(provider);}await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}if(signal?.aborted||queryGeneration!==this.generation)throw abort();
370
402
  }
371
403
  // 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`.
372
404
  const snapshot=this.current,older=snapshot!==undefined&&snapshot.id!==bound.id&&grownFrom(bound,snapshot);if(!snapshot||(snapshot.id!==bound.id&&!older)||(snapshot.units.length?indexedPrefix(snapshot)===0:!snapshot.complete)){const failure=this.error??this.healReason;return failure&&!CHURN.test(failure)?unavailable():incomplete();}
373
- if(!snapshot.units.length)return{status:'no_matches',notice:'No eligible original source units in this snapshot.',coverage:this.status(),method:'embedding',groups:[]};
405
+ if(!snapshot.units.length)return{status:'no_matches',notice:'No eligible original source units in this snapshot.',coverage:this.requestCoverage(),method:'embedding',groups:[]};
374
406
  try{const worker=await this.runtime(snapshot.project);this.recheckGrown(provider,snapshot,bound.id);const encoded=await worker.call('embed',{texts:[query],kind:'query'},signal);this.operations.queryEmbeddings++;this.recheckGrown(provider,snapshot,bound.id);const prefix=indexedPrefix(snapshot),stored=snapshot.units.slice(0,prefix),byKey=new Map(stored.map(u=>[u.key,u])),covered=windowCoverage(snapshot,prefix)!;let ranked:{unit:Unit;score:number}[]=[],observed=0;for(let offset=0;offset<stored.length;offset+=SCORE_CHUNK){this.recheckGrown(provider,snapshot,bound.id);const scored=await this.index('score',snapshot,{keys:stored.slice(offset,offset+SCORE_CHUNK).map(u=>u.key),query:encoded.vectors[0]},bound.id) as {ranked:{key:string;score:number}[];observed:number};observed+=scored.observed;ranked.push(...scored.ranked.map(row=>({unit:byKey.get(row.key)!,score:row.score})));ranked.sort((a,b)=>b.score-a.score);ranked=ranked.slice(0,24);if(signal?.aborted)throw abort();await yieldWork();}if(observed!==stored.length)throw new Error('Snapshot index coverage changed');
375
407
  if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.recheckGrown(provider,snapshot,bound.id);const final=provider();const finalSources=new Map(final.sources.map(source=>[source.entry.id,source.sha]));if(snapshot.sources.some(source=>finalSources.get(source.entry.id)!==source.sha))throw new Error('Eligible source changed or access revoked before return');
376
408
  // 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.
377
- 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;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({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}))];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)};
378
- }catch(error){if(error instanceof Error&&error.name==='SemanticAccessError'){await this.revoked(error);throw error;}if(signal?.aborted||queryGeneration!==this.generation||error instanceof Error&&error.name==='AbortError'){await this.cancelled();throw abort();}this.heal(error);return{status:'unavailable',notice:'Local semantic search failed; ordinary recall remains available. '+(error instanceof Error?error.message:'failure'),coverage:this.status(),groups:[]};}
409
+ 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;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({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}))];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.requestCoverage(),window:covered},method:'embedding',groups:this.assemble(ranked.map(hit=>hit.unit),snapshot,previous)};
410
+ }catch(error){if(error instanceof Error&&error.name==='SemanticAccessError'){await this.revoked(error);throw error;}if(signal?.aborted||queryGeneration!==this.generation||error instanceof Error&&error.name==='AbortError'){await this.cancelled();throw abort();}this.heal(error);return{status:'unavailable',notice:'Local semantic search failed; ordinary recall remains available. '+(error instanceof Error?error.message:'failure'),coverage:this.requestCoverage(),groups:[]};}
379
411
  }
380
412
  private assemble(units:Unit[],snapshot:Snapshot,previous:PriorResult[]):SemanticGroup[]{
381
413
  // Collapse coordinate overlap only; disjoint evidence in one entry remains independent.
@@ -405,7 +437,7 @@ export class SemanticRecallController {
405
437
  return{source,start,end};
406
438
  }
407
439
  private evidence(source:Source,start:number,end:number){return{...source.row,sourceOrder:source.order,sourceSha:source.sha,text:source.sourceText.slice(start,end),textOffset:start,totalCharacters:source.sourceText.length,read:{entryId:source.entry.id,offset:start,limit:end-start},complete:start===0&&end===source.sourceText.length};}
408
- async disable(joinSearches=true){if(joinSearches)this.joiningSearches++;try{if(!this.closing){const stop=this.stopOwner();this.closing=stop;void stop.finally(()=>{if(this.closing===stop)this.closing=undefined;}).catch(()=>{});}await this.closing;if(joinSearches)await Promise.allSettled([...this.activeSearches]);}finally{if(joinSearches)this.joiningSearches--;}}
440
+ async disable(joinSearches=true, userOff=true){if(userOff)this.setSetup('off-user');if(joinSearches)this.joiningSearches++;try{if(!this.closing){const stop=this.stopOwner();this.closing=stop;void stop.finally(()=>{if(this.closing===stop)this.closing=undefined;}).catch(()=>{});}await this.closing;if(joinSearches)await Promise.allSettled([...this.activeSearches]);}finally{if(joinSearches)this.joiningSearches--;}}
409
441
  private async closeResources(){this.fullIndexing=false;this.cancelHeal();this.cancelIdle();this.active=false;this.healReason=null;this.healAttempts=0;this.recycle=false;const worker=this.worker;await worker?.close();await Promise.all([...this.indexFiles].flatMap(path=>[path,path+'-wal',path+'-shm']).map(path=>rm(path,{force:true})));this.indexFiles.clear();if(this.worker===worker)this.worker=undefined;this.identity='';this.current=undefined;this.tokenized.clear();this.cleanupError=null;}
410
442
  private async stopOwner(){this.dirty=false;this.enabled=false;this.generation++;if(this.timer){clearTimeout(this.timer);this.timer=undefined;}try{await this.closeResources();await this.task;this.task=undefined;}catch(error){this.cleanupError=error instanceof Error?error.message:'Semantic cleanup failed';throw error;}}
411
443
 
@@ -458,7 +490,7 @@ export class SemanticRecallController {
458
490
  // A source that can no longer be read stops recall; the reason stays readable until the next enable, so this never looks like recall that was never on.
459
491
  private async revoked(error:unknown){await this.disable(false);this.error=error instanceof Error?error.message:'Source access denied';}
460
492
  async invalidate(){await this.disable();this.error='Caller invalidated semantic source access; explicit enable required';}
461
- async close(){await this.disable();this.provider=undefined;this.searched=false;} // a closed controller serves a new session, which has not searched; a disable and enable keeps it
493
+ async close(){await this.disable(true,false);this.provider=undefined;this.searched=false;} // a closed controller serves a new session, which has not searched; a disable and enable keeps it
462
494
  }
463
495
  export const createSemanticRecall=(config?:SemanticRecallConfig)=>new SemanticRecallController(config);
464
496
  /** Consolidate only source-bound coordinate overlap; equal text in distinct events is retained.
package/src/status.ts CHANGED
@@ -16,6 +16,7 @@ export function statusText(input: {
16
16
  version: string;
17
17
  active: boolean;
18
18
  branch: readonly { type: string; tokensBefore?: unknown; details?: unknown; customType?: unknown; data?: unknown }[];
19
+ pending?: boolean;
19
20
  cutAt: number | undefined;
20
21
  search: boolean;
21
22
  semantic: { state: SemanticState; lastError: string | null };
@@ -24,7 +25,7 @@ export function statusText(input: {
24
25
  const cuts = input.branch.filter(e => e.type === "compaction"), ours = cuts.filter(e => (e.details as { compactor?: unknown } | null | undefined)?.compactor === "pi-tuck");
25
26
  const last = ours.at(-1), before = last?.tokensBefore, after = (last?.details as { tokensAfter?: unknown } | undefined)?.tokensAfter;
26
27
  const count = (n: number) => n.toLocaleString("en-US");
27
- const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
28
+ const cutLine = ours.length === 0 ? input.pending ? "Cuts: none saved yet in this session." : "Cuts: none yet in this session. The recall tools appear after the first cut."
28
29
  : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
29
30
  const native = cuts.length - ours.length;
30
31
  const retries = input.branch.filter(entry => entry.type === "custom" && entry.customType === "pi-tuck-capacity" &&
@@ -37,6 +38,7 @@ export function statusText(input: {
37
38
  `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
38
39
  cutLine,
39
40
  ...(retries ? [`Retries: ${retries} after thinking-only length stops in this session.`] : []),
41
+ ...(input.pending ? ["A cut is pending: requests already carry it; the first boundary saves it."] : []),
40
42
  ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
41
43
  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.`,
42
44
  searchLine,
package/src/tuck.ts CHANGED
@@ -136,16 +136,17 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
136
136
  if (!prefix.ok) return { unavailable: "No reducible history before the fresh shell output" };
137
137
  cut = cutBefore(cut, freshBash, prefix);
138
138
  }
139
- // Preserve the actual latest user source, never an inferred task/decision.
140
- // Explicit omission of it must not promote an earlier user source instead.
139
+ // The latest user source still owns first exposure and non-text native floors. An omission never promotes an older source.
141
140
  const latestUser = retainCurrentInput ? branch.findLast(entry => entry.type === "message" && entry.message.role === "user") : undefined;
142
- const userPosition = entries.findIndex(entry => entry.id === latestUser?.id && entry.type === "message");
143
- const current = entries[userPosition];
144
- const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
145
- const textOnly = isTextOnly(userContent);
146
- // Steering can become the latest user source while the run still owns its original prompt.
147
- const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
148
- const starting = entries[startingPosition];
141
+ const latestPosition = entries.findIndex(entry => entry.id === latestUser?.id && entry.type === "message");
142
+ const latest = entries[latestPosition];
143
+ const latestContent = latest?.type === "message" && latest.message.role === "user" ? latest.message.content : undefined;
144
+ const latestTextOnly = isTextOnly(latestContent);
145
+ // During a run, current input is the run owner's starting entry, even after steering. Manual cuts use the latest source.
146
+ const currentId = input.protectedPromptEntryId ?? latestUser?.id;
147
+ const currentPosition = entries.findIndex(entry => entry.id === currentId &&
148
+ (entry.type === "custom_message" || entry.type === "message" && entry.message.role === "user"));
149
+ const current = entries[currentPosition];
149
150
  // Anchor the actual first source only; a native omission never promotes a later prompt.
150
151
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
151
152
  const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
@@ -153,10 +154,10 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
153
154
  const selectedBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === selectedKeptId);
154
155
  // An unobserved input or non-text input stays native. Quoting cannot deliver
155
156
  // its pixels/unknown blocks, and must not count as first exposure.
156
- if (userPosition >= 0 && userPosition < selectedBoundary && (!textOnly || userPosition > lastResponse)) {
157
- const prefix = tuck.buildOwnCut(entries.slice(0, userPosition), 0);
157
+ if (latestPosition >= 0 && latestPosition < selectedBoundary && (!latestTextOnly || latestPosition > lastResponse)) {
158
+ const prefix = tuck.buildOwnCut(entries.slice(0, latestPosition), 0);
158
159
  if (!prefix.ok) return { unavailable: "No reducible history before the current user input" };
159
- cut = cutBefore(cut, userPosition, prefix);
160
+ cut = cutBefore(cut, latestPosition, prefix);
160
161
  }
161
162
 
162
163
  if (cut.messages.length !== cut.selectedIds.length || (!cut.compactAll && !entries.some((e) => e.id === cut.firstKeptEntryId)))
@@ -165,7 +166,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
165
166
  let preferredBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === cut.firstKeptEntryId);
166
167
  let floorBoundary = freshRound ? lastResponse : entries.length;
167
168
  if (freshBash >= 0) floorBoundary = Math.min(floorBoundary, freshBash);
168
- if (userPosition >= 0 && (!textOnly || userPosition > lastResponse)) floorBoundary = Math.min(floorBoundary, userPosition);
169
+ if (latestPosition >= 0 && (!latestTextOnly || latestPosition > lastResponse)) floorBoundary = Math.min(floorBoundary, latestPosition);
169
170
  // One fill owns the summary and the contiguous native suffix. Protocol/current-input
170
171
  // floors are representation constraints; every other source competes by rank.
171
172
  return rankedFill();
@@ -265,10 +266,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
265
266
  let cached = quoteCache.get(key);
266
267
  if (cached) return cached;
267
268
  const index = indices.get(entry.id);
268
- const text = entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
269
+ const text = entry.type === "custom_message" ? textOf(entry.content) : entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
269
270
  const sourceTimes = index === undefined ? undefined : [{ sourceIndex: index, ...resolveSourceTime(entry) }];
270
- const records: TranscriptRecord[] = index === undefined ? [] : take === Infinity
271
- ? [{ kind: "excerpt", role: "user", sourceIndex: index, text, sourceTimes }]
271
+ // Custom entries have a native identity but no global message index.
272
+ const records: TranscriptRecord[] = index === undefined && entry.type !== "custom_message" ? [] : take === Infinity
273
+ ? [{ kind: "excerpt", role: entry.type === "custom_message" ? "custom" : "user", ...(index === undefined ? {} : { sourceIndex: index }), text, sourceTimes,
274
+ ...(entry.type === "custom_message" ? { sourceEntryId: entry.id } : {}) }]
272
275
  : userPrefixRecords({ kind: "user", text, originalText: text, startsSource: true, sourceIndex: index }, undefined, take).map(record => ({ ...record, sourceTimes }));
273
276
  cached = { records, cost: lineCost(records), whole: take >= text.length };
274
277
  quoteCache.set(key, cached);
@@ -282,9 +285,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
282
285
  kept.push(...quoted.records); cost += quoted.cost;
283
286
  if (index !== undefined && quoted.records.length) { selected.add(index); if (quoted.whole) whole.add(index); }
284
287
  };
285
- if (userPosition >= 0 && userPosition < boundary && textOnly) add(current!, Infinity);
286
- if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id) add(starting!, Infinity);
287
- for (const entry of anchors) if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id)! < boundary) add(entry, length);
288
+ if (latestPosition >= 0 && latestPosition < boundary && latestTextOnly) add(latest!, Infinity);
289
+ if (currentPosition >= 0 && currentPosition < boundary && current?.id !== latest?.id) add(current!, Infinity);
290
+ for (const entry of anchors) if (entry.id !== latest?.id && entry.id !== current?.id && positions.get(entry.id)! < boundary) add(entry, length);
288
291
  const omissions: TranscriptRecord[] = [];
289
292
  for (const role of ["user", "assistant", "toolResult"] as const) {
290
293
  const list = units[role], prefix = prefixes[role];
@@ -383,19 +386,23 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
383
386
  for (const row of sourceRows) if (row.message.role === "user" && Array.isArray(row.message.content))
384
387
  imageLabels.set(row.index, new Set(row.message.content.flatMap(part => part.type === "image" ? [`[image: ${part.mimeType}]`] : [])));
385
388
  const compiledBoundary = boundary;
389
+ // A protected custom prompt uses its native entry ID in the same anchoring/movement path as indexed user prompts.
390
+ const sourceKey = (record: TranscriptRecord) => current?.type === "custom_message" && record.sourceEntryId === current.id ? current.id : record.sourceIndex;
386
391
  const assemble = (compiled: string, at = compiledBoundary) => {
387
392
  const split = compiled ? splitSummary(compiled) : undefined;
388
393
  if (compiled && !split) throw new Error("pi-tuck returned an invalid summary");
389
394
  const inputRecords = split ? parseRecords(split.body, split.version) : protectedView.omissions;
390
395
  const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row =>
391
- row.kind === "excerpt" && row.role === "user" && row.sourceIndex === record.sourceIndex && row.text.startsWith(record.text!) && row.text.length >= record.text!.length)).map(record => record.sourceIndex));
392
- const anchored = new Set(protectedView.kept.map(record => record.sourceIndex));
393
- const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => row.index));
396
+ row.kind === "excerpt" && row.role === record.role && sourceKey(row) === sourceKey(record) && row.text.startsWith(record.text!) && row.text.length >= record.text!.length)).map(sourceKey));
397
+ const anchored = new Set(protectedView.kept.map(sourceKey));
398
+ const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row =>
399
+ current?.type === "custom_message" && row.id === current.id ? row.id : row.index));
394
400
  const records = [...inputRecords.filter(record => {
395
- if (record.sourceIndex !== undefined && moved.has(record.sourceIndex)) return false;
401
+ const key = sourceKey(record);
402
+ if (key !== undefined && moved.has(key)) return false;
396
403
  if (record.kind === "excerpt" && record.role === "user" && imageLabels.get(record.sourceIndex)?.has(record.text)) return true;
397
- return record.role !== "user" || record.sourceIndex === undefined || !anchored.has(record.sourceIndex) || expanded.has(record.sourceIndex);
398
- }), ...protectedView.kept.filter(record => !expanded.has(record.sourceIndex) && !moved.has(record.sourceIndex))];
404
+ return record.role !== "user" && record.role !== "custom" || key === undefined || !anchored.has(key) || expanded.has(key);
405
+ }), ...protectedView.kept.filter(record => !expanded.has(sourceKey(record)) && !moved.has(sourceKey(record)))];
399
406
  // Mixed user turns keep their text opening, while their image
400
407
  // metadata remains in the ordinary lower-ranked dialogue lane.
401
408
  const wholeUser = new Set(records.filter(record => record.kind === "excerpt" && record.role === "user" && record.text === userTexts.get(record.sourceIndex)).map(record => record.sourceIndex));