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/dist/src/tuck.js CHANGED
@@ -108,16 +108,17 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
108
108
  return { unavailable: "No reducible history before the fresh shell output" };
109
109
  cut = cutBefore(cut, freshBash, prefix);
110
110
  }
111
- // Preserve the actual latest user source, never an inferred task/decision.
112
- // Explicit omission of it must not promote an earlier user source instead.
111
+ // The latest user source still owns first exposure and non-text native floors. An omission never promotes an older source.
113
112
  const latestUser = retainCurrentInput ? branch.findLast(entry => entry.type === "message" && entry.message.role === "user") : undefined;
114
- const userPosition = entries.findIndex(entry => entry.id === latestUser?.id && entry.type === "message");
115
- const current = entries[userPosition];
116
- const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
117
- const textOnly = isTextOnly(userContent);
118
- // Steering can become the latest user source while the run still owns its original prompt.
119
- const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
120
- const starting = entries[startingPosition];
113
+ const latestPosition = entries.findIndex(entry => entry.id === latestUser?.id && entry.type === "message");
114
+ const latest = entries[latestPosition];
115
+ const latestContent = latest?.type === "message" && latest.message.role === "user" ? latest.message.content : undefined;
116
+ const latestTextOnly = isTextOnly(latestContent);
117
+ // During a run, current input is the run owner's starting entry, even after steering. Manual cuts use the latest source.
118
+ const currentId = input.protectedPromptEntryId ?? latestUser?.id;
119
+ const currentPosition = entries.findIndex(entry => entry.id === currentId &&
120
+ (entry.type === "custom_message" || entry.type === "message" && entry.message.role === "user"));
121
+ const current = entries[currentPosition];
121
122
  // Anchor the actual first source only; a native omission never promotes a later prompt.
122
123
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
123
124
  const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
@@ -125,11 +126,11 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
125
126
  const selectedBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === selectedKeptId);
126
127
  // An unobserved input or non-text input stays native. Quoting cannot deliver
127
128
  // its pixels/unknown blocks, and must not count as first exposure.
128
- if (userPosition >= 0 && userPosition < selectedBoundary && (!textOnly || userPosition > lastResponse)) {
129
- const prefix = tuck.buildOwnCut(entries.slice(0, userPosition), 0);
129
+ if (latestPosition >= 0 && latestPosition < selectedBoundary && (!latestTextOnly || latestPosition > lastResponse)) {
130
+ const prefix = tuck.buildOwnCut(entries.slice(0, latestPosition), 0);
130
131
  if (!prefix.ok)
131
132
  return { unavailable: "No reducible history before the current user input" };
132
- cut = cutBefore(cut, userPosition, prefix);
133
+ cut = cutBefore(cut, latestPosition, prefix);
133
134
  }
134
135
  if (cut.messages.length !== cut.selectedIds.length || (!cut.compactAll && !entries.some((e) => e.id === cut.firstKeptEntryId)))
135
136
  throw new Error("pi-tuck returned an invalid source boundary");
@@ -138,8 +139,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
138
139
  let floorBoundary = freshRound ? lastResponse : entries.length;
139
140
  if (freshBash >= 0)
140
141
  floorBoundary = Math.min(floorBoundary, freshBash);
141
- if (userPosition >= 0 && (!textOnly || userPosition > lastResponse))
142
- floorBoundary = Math.min(floorBoundary, userPosition);
142
+ if (latestPosition >= 0 && (!latestTextOnly || latestPosition > lastResponse))
143
+ floorBoundary = Math.min(floorBoundary, latestPosition);
143
144
  // One fill owns the summary and the contiguous native suffix. Protocol/current-input
144
145
  // floors are representation constraints; every other source competes by rank.
145
146
  return rankedFill();
@@ -256,10 +257,12 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
256
257
  if (cached)
257
258
  return cached;
258
259
  const index = indices.get(entry.id);
259
- const text = entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
260
+ const text = entry.type === "custom_message" ? textOf(entry.content) : entry.type === "message" && entry.message.role === "user" ? textOf(entry.message.content) : "";
260
261
  const sourceTimes = index === undefined ? undefined : [{ sourceIndex: index, ...resolveSourceTime(entry) }];
261
- const records = index === undefined ? [] : take === Infinity
262
- ? [{ kind: "excerpt", role: "user", sourceIndex: index, text, sourceTimes }]
262
+ // Custom entries have a native identity but no global message index.
263
+ const records = index === undefined && entry.type !== "custom_message" ? [] : take === Infinity
264
+ ? [{ kind: "excerpt", role: entry.type === "custom_message" ? "custom" : "user", ...(index === undefined ? {} : { sourceIndex: index }), text, sourceTimes,
265
+ ...(entry.type === "custom_message" ? { sourceEntryId: entry.id } : {}) }]
263
266
  : userPrefixRecords({ kind: "user", text, originalText: text, startsSource: true, sourceIndex: index }, undefined, take).map(record => ({ ...record, sourceTimes }));
264
267
  cached = { records, cost: lineCost(records), whole: take >= text.length };
265
268
  quoteCache.set(key, cached);
@@ -278,12 +281,12 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
278
281
  whole.add(index);
279
282
  }
