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.
- package/CHANGELOG.md +47 -0
- package/README.md +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +37 -24
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +4 -0
- package/dist/src/compaction.js +156 -172
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +8 -3
- package/dist/src/request-capacity.js +19 -14
- package/dist/src/run.d.ts +52 -0
- package/dist/src/run.js +89 -0
- package/dist/src/semantic-recall.d.ts +72 -2
- package/dist/src/semantic-recall.js +58 -12
- package/dist/src/status.d.ts +1 -0
- package/dist/src/status.js +2 -1
- package/dist/src/tuck.js +33 -27
- package/index.ts +31 -22
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +137 -155
- package/src/request-capacity.ts +18 -12
- package/src/run.ts +91 -0
- package/src/semantic-recall.ts +46 -14
- package/src/status.ts +3 -1
- package/src/tuck.ts +32 -25
package/src/request-capacity.ts
CHANGED
|
@@ -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
|
-
/**
|
|
66
|
-
export function
|
|
67
|
-
let
|
|
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
|
-
|
|
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 >
|
|
74
|
+
if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput) largestOutput = output;
|
|
75
75
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
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 ===
|
|
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 ===
|
|
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 });
|
package/src/semantic-recall.ts
CHANGED
|
@@ -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["
|
|
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
|
-
|
|
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.
|
|
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.
|
|
361
|
-
unavailable=():SemanticPage=>({status:'unavailable',notice:'Local semantic preparation unavailable; permitted ordinary recall remains available.',coverage:this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
//
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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 (
|
|
157
|
-
const prefix = tuck.buildOwnCut(entries.slice(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,
|
|
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 (
|
|
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
|
-
|
|
271
|
-
|
|
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 (
|
|
286
|
-
if (
|
|
287
|
-
for (const entry of anchors) if (entry.id !==
|
|
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 ===
|
|
392
|
-
const anchored = new Set(protectedView.kept.map(
|
|
393
|
-
const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row =>
|
|
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
|
-
|
|
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"
|
|
398
|
-
}), ...protectedView.kept.filter(record => !expanded.has(record
|
|
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));
|