@pi-unipi/unipi 2.20.3 → 2.20.5

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 CHANGED
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.20.5] — 2026-09-19
10
+
11
+ ### Fixed
12
+
13
+ - `memory`: **MemPalace init no longer blocks startup by ~8–9s, and its migration finally converges.** `MemoryStorage.init()` ran the palace `ping` and the full `migrate` synchronously inside the memory `session_start` handler; whenever the source fingerprint had changed (i.e. after almost any memory write) the migrate re-embedded every changed record and, when a MemPalace daemon held the per-palace mine lock, every contended upsert raised `MineAlreadyRunning`, was counted as a hard `failed`, and the completion marker (`failed===0`) never advanced — so the whole ~8s sweep re-ran on every boot. Time-to-first-input dropped from ~9.5s to ~1s. The fix spans several layers:
14
+ - **Non-blocking init.** `init()` now does only the cheap install check and marks the backend active optimistically; `ping` + migration/catch-up run in a single-flight background task via `runBridgeAsync` (the spawned child is `unref()`d so a fire-and-forget migrate never keeps the process alive). `session_start` no longer awaits the bridge.
15
+ - **Contention-aware convergence.** The Python bridge now classifies `MineAlreadyRunning` as `deferred` (with `deferred_keys`), distinct from a genuine `failed`, and accepts an `only:[keys]` targeted pass. `markMigrated` advances when `failed===0 && verified+deferred===discovered`, persisting the deferred keys with an exponential backoff (15m → 1h → 6h → 24h) so later sessions retry just those keys instead of re-sweeping. `deferred` (lock) is kept strictly separate from `failed` (malformed) so the marker never advances over lost data.
16
+ - **Record-level ledger.** A new `~/.unipi/memory/.mempalace-ledger.json` maps `project/id` → sha256 of the exact markdown bytes last confirmed in the palace, replacing the size+mtime fingerprint that a normal `store()` invalidated on every write. Catch-up now migrates only records whose bytes differ from the ledger (plus due-for-retry deferred keys), `store()` records the ledger only on a confirmed upsert, and an existing `.mempalace-migrated` marker is used once to seed the ledger. The bridge stops discovering legacy `memory.db` (the SQLite tier is gone), so the corpus matches the ledger scanner exactly.
17
+ - **Daemon-aware catch-up.** `probeDaemon()` reads the palace daemon's `endpoint.json` + token and checks `/health`; when a daemon is reachable and actively mining, the background catch-up stands down for the session and lets the backoff ride out the lock instead of contending. When no daemon is running, behaviour is unchanged. (Writes are deliberately not routed through the daemon `/jobs` queue: it exposes no idempotent record-upsert job, and its generic write path uses a content-addressed drawer id incompatible with the bridge's source-URI id.)
18
+ - **Ping-cache hygiene.** Bridge calls now distinguish a successful `null` result and transient lock contention from real backend errors (`runBridgeOutcome`/`isTransientBridgeError`), so `.mempalace-ping-verified` is only invalidated on a genuine failure instead of on nearly every session.
19
+
20
+ ## [2.20.4] — 2026-09-19
21
+
22
+ ### Changed
23
+
24
+ - `fusion`: **the `/unipi:model` picker name column now grows with the terminal.** `NAME_COL` was a fixed 24 columns, so every name past 23 characters was ellipsized even on a wide terminal and the thinking-effort control sat close to the name. `packages/fusion/src/picker.ts` now computes the column per render from the widest row label (`NAME_COL_MIN = 24`, capped to the available width) and keeps the effort bar right-anchored, so full names fit before the effort control.
25
+
26
+ ### Fixed
27
+
28
+ - `fusion`: **model rows that look like a `provider/id` key are no longer ambiguous.** Rows label with `model.name || model.id` and never showed the provider, so an OpenRouter model named `deepseek/deepseek-v4.1-flash` was indistinguishable from a model actually served by provider `deepseek`. A dim `provider · ` prefix is now added only when the display name contains `/` or the same name is offered by more than one provider; the prefix is never truncated (the friendly name truncates instead). The highlighted row's price panel now spells out the exact registry key (`Model key openrouter/deepseek/deepseek-v4.1-flash`, both lead and sidekick keys on the Fusion row).
29
+
9
30
  ## [2.20.3] — 2026-09-17
10
31
 