280
283
  };
281
- if (userPosition >= 0 && userPosition < boundary && textOnly)
284
+ if (latestPosition >= 0 && latestPosition < boundary && latestTextOnly)
285
+ add(latest, Infinity);
286
+ if (currentPosition >= 0 && currentPosition < boundary && current?.id !== latest?.id)
282
287
  add(current, Infinity);
283
- if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id)
284
- add(starting, Infinity);
285
288
  for (const entry of anchors)
286
- if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id) < boundary)
289
+ if (entry.id !== latest?.id && entry.id !== current?.id && positions.get(entry.id) < boundary)
287
290
  add(entry, length);
288
291
  const omissions = [];
289
292
  for (const role of ["user", "assistant", "toolResult"]) {
@@ -399,21 +402,24 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
399
402
  if (row.message.role === "user" && Array.isArray(row.message.content))
400
403
  imageLabels.set(row.index, new Set(row.message.content.flatMap(part => part.type === "image" ? [`[image: ${part.mimeType}]`] : [])));
401
404
  const compiledBoundary = boundary;
405
+ // A protected custom prompt uses its native entry ID in the same anchoring/movement path as indexed user prompts.
406
+ const sourceKey = (record) => current?.type === "custom_message" && record.sourceEntryId === current.id ? current.id : record.sourceIndex;
402
407
  const assemble = (compiled, at = compiledBoundary) => {
403
408
  const split = compiled ? splitSummary(compiled) : undefined;
404
409
  if (compiled && !split)
405
410
  throw new Error("pi-tuck returned an invalid summary");
406
411
  const inputRecords = split ? parseRecords(split.body, split.version) : protectedView.omissions;
407
- const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row => 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));
408
- const anchored = new Set(protectedView.kept.map(record => record.sourceIndex));
409
- const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => row.index));
412
+ const expanded = new Set(protectedView.kept.filter(record => record.kind === "excerpt" && inputRecords.some(row => row.kind === "excerpt" && row.role === record.role && sourceKey(row) === sourceKey(record) && row.text.startsWith(record.text) && row.text.length >= record.text.length)).map(sourceKey));
413
+ const anchored = new Set(protectedView.kept.map(sourceKey));
414
+ const moved = new Set(rows.filter(row => row.position >= at && row.position < compiledBoundary).map(row => current?.type === "custom_message" && row.id === current.id ? row.id : row.index));
410
415
  const records = [...inputRecords.filter(record => {
411
- if (record.sourceIndex !== undefined && moved.has(record.sourceIndex))
416
+ const key = sourceKey(record);
417
+ if (key !== undefined && moved.has(key))
412
418
  return false;
413
419
  if (record.kind === "excerpt" && record.role === "user" && imageLabels.get(record.sourceIndex)?.has(record.text))
414
420
  return true;
415
- return record.role !== "user" || record.sourceIndex === undefined || !anchored.has(record.sourceIndex) || expanded.has(record.sourceIndex);
416
- }), ...protectedView.kept.filter(record => !expanded.has(record.sourceIndex) && !moved.has(record.sourceIndex))];
421
+ return record.role !== "user" && record.role !== "custom" || key === undefined || !anchored.has(key) || expanded.has(key);
422
+ }), ...protectedView.kept.filter(record => !expanded.has(sourceKey(record)) && !moved.has(sourceKey(record)))];
417
423
  // Mixed user turns keep their text opening, while their image
418
424
  // metadata remains in the ordinary lower-ranked dialogue lane.
419
425
  const wholeUser = new Set(records.filter(record => record.kind === "excerpt" && record.role === "user" && record.text === userTexts.get(record.sourceIndex)).map(record => record.sourceIndex));
package/index.ts CHANGED
@@ -11,7 +11,7 @@ export type { CompactionOptions } from "./src/compaction";
11
11
  export { installCompaction } from "./src/compaction";
12
12
  export { inspectIncident, readableIncident } from "./src/diagnostic-replay.js";
13
13
  export { createSemanticRecall, SemanticRecallController } from "./src/semantic-recall";
14
- export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/semantic-recall";
14
+ export type { SemanticRecallConfig, PriorResult, SemanticState, SemanticSetup } from "./src/semantic-recall";
15
15
  export { ensureSemanticRuntime } from "./src/setup";
16
16
 
