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.
@@ -1,6 +1,6 @@
1
1
  import { type RecallPolicy } from './recall.js';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
- export declare const DIAGNOSTIC_BUILD = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
3
+ export declare const DIAGNOSTIC_BUILD = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -22,10 +22,15 @@ export declare const REPLY_FLOOR_MIN = 3584;
22
22
  export declare const thinkingOnlyLength: (message: AgentMessage) => boolean;
23
23
  /** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
24
24
  export declare const needsFullReplyReserve: (message: AgentMessage) => boolean;
25
- /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
25
+ /** One branch reader for the reserve override and reply floor, including history before cuts. */
26
+ export declare function replyFit(branch: readonly SessionEntry[]): {
27
+ failed: boolean;
28
+ largestOutput: number;
29
+ thinkingStop: boolean;
30
+ };
26
31
  export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
27
32
  /** Admission never promises more output than a model with a positive reply cap can produce. */
28
33
  export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
29
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
30
- export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
34
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
35
+ export declare function carriesPrompt(messages: Messages, prompt: string, role?: 'user' | 'custom'): boolean;
31
36
  export {};
@@ -64,41 +64,46 @@ export const thinkingOnlyLength = (message) => message.role === 'assistant' && m
64
64
  /** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
65
65
  export const needsFullReplyReserve = (message) => message.role === 'assistant' &&
66
66
  (message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
67
- /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
68
- export function branchReplyFloor(branch, reserve) {
69
- let largest = REPLY_FLOOR_MIN, thinkingStop = false;
67
+ /** One branch reader for the reserve override and reply floor, including history before cuts. */
68
+ export function replyFit(branch) {
69
+ let failed = false, largestOutput = 0, thinkingStop = false;
70
70
  for (const entry of branch) {
71
71
  if (entry.type !== 'message' || entry.message.role !== 'assistant')
72
72
  continue;
73
73
  const message = entry.message;
74
- if (needsFullReplyReserve(message))
75
- return reserve;
74
+ failed ||= needsFullReplyReserve(message);
76
75
  thinkingStop ||= thinkingOnlyLength(message);
77
76
  const output = message.usage?.output;
78
- if (typeof output === 'number' && Number.isFinite(output) && output > largest)
79
- largest = output;
77
+ if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput)
78
+ largestOutput = output;
80
79
  }
81
- // Include the exhausted thinking reply itself, even across later cuts/restarts. Its observed output must fit again;
82
- // requestReplyFloor applies the model cap. Ordinary branches retain their existing reserve cap.
83
- return thinkingStop ? Math.ceil(largest) : Math.min(reserve, Math.ceil(largest));
80
+ return { failed, largestOutput, thinkingStop };
81
+ }
82
+ export function branchReplyFloor(branch, reserve) {
83
+ const { failed, largestOutput, thinkingStop } = replyFit(branch);
84
+ if (failed)
85
+ return reserve;
86
+ // Include exhausted thinking output across cuts/restarts; requestReplyFloor applies the model cap.
87
+ const largest = Math.ceil(Math.max(REPLY_FLOOR_MIN, largestOutput));
88
+ return thinkingStop ? largest : Math.min(reserve, largest);
84
89
  }
85
90
  /** Admission never promises more output than a model with a positive reply cap can produce. */
86
91
  export function requestReplyFloor(branch, reserve, maxTokens) {
87
92
  const floor = branchReplyFloor(branch, reserve);
88
93
  return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
89
94
  }