11
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,29 +84,29 @@
84
84
  "typebox": "^1.1.38"
85
85
  },
86
86
  "dependencies": {
87
- "@pi-unipi/ask-user": "2.20.3",
88
- "@pi-unipi/background-tasks": "2.20.3",
89
- "@pi-unipi/btw": "2.20.3",
90
- "@pi-unipi/command-enchantment": "2.20.3",
91
- "@pi-unipi/compactor": "2.20.3",
92
- "@pi-unipi/core": "2.20.3",
93
- "@pi-unipi/footer": "2.20.3",
94
- "@pi-unipi/image": "2.20.3",
95
- "@pi-unipi/fusion": "2.20.3",
96
- "@pi-unipi/info-screen": "2.20.3",
97
- "@pi-unipi/input-shortcuts": "2.20.3",
98
- "@pi-unipi/kanboard": "2.20.3",
99
- "@pi-unipi/mcp": "2.20.3",
100
- "@pi-unipi/memory": "2.20.3",
101
- "@pi-unipi/milestone": "2.20.3",
102
- "@pi-unipi/notify": "2.20.3",
103
- "@pi-unipi/ralph": "2.20.3",
104
- "@pi-unipi/subagents": "2.20.3",
105
- "@pi-unipi/trajectory": "2.20.3",
106
- "@pi-unipi/updater": "2.20.3",
107
- "@pi-unipi/utility": "2.20.3",
108
- "@pi-unipi/web-api": "2.20.3",
109
- "@pi-unipi/workflow": "2.20.3"
87
+ "@pi-unipi/ask-user": "2.20.5",
88
+ "@pi-unipi/background-tasks": "2.20.5",
89
+ "@pi-unipi/btw": "2.20.5",
90
+ "@pi-unipi/command-enchantment": "2.20.5",
91
+ "@pi-unipi/compactor": "2.20.5",
92
+ "@pi-unipi/core": "2.20.5",
93
+ "@pi-unipi/footer": "2.20.5",
94
+ "@pi-unipi/image": "2.20.5",
95
+ "@pi-unipi/fusion": "2.20.5",
96
+ "@pi-unipi/info-screen": "2.20.5",
97
+ "@pi-unipi/input-shortcuts": "2.20.5",
98
+ "@pi-unipi/kanboard": "2.20.5",
99
+ "@pi-unipi/mcp": "2.20.5",
100
+ "@pi-unipi/memory": "2.20.5",
101
+ "@pi-unipi/milestone": "2.20.5",
102
+ "@pi-unipi/notify": "2.20.5",
103
+ "@pi-unipi/ralph": "2.20.5",
104
+ "@pi-unipi/subagents": "2.20.5",
105
+ "@pi-unipi/trajectory": "2.20.5",
106
+ "@pi-unipi/updater": "2.20.5",
107
+ "@pi-unipi/utility": "2.20.5",
108
+ "@pi-unipi/web-api": "2.20.5",
109
+ "@pi-unipi/workflow": "2.20.5"
110
110
  },