17
17
  /** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
@@ -41,13 +41,19 @@ export default async function (pi: ExtensionAPI) {
41
41
  }
42
42
  const semanticRecall = createSemanticRecall();
43
43
  // `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
44
- let off = false;
45
44
  const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
46
45
  // Semantic recall runs on Metal, so only on Apple silicon; elsewhere the model is not offered tuck_search at all.
47
46
  const appleSilicon = process.platform === "darwin" && process.arch === "arm64";
48
- const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
49
- if (settings.semanticRecall === false) return;
50
- let session = 0, said = "", setup: Promise<unknown> | undefined, announced = false;
47
+ const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async enabled => {
48
+ if (!enabled) semanticRecall.setSetup("off-user");
49
+ else if (semanticRecall.status().setup === "off-user") semanticRecall.setSetup("starting", { installed: existsSync(runtimeConfigPath(defaultRoot())) });
50
+ } });
51
+ if (settings.semanticRecall === false) {
52
+ semanticRecall.setSetup("off-settings");
53
+ pi.on("session_start", () => { semanticRecall.setSetup("off-settings"); });
54
+ return;
55
+ }
56
+ let session = 0, said = "", announced = false, setup: Promise<unknown> | undefined;
51
57
  // A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
52
58
  // With no UI (pi -p), Pi shows no notices, so a settings error or an off or failing semantic recall would go unsaid: write each to stderr
53
59
  // instead, once per session. stderr is the operator's; it never enters the model's context.
@@ -57,41 +63,44 @@ export default async function (pi: ExtensionAPI) {
57
63
  try { ctx.ui.notify(message, level); } catch { /* ended */ }
58
64
  };
59
65
  const once = (ctx: ExtensionContext, message: string, level: "info" | "warning") => { if (said !== message) { said = message; say(ctx, message, level); } };
66
+ const announce = (ctx: ExtensionContext, state: Parameters<typeof semanticRecall.setSetup>[0], detail?: Parameters<typeof semanticRecall.setSetup>[1]) => {
67
+ semanticRecall.setSetup(state, detail);
68
+ const notice = semanticRecall.setupNotice();
69
+ if (notice) (state === "off-platform" || state === "off-project" ? once : say)(ctx, notice.message, notice.level);
70
+ };
60
71
  pi.on("session_shutdown", () => { session++; });
61
72
  pi.on("session_start", (_event, ctx) => {
62
73
  const current = ++session;
63
- off = false;
64
74
  stderrSaid.clear();
65
75
  if (settings.error) once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
66
76
  if (!appleSilicon)
67
- return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.", "info");
77
+ return announce(ctx, "off-platform");
68
78
  // Semantic recall keeps its runtime and indexes outside the project it serves, so not when Pi runs in a folder that holds its
69
79
  // agent folder, such as the home folder.
70
80
  if (within(real(getAgentDir()), real(ctx.cwd)))
71
- return once(ctx, "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.", "info");
81
+ return announce(ctx, "off-project");
72
82
  // A run with no UI (pi -p, RPC, JSON) uses a runtime that is already installed: that needs no download. It does not start a first-time
73
83
  // setup, which would outlast a one-shot run, and it says why, so tuck_search and /tuck name the real state.
74
84
  const installed = existsSync(runtimeConfigPath(defaultRoot()));
75
- if (!ctx.hasUI && !installed) {
76
- semanticRecall.refuse("Semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works meanwhile");
77
- return say(ctx, "pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.", "warning");
78
- }
79
- if (!semanticRecall.status().configured)
80
- semanticRecall.refuse(installed ? "Semantic recall is starting; word recall works meanwhile" : "Semantic recall is being set up; word recall works meanwhile");
81
- setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
85
+ if (!ctx.hasUI && !installed) return announce(ctx, "not-installed");
86
+ // The download's announcement outlives the session that made it: a session that starts while the shared setup is still running
87
+ // owes the "set up" notice once, as before the refactor.
88
+ semanticRecall.setSetup("starting", { installed, announced });
89
+ setup ??= ensureSemanticRuntime(defaultRoot(), message => {
90
+ announced = true;
91
+ if (current === session && semanticRecall.status().setup !== "off-user") announce(ctx, "starting", { installed, notice: message, announced: true });
92
+ });
82
93
  setup.then(async () => {
83
- if (current !== session || off) return;
94
+ if (current !== session || semanticRecall.status().setup === "off-user") return;
84
95
  try {
85
- await handle.setSemanticEnabled(true, ctx); // reads the runtime's configuration through the outside-project check
86
- // The download was announced, so its end is too: once, when semantic recall first starts on it.
87
- if (announced && current === session) { announced = false; say(ctx, "pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.", "info"); }
96
+ await handle.setSemanticEnabled(true, ctx);
97
+ if (current === session && semanticRecall.status().setup !== "off-user") { announce(ctx, "ready", { announced }); announced = false; }
88
98
  } catch (error) {
89
- if (current === session) say(ctx, `pi-tuck: semantic recall could not start (${reason(error)}); it tries again when a session starts.`, "warning");
99
+ if (current === session && semanticRecall.status().setup !== "off-user") announce(ctx, "failed", { stage: "start", reason: reason(error) });
90
100
  }
91
101
  }, error => {
92
102
  setup = undefined; // the next session start tries again
93
- semanticRecall.refuse(`Semantic recall setup failed: ${reason(error)}`);
94
- if (current === session) say(ctx, `pi-tuck: semantic recall setup failed (${reason(error)}). Word recall works; setup retries when a session starts.`, "warning");
103
+ if (current === session && semanticRecall.status().setup !== "off-user") announce(ctx, "failed", { stage: "setup", reason: reason(error) });
95
104
  });
96
105
  });
97
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
1
+ export const BUILD_ID = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";