90
- /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
91
- export function carriesPrompt(messages, prompt) {
95
+ /** Check decoded starting text, native or quoted whole with its original role; never JSON-escaped substring matching. */
96
+ export function carriesPrompt(messages, prompt, role = 'user') {
92
97
  if (!prompt.length)
93
98
  return true;
94
99
  return messages.some(message => {
95
- if (message.role === 'user') {
100
+ if (message.role === role) {
96
101
  const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
97
102
  return text.includes(prompt);
98
103
  }
99
104
  if (message.role !== 'compactionSummary')
100
105
  return false;
101
106
  const split = splitSummary(message.summary);
102
- return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
107
+ return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === role && record.text.includes(prompt));
103
108
  });
104
109
  }
@@ -0,0 +1,52 @@
1
+ import { type ExtensionContext, type SessionEntry, type SessionBoundaryDraft } from '@earendil-works/pi-coding-agent';
2
+ import type { Clip } from './clip.js';
3
+ export type CutEntry = {
4
+ type: 'compaction';
5
+ summary: string;
6
+ firstKeptEntryId: string;
7
+ tokensBefore: number;
8
+ details?: unknown;
9
+ };
10
+ type Context = Pick<ExtensionContext, 'cwd' | 'sessionManager'>;
11
+ export interface CutView {
12
+ session: ExtensionContext['sessionManager'];
13
+ keptFromId: string | undefined;
14
+ viewStart: number;
15
+ hasTuckCut: boolean;
16
+ pending: boolean;
17
+ }
18
+ /** The only owner of a cut that requests carry before Pi saves it. */
19
+ export declare class PendingCut {
20
+ private cut?;
21
+ private branchLength;
22
+ private preview?;
23
+ plan(cut: CutEntry, branchLength: number): void;
24
+ clear(): void;
25
+ take(): CutEntry | undefined;
26
+ view(ctx: Context, entries?: readonly SessionBoundaryDraft[]): CutView;
27
+ }
28
+ /** Text bridges before Pi appends the starting message. Once visible, every reader uses its entry id.
29
+ * Bind on the first request/compaction read after message_end (which Pi emits before appending), before steering can replace it. */
30
+ export declare class StartingPrompt {
31
+ private readonly branchLength;
32
+ readonly role: 'user' | 'custom';
33
+ private entryId?;
34
+ private bridge?;
35
+ constructor(bridge: string, branchLength: number, role?: 'user' | 'custom');
36
+ id(branch: readonly SessionEntry[]): string | undefined;
37
+ text(branch: readonly SessionEntry[]): string;
38
+ }
39
+ export interface Run {
40
+ phase: 'decided' | 'starting' | 'admitted' | 'failed';
41
+ claimed: boolean;
42
+ expired: boolean;
43
+ prompt?: StartingPrompt;
44
+ cut: PendingCut;
45
+ clips: Map<string, Clip>;
46
+ pendingBoundaryCommit: boolean;
47
+ thinkingRetryPending: boolean;
48
+ failure?: Error;
49
+ refusalReceipted: boolean;
50
+ }
51
+ export declare const createRun: (phase: Run["phase"]) => Run;
52
+ export {};
@@ -0,0 +1,89 @@
1
+ import { SessionManager } 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
+ /** The only owner of a cut that requests carry before Pi saves it. */
6
+ export class PendingCut {
7
+ cut;
8
+ branchLength = 0;
9
+ preview;
10
+ plan(cut, branchLength) { this.cut = cut; this.branchLength = branchLength; this.preview = undefined; }
11
+ clear() { this.cut = undefined; this.branchLength = 0; this.preview = undefined; }
12
+ take() { const cut = this.cut; this.clear(); return cut; }
13
+ view(ctx, entries = []) {
14
+ const live = ctx.sessionManager, leaf = live.getLeafId?.(), held = this.preview;
15
+ // Native entries are append-only; a leaf move invalidates both the saved and pending views, as in the eligibility cache.
16
+ if (!entries.length && leaf !== undefined && held && held.manager === live && held.cwd === ctx.cwd && held.leaf === leaf)
17
+ return held.view;
18
+ const branch = live.getBranch(), cut = this.cut;
19
+ const remember = (view) => {
20
+ if (!entries.length && leaf !== undefined)
21
+ this.preview = { manager: live, cwd: ctx.cwd, leaf, view };
22
+ return view;
23
+ };
24
+ if (!cut && !entries.length) {
25
+ const last = branch.findLast(entry => entry.type === 'compaction');
26
+ return remember({ session: live, keptFromId: last?.type === 'compaction' ? last.firstKeptEntryId : undefined,
27
+ viewStart: viewStart(branch), hasTuckCut: hasTuckCut(branch), pending: false });
28
+ }
29
+ // A compact-all plan predates the prompt. Keep from the first appended entry, skipping our capacity receipts.
30
+ const keptFromId = cut && (cut.firstKeptEntryId || branch.slice(this.branchLength)
31
+ .find(entry => entry.type !== 'custom' || entry.customType !== 'pi-tuck-capacity')?.id || '');
32
+ const header = live.getHeader();
33
+ if (!header)
34
+ throw new Error("Missing native session header for the prompt's cut");
35
+ const session = SessionManager.inMemory(ctx.cwd, undefined, [header, ...branch]);
36
+ if (cut)
37
+ session.appendCompaction(cut.summary, keptFromId, 0, cut.details, true);
38
+ // Retry/settlement drafts model the same request view, including an omitted failed reply.
39
+ for (const entry of entries) {
40
+ if (entry.type === 'context_edit')
41
+ session.appendContextEdit(entry.targetId, entry.replacement);
42
+ else if (entry.type === 'compaction')
43
+ session.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
44
+ else if (entry.type === 'custom')
45
+ session.appendCustomEntry(entry.customType, entry.data);
46
+ else
47
+ session.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
48
+ }
49
+ const previewBranch = session.getBranch(), last = previewBranch.findLast(entry => entry.type === 'compaction');
50
+ return remember({ session, keptFromId: last?.type === 'compaction' ? last.firstKeptEntryId : undefined,
51
+ viewStart: viewStart(previewBranch), hasTuckCut: hasTuckCut(previewBranch), pending: Boolean(cut) });
52
+ }
53
+ }
54
+ /** Text bridges before Pi appends the starting message. Once visible, every reader uses its entry id.
55
+ * Bind on the first request/compaction read after message_end (which Pi emits before appending), before steering can replace it. */
56
+ export class StartingPrompt {
57
+ branchLength;
58
+ role;
59
+ entryId;
60
+ bridge;
61
+ constructor(bridge, branchLength, role = 'user') {
62
+ this.branchLength = branchLength;
63
+ this.role = role;
64
+ this.bridge = bridge;
65
+ }
66
+ id(branch) {
67
+ if (!this.entryId && this.bridge !== undefined) {
68
+ const text = this.bridge;
69
+ this.entryId = branch.slice(this.branchLength).find(entry => this.role === 'custom'
70
+ ? entry.type === 'custom_message' && textOf(entry.content) === text
71
+ : entry.type === 'message' && entry.message.role === 'user' && textOf(entry.message.content).includes(text))?.id;
72
+ if (this.entryId)
73
+ this.bridge = undefined;
74
+ }
75
+ return this.entryId;
76
+ }
77
+ text(branch) {
78
+ const id = this.id(branch), entry = branch.find(entry => entry.id === id);
79
+ if (entry?.type === 'message' && entry.message.role === 'user')
80
+ return textOf(entry.message.content);
81
+ if (entry?.type === 'custom_message')
82
+ return textOf(entry.content);
83
+ if (this.bridge !== undefined)
84
+ return this.bridge;
85
+ throw new Error('The starting prompt entry is no longer on this branch');
86
+ }
87
+ }
88
+ export const createRun = (phase) => ({ phase, claimed: false, expired: false,
89
+ cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
@@ -101,7 +101,7 @@ export interface SemanticGroup {
101
101
  export interface SemanticPage {
102
102
  status: string;
103
103
  notice: string;
104
- coverage: ReturnType<SemanticRecallController["status"]>;
104
+ coverage: ReturnType<SemanticRecallController["requestCoverage"]>;
105
105
  groups: SemanticGroup[];
106
106
  method?: string;
107
107
  }
@@ -127,6 +127,20 @@ export declare const MAX_INDEX_UNITS = 8000;
127
127
  /** The wait before the next automatic attempt, given how many were already armed since the last success. */
128
128
  export declare function healDelayMs(attempts: number, integrity: boolean, delays?: readonly number[], steady?: number): number;
129
129
  export type SemanticState = 'disabled' | 'unavailable' | 'preparing' | 'ready' | 'waiting';
130
+ export type SemanticSetup = 'off-settings' | 'off-user' | 'not-installed' | 'off-platform' | 'off-project' | 'starting' | 'failed' | 'ready';
131
+ interface SetupDetail {
132
+ installed?: boolean;
133
+ reason?: string;
134
+ stage?: 'setup' | 'start';
135
+ notice?: string;
136
+ announced?: boolean;
137
+ }
138
+ /** Setup and operator wording have one owner; runtime indexing state remains separate. */
139
+ export declare function semanticSetupMessage(setup: SemanticSetup, detail?: SetupDetail): string | null;
140
+ export declare function semanticSetupNotice(setup: SemanticSetup, detail?: SetupDetail): {
141
+ message: string;
142
+ level: 'info' | 'warning';
143
+ } | undefined;
130
144
  /** Where the current view starts, as a branch position: the latest compaction entry's first kept entry. Earlier entries were compacted away. With no compaction, nothing is out of view. */
131
145
  export declare function viewStart(branch: SessionEntry[]): number;
132
146
  /** `pendingKeptId`: the first kept entry of a cut pi-tuck has applied to the requests but Pi has not yet saved (a prompt's cut is saved at the end of
@@ -147,6 +161,8 @@ export declare class SemanticRecallController {
147
161
  private cleanupError;
148
162
  private dirty;
149
163
  private operations;
164
+ private setup;
165
+ private setupDetail;
150
166
  private _enabled;
151
167
  private _current?;
152
168
  private _task?;
@@ -190,7 +206,13 @@ export declare class SemanticRecallController {
190
206
  onStateChange(listener: (state: SemanticState, reason: string | null) => void): () => void;
191
207
  private touch;
192
208
  /** 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`. */
209
+ setSetup(setup: SemanticSetup, detail?: SetupDetail): void;
210
+ /** Compatibility for hosts that already record an external start failure. */
193
211
  refuse(reason: string): void;
212
+ setupNotice(): {
213
+ message: string;
214
+ level: "info" | "warning";
215
+ } | undefined;
194
216
  configure(config: SemanticRecallConfig): void;
195
217
  /** Ordinary external settings remain caller-selected/outside-project. The
196
218
  * optional override is a current-process verified dependency, not Task data. */
@@ -199,6 +221,52 @@ export declare class SemanticRecallController {
199
221
  }): Promise<void>;
200
222
  enable(): void;
201
223
  status(): {
224
+ setup: SemanticSetup;
225
+ setupMessage: string | null;
226
+ enabled: boolean;
227
+ configured: boolean;
228
+ cleanupError: string | null;
229
+ state: SemanticState;
230
+ snapshotId: string | null;
231
+ throughEntryId: string | null;
232
+ eligibleSources: number;
233
+ totalUnits: number;
234
+ coveredUnits: number;
235
+ indexingComplete: boolean;
236
+ lastError: string | null;
237
+ window: {
238
+ indexedCharacters: number;
239
+ windowCharacters: number;
240
+ outOfViewCharacters: number;
241
+ eligibleCharacters: number;
242
+ oldestCoveredEntryId: string | null;
243
+ oldestCoveredTime: string | null;
244
+ conversation: {
245
+ indexedCharacters: number;
246
+ outOfViewCharacters: number;
247
+ };
248
+ toolResults: {
249
+ indexedCharacters: number;
250
+ outOfViewCharacters: number;
251
+ oldestCoveredTime: string | null;
252
+ };
253
+ } | null;
254
+ recovery: {
255
+ attempts: number;
256
+ integrity: boolean;
257
+ retryAt: string | null;
258
+ } | null;
259
+ operations: {
260
+ preparationEmbeddings: number;
261
+ queryEmbeddings: number;
262
+ };
263
+ limits: {
264
+ keyword: number;
265
+ broad: number;
266
+ };
267
+ };
268
+ /** The existing model-facing coverage contract: setup metadata belongs to operator status, not the request. */
269
+ requestCoverage(): {
202
270
  enabled: boolean;
203
271
  configured: boolean;
204
272
  cleanupError: string | null;
@@ -251,6 +319,8 @@ export declare class SemanticRecallController {
251
319
  private index;
252
320
  private prepare;
253
321
  waitForReady(timeoutMs?: number, signal?: AbortSignal, expectedSnapshotId?: string, forSearch?: boolean): Promise<{
322
+ setup: SemanticSetup;
323
+ setupMessage: string | null;
254
324
  enabled: boolean;
255
325
  configured: boolean;
256
326
  cleanupError: string | null;
@@ -306,7 +376,7 @@ export declare class SemanticRecallController {
306
376
  private assemble;
307
377
  private focus;
308
378
  private evidence;
309
- disable(joinSearches?: boolean): Promise<void>;
379
+ disable(joinSearches?: boolean, userOff?: boolean): Promise<void>;
310
380
  private closeResources;
311
381
  private stopOwner;
312
382
  /** A search, or a wait for readiness, is demand: it lets background work start and keeps it alive for IDLE_STOP_MS after the last one. */
@@ -188,6 +188,44 @@ const SCORE_CHUNK = 1024;
188
188
  export function healDelayMs(attempts, integrity, delays = HEAL_DELAYS_MS, steady = HEAL_STEADY_MS) { return integrity ? steady : delays[attempts] ?? steady; }
189
189
  /** Errors that mean "the history moved or a slice ended; try again", not that the runtime failed. They do not make the state `unavailable`. */
190
190
  const CHURN = /indexing incomplete|history changed|source changed before|branch or source changed|cancelled/i;
191
+ /** Setup and operator wording have one owner; runtime indexing state remains separate. */
192
+ export function semanticSetupMessage(setup, detail = {}) {
193
+ switch (setup) {
194
+ 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';
195
+ case 'starting': return detail.installed ? 'Semantic recall is starting; word recall works meanwhile' : 'Semantic recall is being set up; word recall works meanwhile';
196
+ case 'failed': return detail.stage === 'start' ? detail.reason ?? 'Semantic recall could not start' : `Semantic recall setup failed: ${detail.reason}`;
197
+ default: return null;
198
+ }
199
+ }
200
+ export function semanticSetupNotice(setup, detail = {}) {
201
+ let message, level = 'info';
202
+ switch (setup) {
203
+ case 'off-platform':
204
+ message = 'pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.';
205
+ break;
206
+ case 'off-project':
207
+ 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.";
208
+ break;
209
+ case 'not-installed':
210
+ message = 'pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.';
211
+ level = 'warning';
212
+ break;
213
+ case 'starting':
214
+ message = detail.notice;
215
+ break;
216
+ case 'ready':
217
+ if (detail.announced)
218
+ message = 'pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.';
219
+ break;
220
+ case 'failed':
221
+ message = detail.stage === 'start'
222
+ ? `pi-tuck: semantic recall could not start (${detail.reason}); it tries again when a session starts.`
223
+ : `pi-tuck: semantic recall setup failed (${detail.reason}). Word recall works; setup retries when a session starts.`;
224
+ level = 'warning';
225
+ break;
226
+ }
227
+ return message ? { message, level } : undefined;
228
+ }
191
229
  const PROBE_TEXT = 'pi-tuck semantic recall status probe';
192
230
  class LocalWorker {
193
231
  config;
@@ -497,7 +535,7 @@ export function semanticEligibleSources(ctx, policy, access, pendingKeptId) {
497
535
  const orders = new Map(branch.map((entry, order) => [entry.id, order]));
498
536
  const sources = eligible.flatMap(entry => { const order = orders.get(entry.id); const row = recall.discoveryEntry(entry, attempts, links); if (!row)
499
537
  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) }) }]; });
500
- const pending = pendingKeptId === undefined ? -1 : branch.findIndex(e => e.id === pendingKeptId), start = Math.max(viewStart(branch), pending);
538
+ const pending = pendingKeptId === undefined ? -1 : pendingKeptId === '' ? branch.length : branch.findIndex(e => e.id === pendingKeptId), start = Math.max(viewStart(branch), pending);
501
539
  const session = ctx.sessionManager.getSessionId(), id = hash({ session, project: resolve(ctx.cwd), branch: branch.map(e => [e.id, e.parentId]), sources: sources.map(s => [s.entry.id, s.sha]), ...(pending >= 0 ? { pendingKeptId } : {}) });
502
540
  return { id, session, project: ctx.cwd, sources, units: [], complete: false, throughEntryId: sources.at(-1)?.entry.id ?? null, viewStart: start, indexed: new Set() };
503
541
  }
@@ -517,6 +555,8 @@ export class SemanticRecallController {
517
555
  dirty = false;
518
556
  operations = { preparationEmbeddings: 0, queryEmbeddings: 0 };
519
557
  // The fields below decide `status().state`; their setters announce a change to `onStateChange` listeners.
558
+ setup = 'off-user';
559
+ setupDetail = {};
520
560
  _enabled = false;
521
561
  _current;
522
562
  _task;
@@ -568,7 +608,10 @@ export class SemanticRecallController {
568
608
  }
569
609
  catch { } }); }
570
610
  /** 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`. */
571
- refuse(reason) { this.error = reason; }
611
+ setSetup(setup, detail = {}) { const announced = setup === 'ready' && this.setupDetail.announced; this.setup = setup; this.setupDetail = announced ? { ...detail, announced: true } : detail; this.touch(); }
612
+ /** Compatibility for hosts that already record an external start failure. */
613
+ refuse(reason) { this.setSetup('failed', { stage: 'start', reason }); }
614
+ setupNotice() { return semanticSetupNotice(this.setup, this.setupDetail); }
572
615
  configure(config) { if (this.enabled || this.worker || this.task)
573
616
  throw new Error('Disable before changing semantic configuration'); for (const [key, max] of [['queryWaitMs', 120000], ['prepareWorkMs', 120000], ['maxUnits', MAX_INDEX_UNITS], ['maxSourceCharacters', 2000000]]) {
574
617
  const value = config[key];
@@ -592,9 +635,11 @@ export class SemanticRecallController {
592
635
  }
593
636
  enable() { if (this.closing || this.joiningSearches || this.cleanupError)
594
637
  throw new Error('Semantic cleanup is unresolved; retry close before enable'); if (!this.config)
595
- 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)
638
+ 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)
596
639
  this.schedule(this.provider); }
597
- 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'), 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 } }; }
640
+ 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'), 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 } }; }
641
+ /** The existing model-facing coverage contract: setup metadata belongs to operator status, not the request. */
642
+ requestCoverage() { const { setup, setupMessage, ...coverage } = this.status(); return coverage; }
598
643
  schedule(provider) { this.provider = provider; if (!this.enabled || this.timer || !this.active || this.healTimer)
599
644
  return; this.timer = setTimeout(() => { this.timer = undefined; if (this.task) {
600
645
  this.dirty = true;
@@ -858,10 +903,10 @@ export class SemanticRecallController {
858
903
  if (signal?.aborted)
859
904
  throw abort();
860
905
  if (!this.enabled)
861
- return { status: 'disabled', notice: 'Optional semantic recall is disabled; ordinary word recall remains available.', coverage: this.status(), groups: [] };
906
+ return { status: 'disabled', notice: 'Optional semantic recall is disabled; ordinary word recall remains available.', coverage: this.requestCoverage(), groups: [] };
862
907
  this.demand();
863
908
  this.searched = true;
864
- const deadline = performance.now() + (this.config.queryWaitMs ?? 30000), incomplete = () => ({ status: 'indexing_incomplete', notice: 'No semantic ranking performed: nothing of the requested eligible snapshot is indexed yet.', coverage: this.status(), groups: [] }), unavailable = () => ({ status: 'unavailable', notice: 'Local semantic preparation unavailable; permitted ordinary recall remains available.', coverage: this.status(), groups: [] });
909
+ const deadline = performance.now() + (this.config.queryWaitMs ?? 30000), incomplete = () => ({ status: 'indexing_incomplete', notice: 'No semantic ranking performed: nothing of the requested eligible snapshot is indexed yet.', coverage: this.requestCoverage(), groups: [] }), unavailable = () => ({ status: 'unavailable', notice: 'Local semantic preparation unavailable; permitted ordinary recall remains available.', coverage: this.requestCoverage(), groups: [] });
865
910
  const queryGeneration = this.generation;
866
911
  let bound;
867
912
  try {
@@ -884,7 +929,7 @@ export class SemanticRecallController {
884
929
  if (!(this.current?.id === bound.id && ((this.current.complete && !this.current.units.length) || (indexedPrefix(this.current) > 0 && this.worker !== undefined && !this.recycle && !this.idling)))) {
885
930
  if (page)
886
931
  return page;
887
- 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: [] };
932
+ 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: [] };
888
933
  }
889
934
  }
890
935
  else {
@@ -915,7 +960,7 @@ export class SemanticRecallController {
915
960
  return failure && !CHURN.test(failure) ? unavailable() : incomplete();
916
961
  }
917
962
  if (!snapshot.units.length)
918
- return { status: 'no_matches', notice: 'No eligible original source units in this snapshot.', coverage: this.status(), method: 'embedding', groups: [] };
963
+ return { status: 'no_matches', notice: 'No eligible original source units in this snapshot.', coverage: this.requestCoverage(), method: 'embedding', groups: [] };
919
964
  try {
920
965
  const worker = await this.runtime(snapshot.project);
921
966
  this.recheckGrown(provider, snapshot, bound.id);
@@ -947,7 +992,7 @@ export class SemanticRecallController {
947
992
  // 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.
948
993
  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;
949
994
  const previous = [...usable, ...(words?.hits ?? []).map((hit) => ({ entryId: hit.entryId, start: hit.read.offset, end: hit.read.offset + hit.read.limit, sourceSha: snapshot.sources.find(s => s.entry.id === hit.entryId)?.sha }))];
950
- return { status: 'ok', notice: 'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.' + (ignored ? ` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.` : '') + ` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime ?? covered.oldestCoveredTime ?? 'an unknown time'}); use tuck_recall for the rest.` + (older ? ` This ranking covers the history through entry ${snapshot.throughEntryId}: later entries are still being indexed, so search them by keyword.` : ''), coverage: { ...this.status(), window: covered }, method: 'embedding', groups: this.assemble(ranked.map(hit => hit.unit), snapshot, previous) };
995
+ 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) };
951
996
  }
952
997
  catch (error) {
953
998
  if (error instanceof Error && error.name === 'SemanticAccessError') {
@@ -959,7 +1004,7 @@ export class SemanticRecallController {
959
1004
  throw abort();
960
1005
  }
961
1006
  this.heal(error);
962
- return { status: 'unavailable', notice: 'Local semantic search failed; ordinary recall remains available. ' + (error instanceof Error ? error.message : 'failure'), coverage: this.status(), groups: [] };
1007
+ return { status: 'unavailable', notice: 'Local semantic search failed; ordinary recall remains available. ' + (error instanceof Error ? error.message : 'failure'), coverage: this.requestCoverage(), groups: [] };
963
1008
  }
964
1009
  }
965
1010
  assemble(units, snapshot, previous) {
@@ -1016,7 +1061,8 @@ export class SemanticRecallController {
1016
1061
  return { source, start, end };
1017
1062
  }
1018
1063
  evidence(source, start, end) { 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 }; }
1019
- async disable(joinSearches = true) { if (joinSearches)
1064
+ async disable(joinSearches = true, userOff = true) { if (userOff)
1065
+ this.setSetup('off-user'); if (joinSearches)
1020
1066
  this.joiningSearches++; try {
1021
1067
  if (!this.closing) {
1022
1068
  const stop = this.stopOwner();
@@ -1142,7 +1188,7 @@ export class SemanticRecallController {
1142
1188
  // 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.
1143
1189
  async revoked(error) { await this.disable(false); this.error = error instanceof Error ? error.message : 'Source access denied'; }
1144
1190
  async invalidate() { await this.disable(); this.error = 'Caller invalidated semantic source access; explicit enable required'; }
1145
- 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
1191
+ 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
1146
1192
  }
1147
1193
  export const createSemanticRecall = (config) => new SemanticRecallController(config);
1148
1194
  /** Consolidate only source-bound coordinate overlap; equal text in distinct events is retained.
@@ -12,6 +12,7 @@ export declare function statusText(input: {
12
12
  customType?: unknown;
13
13
  data?: unknown;
14
14
  }[];
15
+ pending?: boolean;
15
16
  cutAt: number | undefined;
16
17
  search: boolean;
17
18
  semantic: {
@@ -18,7 +18,7 @@ export function statusText(input) {
18
18
  const cuts = input.branch.filter(e => e.type === "compaction"), ours = cuts.filter(e => e.details?.compactor === "pi-tuck");
19
19
  const last = ours.at(-1), before = last?.tokensBefore, after = last?.details?.tokensAfter;
20
20
  const count = (n) => n.toLocaleString("en-US");
21
- const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
21
+ 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."
22
22
  : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
23
23
  const native = cuts.length - ours.length;
24
24
  const retries = input.branch.filter(entry => entry.type === "custom" && entry.customType === "pi-tuck-capacity" &&
@@ -31,6 +31,7 @@ export function statusText(input) {
31
31
  `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
32
32
  cutLine,
33
33
  ...(retries ? [`Retries: ${retries} after thinking-only length stops in this session.`] : []),
34
+ ...(input.pending ? ["A cut is pending: requests already carry it; the first boundary saves it."] : []),
34
35
  ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
35
36
  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.`,
36
37
  searchLine,