111
111
  "devDependencies": {
112
112
  "@earendil-works/pi-agent-core": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.20.3"
43
+ "@pi-unipi/core": "2.20.5"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.84.0",
13
- "@pi-unipi/core": "2.20.3",
13
+ "@pi-unipi/core": "2.20.5",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.84.0"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "2.20.3"
40
+ "@pi-unipi/core": "2.20.5"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.20.3",
38
- "@pi-unipi/info-screen": "2.20.3",
37
+ "@pi-unipi/core": "2.20.5",
38
+ "@pi-unipi/info-screen": "2.20.5",
39
39
  "@earendil-works/pi-agent-core": "^0.84.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.20.3",
36
- "@pi-unipi/background-tasks": "2.20.3"
35
+ "@pi-unipi/core": "2.20.5",
36
+ "@pi-unipi/background-tasks": "2.20.5"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -10,10 +10,9 @@ pi's extension and RPC APIs natively.
10
10
  ╭ Model ───────────────────────────────────────────────────────────────╮
11
11
  │/ Type to search │
12
12
  │───────────────────────────────────────────────────────────────────────│
13
- │❭ Fusion ← ▰▰▰▱▱ → Medium Lead Claude Fable… ▾
14
- │· GLM-5.3 Flash ✱ ▰▰▰▰▰ Max
15
- │· Claude Fable 5.1 ▰▰▰▱▱ Medium
16
- │· DeepSeek V4.1 Flash ✱ ▰▱▱▱▱ High │
13
+ │❭ Fusion ← ▰▰▰▱▱ → Medium Lead Fable… ▾
14
+ │· GLM-5.3 Flash ✱ ▰▰▰▰▰ Max
15
+ │· openrouter · deepseek/deepseek-v4.1-flash ▰▰▱▱▱ High
17
16
  │ ↓ more below │
18
17
  │ │
19
18
  │ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
@@ -21,6 +20,7 @@ pi's extension and RPC APIs natively.
21
20
  │ $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M │
22
21
  │ Sidekick cached input Sidekick output │
23
22
  │ $0.02 / 1M $1.2 / 1M │
23
+ │ Model key openrouter/deepseek/deepseek-v4.1-flash │
24
24
  │ │
25
25
  │ ✱ New ✱ Promotion ✱ Beta · Pairs frontier intelligence with cost-efficient execution │
26
26
  │↑↓ select · tab lead · ←→ effort · ↵ confirm · esc cancel │
@@ -44,7 +44,16 @@ pi's extension and RPC APIs natively.
44
44
  - **Price panel**: the highlighted model's blended price is marked on a
45
45
  logarithmic red→orange→yellow→green→cyan→blue→violet slider. Fusion shows
46
46
  `Input`, `Cached input`, `Output`, `Sidekick input`, `Sidekick cached input`,
47
- and `Sidekick output`.
47
+ and `Sidekick output`, and the exact registry key of the highlighted row is
48
+ spelled out (`Model key openrouter/deepseek/deepseek-v4.1-flash`; both the
49
+ lead and sidekick keys on the Fusion row).
50
+ - **Name column & provider**: the name column sizes itself to the widest row
51
+ label (minimum 24 columns) up to the terminal width, and the effort control
52
+ stays right-anchored — so wide terminals show full names. A row only gets a
53
+ dim `provider · ` prefix when its display name is ambiguous: it contains a
54
+ `/` (`deepseek/deepseek-v4.1-flash` reads like a key) or the same friendly
55
+ name is offered by more than one provider. Unique names stay clean, and the
56
+ prefix is never truncated.
48
57
  - **Confirm** applies: `pi.setModel`, `pi.setThinkingLevel(effort)`, updates
49
58
  MRU + persisted active selection, and shows `Fusion · Lead ◆ Sidekick` in
50
59
  the footer. Switching the model through pi's own `/model`/Ctrl+P drops
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.20.3",
36
- "@pi-unipi/subagents": "2.20.3"
35
+ "@pi-unipi/core": "2.20.5",
36
+ "@pi-unipi/subagents": "2.20.5"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
@@ -109,9 +109,39 @@ type Row = { kind: "fusion" } | { kind: "model"; key: ModelKey };
109
109
  type FusionFocus = "effort" | "lead" | "sidekick";
110
110
 
111
111
  const DEFAULT_VISIBLE_ROWS = 10;
112
- const NAME_COL = 24;
112
+ /** Minimum width of the name column (the pre-autosize default). */
113
+ const NAME_COL_MIN = 24;
113
114
  const MARKER_COL = 2;
114
115
  const BAR_SEGMENTS = 5;
116
+ /** Visible columns outside the name: pointer + marker prefix (4) + effort block (19). */
117
+ const FIXED_COLS = 23;
118
+
119
+ /**
120
+ * A model needs a provider prefix when its display name alone is ambiguous:
121
+ * it contains a "/" (so it reads like a `provider/id` key), or the same name
122
+ * is offered by more than one provider.
123
+ */
124
+ function ambiguousNamesOf(models: readonly PickerModel[]): Set<string> {
125
+ const providersByName = new Map<string, Set<string>>();
126
+ for (const m of models) {
127
+ const set = providersByName.get(m.name) ?? new Set<string>();
128
+ set.add(m.provider);
129
+ providersByName.set(m.name, set);
130
+ }
131
+ const out = new Set<string>();
132
+ for (const [name, set] of providersByName) {
133
+ if (set.size > 1) out.add(name);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function needsProvider(m: PickerModel, ambiguous: ReadonlySet<string>): boolean {
139
+ return m.name.includes("/") || ambiguous.has(m.name);
140
+ }
141
+
142
+ function labelOf(m: PickerModel, ambiguous: ReadonlySet<string>): string {
143
+ return needsProvider(m, ambiguous) ? `${m.provider} \u00b7 ${m.name}` : m.name;
144
+ }
115
145
 
116
146
  function printable(data: string): string | undefined {
117
147
  if (data.length !== 1) return undefined;
@@ -146,6 +176,9 @@ export class ModelPicker {
146
176
  private readonly modelsByKey: Map<ModelKey, PickerModel>;
147
177
  private readonly state: PickerState;
148
178
  private readonly priceRange: { min: number; max: number };
179
+ private readonly ambiguousNames: Set<string>;
180
+ /** Name column sized to the widest row label; capped to the available width at render time. */
181
+ private readonly naturalNameCol: number;
149
182
 
150
183
  private effort: Record<ModelKey, EffortLevel>;
151
184
  /** Fusion-row efforts — deliberately NOT stored in the per-model map. */
@@ -166,6 +199,12 @@ export class ModelPicker {
166
199
  this.onRenderRequest = options.onRenderRequest;
167
200
  this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
168
201
  this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
202
+ this.ambiguousNames = ambiguousNamesOf(options.state.models);
203
+ const widestLabel = options.state.models.reduce(
204
+ (max, m) => Math.max(max, visibleWidth(labelOf(m, this.ambiguousNames))),
205
+ 0,
206
+ );
207
+ this.naturalNameCol = Math.max(NAME_COL_MIN, widestLabel + 2);
169
208
  const prices = options.state.models.map((m) => (hasPricing(m.cost) ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined && p > 0);
170
209
  this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
171
210
  this.effort = { ...options.state.effort };
@@ -416,7 +455,11 @@ export class ModelPicker {
416
455
  return shortName(m?.name ?? key, max);
417
456
  }
418
457
 
419
- private renderRow(row: Row, highlighted: boolean, width: number): string {
458
+ private isAmbiguous(model: PickerModel): boolean {
459
+ return needsProvider(model, this.ambiguousNames);
460
+ }
461
+
462
+ private renderRow(row: Row, highlighted: boolean, width: number, nameCol: number): string {
420
463
  const t = this.theme;
421
464
  const pointer = highlighted ? t.fg("accent", "❭") : t.fg("dim", "·");
422
465
  const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
@@ -429,8 +472,14 @@ export class ModelPicker {
429
472
  : " "
430
473
  : this.markerFor(row.key);
431
474
  const working = row.kind === "model" && row.key === this.state.currentModelKey;
432
- const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, NAME_COL - 1);
433
- const name =
475
+ const model = row.kind === "model" ? this.modelsByKey.get(row.key) : undefined;
476
+ // Only ambiguous rows (slash-y names, or names shared across providers) pay
477
+ // the width cost of a dim provider prefix. The name is truncated, never the
478
+ // prefix, so `openrouter · …` always survives.
479
+ const providerPrefix = model !== undefined && this.isAmbiguous(model) ? t.fg("dim", `${model.provider} · `) : "";
480
+ const nameBudget = Math.max(1, nameCol - visibleWidth(providerPrefix) - 1);
481
+ const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, nameBudget);
482
+ const styled =
434
483
  row.kind === "fusion"
435
484
  ? disabledFusion
436
485
  ? highlighted
@@ -444,7 +493,7 @@ export class ModelPicker {
444
493
  : highlighted
445
494
  ? t.fg("accent", nameRaw)
446
495
  : t.fg("text", nameRaw);
447
- const model = row.kind === "model" ? this.modelsByKey.get(row.key) : undefined;
496
+ const name = `${providerPrefix}${styled}`;
448
497
  const badge = model?.badge;
449
498
  const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
450
499
 
@@ -458,7 +507,7 @@ export class ModelPicker {
458
507
  ? t.fg("accent", effortLabel(level))
459
508
  : t.fg("muted", effortLabel(level));
460
509
 
461
- let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
510
+ let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, nameCol)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
462
511
 
463
512
  if (row.kind === "fusion") {
464
513
  if (disabledFusion) {
@@ -480,10 +529,10 @@ export class ModelPicker {
480
529
  return truncateToWidth(line, Math.max(1, width - 1));
481
530
  }
482
531
 
483
- private renderDropdown(width: number): string[] {
532
+ private renderDropdown(width: number, nameCol: number): string[] {
484
533
  const t = this.theme;
485
534
  const items = this.dropdownItems();
486
- const indent = " ".repeat(MARKER_COL + NAME_COL + 5);
535
+ const indent = " ".repeat(MARKER_COL + nameCol + 5);
487
536
  if (items.length === 0) {
488
537
  return [`${indent}${t.fg("warning", `no ${this.focus} models in preset — run /unipi:fusion-preset`)}`];
489
538
  }
@@ -547,7 +596,23 @@ export class ModelPicker {
547
596
  : !hasPricing(primaryCost);
548
597
  const pricing = !disabledFusion && noPricing ? t.fg("dim", " · no pricing data from provider") : "";
549
598
  const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
550
- return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
599
+ // The row label may be a friendly name; spell out the exact registry key so
600
+ // the highlighted model is unambiguous (`openrouter/deepseek/...`).
601
+ const keys = row.kind === "fusion"
602
+ ? [this.lead, this.sidekick].filter((k): k is ModelKey => k !== undefined)
603
+ : primaryKey === undefined
604
+ ? []
605
+ : [primaryKey];
606
+ const out = [
607
+ truncateToWidth(` ${head}`, width - 1),
608
+ truncateToWidth(` ${vals}`, width - 1),
609
+ ];
610
+ if (keys.length > 0) {
611
+ const keyText = keys.map((k) => t.fg("text", k)).join(t.fg("dim", " · "));
612
+ out.push(truncateToWidth(` ${t.fg("dim", "Model key")} ${keyText}`, width - 1));
613
+ }
614
+ out.push(truncateToWidth(` ${description}`, width - 1));
615
+ return out;
551
616
  }
552
617
 
553
618
  private hintLine(row: Row | undefined): string {
@@ -572,6 +637,10 @@ export class ModelPicker {
572
637
  private renderBody(width: number): string[] {
573
638
  const t = this.theme;
574
639
  const rows = this.rows();
640
+ // Give the name column every spare column (the effort control stays
641
+ // right-anchored) but never less than the historical 24.
642
+ const maxNameCol = Math.max(NAME_COL_MIN, width - FIXED_COLS - 1);
643
+ const nameCol = Math.min(this.naturalNameCol, maxNameCol);
575
644
  if (this.selected >= rows.length) this.selected = Math.max(0, rows.length - 1);
576
645
  const row = rows[this.selected];
577
646
  const lines: string[] = [];
@@ -591,9 +660,9 @@ export class ModelPicker {
591
660
  const r = rows[i];
592
661
  if (r === undefined) continue;
593
662
  const highlighted = i === this.selected;
594
- lines.push(this.renderRow(r, highlighted, width));
663
+ lines.push(this.renderRow(r, highlighted, width, nameCol));
595
664
  if (highlighted && r.kind === "fusion" && this.focus !== "effort") {
596
- lines.push(...this.renderDropdown(width));
665
+ lines.push(...this.renderDropdown(width, nameCol));
597
666
  }
598
667
  }
599
668
  if (end < rows.length) lines.push(t.fg("dim", ` ↓ more below (${String(rows.length - end)})`));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.20.3"
37
+ "@pi-unipi/core": "2.20.5"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.20.3"
36
+ "@pi-unipi/core": "2.20.5"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.20.3"
36
+ "@pi-unipi/core": "2.20.5"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@pi-unipi/core": "2.20.3"
42
+ "@pi-unipi/core": "2.20.5"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.20.3",
3
+ "version": "2.20.5",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "2.20.3"
33
+ "@pi-unipi/core": "2.20.5"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -2,7 +2,7 @@
2
2
 
3
3
  Persistent memory that survives across sessions. Stores facts, preferences, and decisions with semantic vector search, so the agent remembers what you told it last week.
4
4
 
5
- **Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable migration of existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
5
+ **Backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable, incremental migration of existing markdown memories. Detection, ping, and migration run off the startup path, so loading the package never blocks the first prompt.
6
6
 
7
7
  Two storage tiers: MemPalace (or SQLite) for vector similarity search, markdown files for a durable human-readable copy you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
8
8
 
@@ -74,46 +74,56 @@ Examples:
74
74
  Memory has no configuration file. Storage paths are fixed:
75
75
 
76
76
  ```
77
- ~/.unipi/memory/ # UniPi memory root (legacy + markdown tier)
77
+ ~/.unipi/memory/ # UniPi memory root (markdown tier)
78
78
  ├── .mempalace-install # Cached MemPalace venv detection
79
- ├── .mempalace-migrated # Versioned migration verification state
79
+ ├── .mempalace-migrated # Legacy migration marker (seeds the ledger once)
80
+ ├── .mempalace-ledger.json # Record-level sync ledger (project/id -> content hash)
81
+ ├── .mempalace-ping-verified # Recent-ping cache (skips the cold-start ping)
80
82
  ├── global/
81
- ├── memory.db # Global vector DB (SQLite fallback)
82
- │ └── *.md # Global memory files
83
+ └── *.md # Global memory files (durable, human-readable)
83
84
  └── <project_name>/
84
- ├── memory.db # Project vector DB (SQLite fallback)
85
- └── *.md # Project memory files
85
+ └── *.md # Project memory files (durable, human-readable)
86
86
 
87
- ~/.mempalace/palace/ # MemPalace palace (primary backend)
87
+ ~/.mempalace/palace/ # MemPalace palace (vector backend)
88
88
  ```
89
89
 
90
90
  ## MemPalace backend
91
91
 
92
- On first load, the memory package:
92
+ MemPalace is the sole vector backend; the markdown files are the durable,
93
+ human-readable tier and the migration source. On load, the memory package:
93
94
  1. Detects MemPalace; if missing and `uv` is available, runs
94
95
  `uv tool install mempalace` once (caches the venv python path in
95
96
  `~/.unipi/memory/.mempalace-install`).
96
- 2. Pings the bridge to confirm the palace is usable.
97
- 3. Fingerprints the durable SQLite and markdown sources. If they differ from
98
- the verified state in `~/.unipi/memory/.mempalace-migrated`, performs an
99
- idempotent read-only migration into MemPalace drawers. Unchanged drawers are
100
- skipped, new or changed memories are upserted, and the versioned state is
101
- written only after every discovered record is verified in MemPalace.
102
- Failed or partial migrations remain unmarked and retry on a later session.
97
+ 2. Marks the backend active immediately, then does everything else
98
+ **off the startup path** so time-to-first-input is never blocked: a
99
+ background task pings the bridge (skipped when recently ping-verified) and
100
+ runs an incremental catch-up.
101
+ 3. Catch-up is driven by a record-level ledger
102
+ (`~/.unipi/memory/.mempalace-ledger.json`) mapping `project/id` to the
103
+ sha256 of the markdown bytes last confirmed in the palace. Only records whose
104
+ bytes differ from the ledger are upserted, so an ordinary write never
105
+ triggers a full re-scan. `store()` updates the ledger only after a confirmed
106
+ upsert; a pre-existing `.mempalace-migrated` marker seeds the ledger once.
103
107
  Legacy files are never deleted or mutated.
104
108
 
105
- Each memory operation invokes the packaged Python bridge
106
- (`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). Both the
107
- standalone memory package and the all-in-one umbrella tarball ship and resolve
108
- this bridge. The
109
- first MemPalace use on a machine also downloads the default ONNX embedding
110
- model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
109
+ Records contended by a running MemPalace daemon's mine lock are recorded as
110
+ **deferred** (never as failures) and retried with exponential backoff, so the
111
+ catch-up always converges instead of re-running every boot. When a daemon is
112
+ reachable and actively mining, the background catch-up stands down for the
113
+ session rather than fighting the lock.
114
+
115
+ Memory operations invoke the packaged Python bridge
116
+ (`bridge/mempalace_bridge.py`); startup-path work uses the async, non-blocking
117
+ variant. Both the standalone memory package and the all-in-one umbrella tarball
118
+ ship and resolve this bridge. The first MemPalace use on a machine also
119
+ downloads the default ONNX embedding model (~80MB, cached at
120
+ `~/.cache/chroma/onnx_models/`).
111
121
 
112
122
  ### Forcing re-detection / re-migration
113
123
 
114
124
  ```bash
115
125
  rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
116
- rm ~/.unipi/memory/.mempalace-migrated # force a full verified migration pass next session
126
+ rm ~/.unipi/memory/.mempalace-ledger.json # force a full verified catch-up pass next session
117
127
  ```
118
128
 
119
129
  ### Backend override