@pi-unipi/unipi 3.0.0-alpha.39 → 3.0.0-alpha.40

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +23 -23
  3. package/packages/app-bridge/package.json +2 -2
  4. package/packages/app-bridge/src/bridge.ts +50 -4
  5. package/packages/app-bridge/src/search.ts +269 -0
  6. package/packages/app-bridge/src/snapshot.ts +21 -4
  7. package/packages/app-bridge/src/wire.ts +16 -2
  8. package/packages/ask-user/package.json +2 -2
  9. package/packages/autocomplete/package.json +1 -1
  10. package/packages/background-tasks/package.json +2 -2
  11. package/packages/btw/package.json +2 -2
  12. package/packages/compactor/package.json +3 -3
  13. package/packages/compactor/src/commands/index.ts +1 -1
  14. package/packages/core/command-echo.ts +74 -1
  15. package/packages/core/package.json +1 -1
  16. package/packages/dream/extensions/dream.ts +13 -0
  17. package/packages/dream/package.json +2 -2
  18. package/packages/footer/package.json +3 -3
  19. package/packages/fusion/package.json +4 -4
  20. package/packages/info-screen/package.json +2 -2
  21. package/packages/input-shortcuts/package.json +2 -2
  22. package/packages/kanboard/package.json +7 -7
  23. package/packages/kanboard/src/commands.ts +17 -3
  24. package/packages/long-horizon/package.json +2 -2
  25. package/packages/long-horizon/src/commands.ts +39 -10
  26. package/packages/long-horizon/src/progress-view.ts +79 -82
  27. package/packages/long-horizon/src/visualize.ts +3 -2
  28. package/packages/mcp/package.json +2 -2
  29. package/packages/memory/commands.ts +12 -2
  30. package/packages/memory/index.ts +21 -1
  31. package/packages/memory/package.json +3 -3
  32. package/packages/notify/package.json +2 -2
  33. package/packages/skill-registry/package.json +2 -2
  34. package/packages/subagents/package.json +3 -3
  35. package/packages/unipi/bundled.js +641 -275
  36. package/packages/updater/package.json +2 -2
  37. package/packages/updater/src/channel.ts +13 -0
  38. package/packages/updater/src/checker.ts +8 -4
  39. package/packages/updater/src/tui/update-overlay.ts +2 -1
  40. package/packages/updater/types.ts +5 -0
  41. package/packages/utility/package.json +2 -2
  42. package/packages/utility/src/summarize/index.ts +7 -6
  43. package/packages/watchdog/package.json +3 -3
  44. package/packages/web-api/package.json +2 -2
  45. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.0.0-alpha.40] — 2026-10-12
10
+
11
+ ### Added
12
+ - Typing `/` in the UniPi app suggests every command pi has, and `/command ` suggests that command's arguments, the same as in the terminal. A command's replies now show in the chat under the command. (UNI-276)
13
+ - `/unipi:dream` suggests its subcommands (status, tray, report, run, stop, approve, reject).
14
+ - App search: the UniPi app can search the whole session by meaning (word forms, typos, phrases) and jump to a match. (UNI-275)
15
+ - Memory: the first session that finds 2.x memories asks once whether to convert them. (UNI-272)
16
+ - Upgrade guide from 2.20 to 3.0: `docs/guide/upgrade-3.0.md`. (UNI-272)
17
+
18
+ ### Changed
19
+ - `/unipi:visualize-progress` is calmer: one title, one bar, smaller boxes and the three newest log lines. When Goal mode is on but no goal has started yet, it says so instead of "No long-horizon mode active". (UNI-274)
20
+
21
+ ### Fixed
22
+ - `/unipi:ralph <text>` + Enter no longer clears the text. (UNI-277)
23
+ - `/unipi:goal`, `/unipi:swarm`, `/unipi:graph` and `/unipi:ralph` prompts (and `resume`) sent while the agent was working were silently dropped. They now run when the current turn ends. (UNI-276)
24
+ - `/unipi:summarize` no longer freezes the input for the whole summary, and queues itself instead of refusing while the agent works. `/unipi:goal status` (the progress estimate), `/unipi:kanboard open|status|doctor` and `/unipi:memory-forget` run in the background, and `/unipi:session-recall` sent mid-run waits for the turn to end. (UNI-276)
25
+ - Updater: an alpha install that is offered 3.0.0 stable now installs it, instead of reinstalling the alpha. (UNI-272)
26
+
9
27
  ## [3.0.0-alpha.39] — 2026-10-11
10
28
 
11
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,28 +80,28 @@
80
80
  "typebox": "^1.1.38"
81
81
  },
82
82
  "dependencies": {
83
- "@pi-unipi/app-bridge": "3.0.0-alpha.39",
84
- "@pi-unipi/ask-user": "3.0.0-alpha.39",
85
- "@pi-unipi/background-tasks": "3.0.0-alpha.39",
86
- "@pi-unipi/btw": "3.0.0-alpha.39",
87
- "@pi-unipi/command-enchantment": "3.0.0-alpha.39",
88
- "@pi-unipi/compactor": "3.0.0-alpha.39",
89
- "@pi-unipi/core": "3.0.0-alpha.39",
90
- "@pi-unipi/dream": "3.0.0-alpha.39",
91
- "@pi-unipi/footer": "3.0.0-alpha.39",
92
- "@pi-unipi/fusion": "3.0.0-alpha.39",
93
- "@pi-unipi/info-screen": "3.0.0-alpha.39",
94
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.39",
95
- "@pi-unipi/kanboard": "3.0.0-alpha.39",
96
- "@pi-unipi/mcp": "3.0.0-alpha.39",
97
- "@pi-unipi/memory": "3.0.0-alpha.39",
98
- "@pi-unipi/notify": "3.0.0-alpha.39",
99
- "@pi-unipi/skill-registry": "3.0.0-alpha.39",
100
- "@pi-unipi/subagents": "3.0.0-alpha.39",
101
- "@pi-unipi/updater": "3.0.0-alpha.39",
102
- "@pi-unipi/utility": "3.0.0-alpha.39",
103
- "@pi-unipi/web-api": "3.0.0-alpha.39",
104
- "@pi-unipi/workflow": "3.0.0-alpha.39",
83
+ "@pi-unipi/app-bridge": "3.0.0-alpha.40",
84
+ "@pi-unipi/ask-user": "3.0.0-alpha.40",
85
+ "@pi-unipi/background-tasks": "3.0.0-alpha.40",
86
+ "@pi-unipi/btw": "3.0.0-alpha.40",
87
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.40",
88
+ "@pi-unipi/compactor": "3.0.0-alpha.40",
89
+ "@pi-unipi/core": "3.0.0-alpha.40",
90
+ "@pi-unipi/dream": "3.0.0-alpha.40",
91
+ "@pi-unipi/footer": "3.0.0-alpha.40",
92
+ "@pi-unipi/fusion": "3.0.0-alpha.40",
93
+ "@pi-unipi/info-screen": "3.0.0-alpha.40",
94
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.40",
95
+ "@pi-unipi/kanboard": "3.0.0-alpha.40",
96
+ "@pi-unipi/mcp": "3.0.0-alpha.40",
97
+ "@pi-unipi/memory": "3.0.0-alpha.40",
98
+ "@pi-unipi/notify": "3.0.0-alpha.40",
99
+ "@pi-unipi/skill-registry": "3.0.0-alpha.40",
100
+ "@pi-unipi/subagents": "3.0.0-alpha.40",
101
+ "@pi-unipi/updater": "3.0.0-alpha.40",
102
+ "@pi-unipi/utility": "3.0.0-alpha.40",
103
+ "@pi-unipi/web-api": "3.0.0-alpha.40",
104
+ "@pi-unipi/workflow": "3.0.0-alpha.40",
105
105
  "@resvg/resvg-wasm": "^2.6.2",
106
106
  "beautiful-mermaid": "^1.1.3"
107
107
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/app-bridge",
3
- "version": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
4
4
  "description": "Live bridge between a pi TUI session and the UniPi phone app — streaming chat, prompts, steering and dialogs from the phone",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.39"
34
+ "@pi-unipi/core": "3.0.0-alpha.40"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -14,7 +14,8 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
14
14
  import { homedir } from "node:os";
15
15
  import { join, relative, resolve as resolvePath } from "node:path";
16
16
  import { DialogHub, wrapUi } from "./dialogs.js";
17
- import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, wantedEntry } from "./snapshot.js";
17
+ import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry } from "./snapshot.js";
18
+ import { searchSession } from "./search.js";
18
19
  import {
19
20
  BRIDGE_PROTOCOL,
20
21
  LineSplitter,
@@ -44,7 +45,7 @@ import { fileSuggestions } from "./files.js";
44
45
  import { statPaths } from "./paths.js";
45
46
  import { registerPath, resolveMedia } from "./media.js";
46
47
  import { listWorkItems, stopWorkItem, backgroundWorkItem, workLogPage } from "./work.js";
47
- import { bus, callCommandRunner, pendingWorkLabel, silenceCommandEcho, subscribePendingWork, UNIPI_EVENTS } from "@pi-unipi/core";
48
+ import { bus, callCommandRunner, commandArgCompleters, pendingWorkLabel, silenceCommandEcho, subscribePendingWork, UNIPI_EVENTS } from "@pi-unipi/core";
48
49
  import type { BtwListPage } from "./wire.js";
49
50
 
50
51
  /** `media_chunk.data` (base64) stays well under the 900 KiB bridge line
@@ -604,6 +605,8 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
604
605
  }, 1000);
605
606
  };
606
607
 
608
+ /** Set once syncCustom exists (below); pushes fresh custom entries shortly after a command ran. */
609
+ let syncSoon: () => void = () => {};
607
610
  const commands = (): CommandInfo[] => {
608
611
  let list: CommandInfo[] = [];
609
612
  try {
@@ -1175,6 +1178,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1175
1178
  try {
1176
1179
  pi.sendUserMessage(content, { expandPromptTemplates: true } as never);
1177
1180
  write(sock, { t: "ack", ref: msg.ref, ...(isCommand ? { as: "command" as const } : {}) });
1181
+ if (isCommand) syncSoon();
1178
1182
  } catch (error) {
1179
1183
  fail(error instanceof Error ? error.message : String(error));
1180
1184
  }
@@ -1208,6 +1212,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1208
1212
  try {
1209
1213
  pi.sendUserMessage(content, { expandPromptTemplates: true } as never);
1210
1214
  write(sock, { t: "ack", ref: msg.ref, ...(isCommand ? { as: "command" as const } : {}) });
1215
+ if (isCommand) syncSoon();
1211
1216
  } catch (error) {
1212
1217
  fail(error instanceof Error ? error.message : String(error));
1213
1218
  }
@@ -1225,6 +1230,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1225
1230
  try {
1226
1231
  pi.sendUserMessage(content, { deliverAs, expandPromptTemplates: true } as never);
1227
1232
  write(sock, { t: "ack", ref: msg.ref, ...(isCommand ? { as: "command" as const } : {}) });
1233
+ if (isCommand) syncSoon();
1228
1234
  } catch (error) {
1229
1235
  fail(error instanceof Error ? error.message : String(error));
1230
1236
  }
@@ -1396,10 +1402,36 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1396
1402
  write(sock, hello());
1397
1403
  return;
1398
1404
  case "history": {
1399
- const page = historyPage(c.sessionManager.getBranch(), msg.before, Math.min(ENTRIES_BUDGET, LINE_BUDGET - 32 * 1024));
1405
+ const page = historyPage(c.sessionManager.getBranch(), msg.before, Math.min(ENTRIES_BUDGET, LINE_BUDGET - 32 * 1024), msg.until);
1400
1406
  write(sock, { t: "history", before: msg.before, entries: page.entries, more: page.more, ref: msg.ref });
1401
1407
  return;
1402
1408
  }
1409
+ case "search": {
1410
+ // UNI-275: the whole branch (not just what the phone has paged in).
1411
+ const hidden = editedAway(c.sessionManager.getBranch());
1412
+ const branch = (c.sessionManager.getBranch() as Array<{ id?: string }>).filter((e) => !hidden.has(e.id ?? "") && wantedEntry(e));
1413
+ write(sock, { t: "search", query: msg.query, hits: searchSession(branch, msg.query, 60), ref: msg.ref });
1414
+ return;
1415
+ }
1416
+ case "arg_completions": {
1417
+ // UNI-276: the same argument completions the terminal's autocomplete shows.
1418
+ let items: Array<{ value: string; label: string; description?: string }> = [];
1419
+ try {
1420
+ const cmd = (pi.getCommands() as Array<{ name: string; source: string; getArgumentCompletions?: unknown }>).find((x) => x.name === msg.command);
1421
+ const fn = (cmd as { getArgumentCompletions?: (p: string) => unknown } | undefined)?.getArgumentCompletions ?? commandArgCompleters().get(msg.command);
1422
+ const out = fn ? await Promise.resolve(fn(msg.prefix)) : undefined;
1423
+ if (Array.isArray(out)) {
1424
+ items = out
1425
+ .filter((x): x is { value: string; label?: string; description?: string } => !!x && typeof (x as { value?: unknown }).value === "string")
1426
+ .slice(0, 50)
1427
+ .map((x) => ({ value: x.value, label: typeof x.label === "string" ? x.label : x.value, ...(typeof x.description === "string" ? { description: clipText(x.description, 160) } : {}) }));
1428
+ }
1429
+ } catch {
1430
+ // A completer that throws just offers nothing.
1431
+ }
1432
+ write(sock, { t: "arg_completions", command: msg.command, prefix: msg.prefix, items, ref: msg.ref });
1433
+ return;
1434
+ }
1403
1435
  case "files": {
1404
1436
  const items = await fileSuggestions(c.cwd, msg.query);
1405
1437
  write(sock, { t: "files", query: msg.query, items, ref: msg.ref });
@@ -1749,7 +1781,10 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1749
1781
  try {
1750
1782
  ctx = c;
1751
1783
  if (c.mode !== "tui" || process.env.UNIPI_SUBAGENT_CHILD === "1" || process.env.UNIPI_APP_BRIDGE === "0") return;
1752
- if (c.hasUI) wrapUi(c.ui, hub, (text, level) => send({ t: "notify", text: clipText(text, 4000), level }));
1784
+ if (c.hasUI) wrapUi(c.ui, hub, (text, level) => {
1785
+ send({ t: "notify", text: clipText(text, 4000), level });
1786
+ syncSoon();
1787
+ });
1753
1788
  computeCost();
1754
1789
  setRemoteDialogs(hub);
1755
1790
  listen();
@@ -2126,6 +2161,17 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
2126
2161
  };
2127
2162
  on("turn_end", safe(syncCustom));
2128
2163
  on("agent_settled", safe(syncCustom));
2164
+ // UNI-276: a command that starts no turn (status / toggles / pickers) still
2165
+ // leaves its echo + reply entries — push them soon after it runs.
2166
+ syncSoon = () => {
2167
+ for (const ms of [120, 1200, 4000]) setTimeout(() => {
2168
+ try {
2169
+ syncCustom();
2170
+ } catch {
2171
+ // never break pi
2172
+ }
2173
+ }, ms).unref?.();
2174
+ };
2129
2175
 
2130
2176
  return {
2131
2177
  /** Test seam. */
@@ -0,0 +1,269 @@
1
+ /**
2
+ * UNI-275: search the session for text — similarity-ranked, not just an exact
3
+ * substring. Pure: (branch entries, query) → hits.
4
+ *
5
+ * Score per entry = BM25 over word stems (exact words count most), plus
6
+ * fuzzy credit for query words that only match a vocabulary word by a typo
7
+ * (edit distance ≤ 1 for short words, ≤ 2 for long ones, or a shared
8
+ * prefix), plus a bonus when the whole query appears as a phrase. Field
9
+ * weights: what the user said counts most, then the model's words and
10
+ * commands; tool calls and their output much less (long and noisy). One hit
11
+ * per entry.
12
+ */
13
+
14
+ export interface SearchHit {
15
+ /** Session entry id (the app jumps to it). */
16
+ id: string;
17
+ /** user | assistant | tool (a call) | output (a tool's result) | command | note */
18
+ role: string;
19
+ /** ~160 chars around the best match. */
20
+ snippet: string;
21
+ /** [start, end) of matched words inside `snippet`, for highlighting. */
22
+ marks: Array<[number, number]>;
23
+ score: number;
24
+ timestamp?: string;
25
+ }
26
+
27
+ interface Doc {
28
+ id: string;
29
+ role: string;
30
+ timestamp?: string;
31
+ /** Searchable text with its weight. */
32
+ fields: Array<{ text: string; weight: number }>;
33
+ }
34
+
35
+ const MAX_FIELD = 8 * 1024;
36
+ const MAX_TOOL_OUTPUT = 4 * 1024;
37
+
38
+ function textOf(content: unknown): string {
39
+ if (typeof content === "string") return content;
40
+ if (!Array.isArray(content)) return "";
41
+ return content
42
+ .map((p) => {
43
+ const part = p as { type?: string; text?: unknown };
44
+ return part?.type === "text" && typeof part.text === "string" ? part.text : "";
45
+ })
46
+ .filter(Boolean)
47
+ .join("\n");
48
+ }
49
+
50
+ function argsText(args: unknown): string {
51
+ if (!args || typeof args !== "object") return typeof args === "string" ? args : "";
52
+ const a = args as Record<string, unknown>;
53
+ const picks = ["command", "path", "file_path", "pattern", "query", "url", "task", "title"];
54
+ return picks.map((k) => (typeof a[k] === "string" ? (a[k] as string) : "")).filter(Boolean).join(" ");
55
+ }
56
+
57
+ /** The searchable documents of a branch (one per entry that says something). */
58
+ export function searchDocs(branch: readonly unknown[]): Doc[] {
59
+ const docs: Doc[] = [];
60
+ for (const raw of branch) {
61
+ const e = raw as {
62
+ id?: string;
63
+ type?: string;
64
+ timestamp?: string;
65
+ customType?: string;
66
+ data?: { text?: unknown; command?: unknown };
67
+ content?: unknown;
68
+ message?: { role?: string; content?: unknown; toolName?: string };
69
+ };
70
+ if (!e?.id) continue;
71
+ const base = { id: e.id, ...(e.timestamp ? { timestamp: e.timestamp } : {}) };
72
+ if (e.type === "message" && e.message) {
73
+ const m = e.message;
74
+ if (m.role === "user") docs.push({ ...base, role: "user", fields: [{ text: textOf(m.content).slice(0, MAX_FIELD), weight: 1 }] });
75
+ else if (m.role === "assistant") {
76
+ const parts = Array.isArray(m.content) ? (m.content as Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>) : [];
77
+ const said = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n");
78
+ const calls = parts.filter((p) => p.type === "toolCall").map((p) => `${p.name ?? ""} ${argsText(p.arguments)}`).join("\n");
79
+ // What the model said and what it ran are separate hits (same entry):
80
+ // a command line must not surface as "the assistant said …".
81
+ if (said.trim()) docs.push({ ...base, role: "assistant", fields: [{ text: said.slice(0, MAX_FIELD), weight: 1 }] });
82
+ if (calls.trim()) docs.push({ ...base, role: "tool", fields: [{ text: calls.slice(0, MAX_FIELD), weight: 1 }] });
83
+ } else if (m.role === "toolResult") {
84
+ const out = textOf(m.content);
85
+ if (out.trim()) docs.push({ ...base, role: "output", fields: [{ text: out.slice(0, MAX_TOOL_OUTPUT), weight: 1 }] });
86
+ }
87
+ } else if (e.type === "custom" && e.customType === "unipi-command-echo" && typeof e.data?.text === "string") {
88
+ docs.push({ ...base, role: "command", fields: [{ text: e.data.text, weight: 1 }] });
89
+ } else if (e.type === "custom_message") {
90
+ const t = textOf(e.content);
91
+ if (t.trim()) docs.push({ ...base, role: "note", fields: [{ text: t.slice(0, MAX_FIELD), weight: 1 }] });
92
+ }
93
+ }
94
+ return docs;
95
+ }
96
+
97
+ // ── text ───────────────────────────────────────────────────────────────
98
+
99
+ const WORD = /[\p{L}\p{N}_]+/gu;
100
+
101
+ /** Light English stemmer: enough that "tests"/"testing"/"tested" meet "test". */
102
+ export function stem(word: string): string {
103
+ let w = word.toLowerCase();
104
+ if (w.length > 4 && w.endsWith("ies")) return `${w.slice(0, -3)}y`;
105
+ for (const suffix of ["ing", "ed", "es", "s"]) {
106
+ if (w.length > suffix.length + 2 && w.endsWith(suffix)) {
107
+ w = w.slice(0, -suffix.length);
108
+ break;
109
+ }
110
+ }
111
+ // "running" → "runn" → "run"
112
+ if (w.length > 3 && w.at(-1) === w.at(-2) && !/[aeiou]/.test(w.at(-1)!)) w = w.slice(0, -1);
113
+ return w;
114
+ }
115
+
116
+ function words(text: string): Array<{ word: string; stem: string; start: number; end: number }> {
117
+ const out: Array<{ word: string; stem: string; start: number; end: number }> = [];
118
+ for (const m of text.matchAll(WORD)) {
119
+ const word = m[0].toLowerCase();
120
+ out.push({ word, stem: stem(word), start: m.index!, end: m.index! + m[0].length });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Edit distance with adjacent swaps as one edit ("depoly" → "deploy" = 1); max+1 when farther than `max`. */
126
+ export function editDistance(a: string, b: string, max: number): number {
127
+ if (Math.abs(a.length - b.length) > max) return max + 1;
128
+ let prev2: number[] = [];
129
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
130
+ for (let i = 1; i <= a.length; i++) {
131
+ const cur = [i];
132
+ let best = i;
133
+ for (let j = 1; j <= b.length; j++) {
134
+ let v = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
135
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2]! + 1);
136
+ cur.push(v);
137
+ if (v < best) best = v;
138
+ }
139
+ if (best > max) return max + 1;
140
+ prev2 = prev;
141
+ prev = cur;
142
+ }
143
+ return Math.min(prev[b.length]!, max + 1);
144
+ }
145
+
146
+ /** How much a vocabulary stem stands in for a query stem (1 = same, 0 = not at all). */
147
+ function closeness(q: string, v: string): number {
148
+ if (q === v) return 1;
149
+ if (q.length >= 3 && v.startsWith(q)) return 0.7; // "auth" → "authentication"
150
+ if (v.length >= 4 && q.startsWith(v)) return 0.5; // "authentication" → "auth"
151
+ if (q.length < 4) return 0;
152
+ const max = q.length >= 8 ? 2 : 1;
153
+ const d = editDistance(q, v, max);
154
+ // editDistance returns max+1 for "too far": only real distances count.
155
+ if (d > max || v.length < 3) return 0;
156
+ return d === 1 ? 0.6 : 0.4;
157
+ }
158
+
159
+ // ── search ─────────────────────────────────────────────────────────────
160
+
161
+ /** What people said counts most; what was run, and its output, less (long and noisy). */
162
+ const ROLE_WEIGHT: Record<string, number> = { user: 1.25, assistant: 1, command: 1, note: 0.7, tool: 0.45, output: 0.3 };
163
+
164
+ const K1 = 1.2;
165
+ const B = 0.75;
166
+
167
+ export function searchSession(branch: readonly unknown[], query: string, limit = 50): SearchHit[] {
168
+ const q = query.trim();
169
+ if (!q) return [];
170
+ const qWords = [...new Set(words(q).map((w) => w.stem))];
171
+ if (!qWords.length) return [];
172
+ const docs = searchDocs(branch);
173
+ if (!docs.length) return [];
174
+
175
+ // Per doc: weighted term frequencies by stem, and its length.
176
+ const indexed = docs.map((d) => {
177
+ const tf = new Map<string, number>();
178
+ let len = 0;
179
+ for (const f of d.fields) {
180
+ for (const w of words(f.text)) {
181
+ tf.set(w.stem, (tf.get(w.stem) ?? 0) + f.weight);
182
+ len += f.weight;
183
+ }
184
+ }
185
+ return { d, tf, len: Math.max(1, len) };
186
+ });
187
+ const avgLen = indexed.reduce((n, x) => n + x.len, 0) / indexed.length;
188
+ const df = new Map<string, number>();
189
+ for (const x of indexed) for (const s of x.tf.keys()) df.set(s, (df.get(s) ?? 0) + 1);
190
+ const vocab = [...df.keys()];
191
+ // Each query stem → the vocabulary stems that count for it, with closeness.
192
+ const expansions = new Map<string, Array<{ stem: string; c: number }>>();
193
+ for (const qs of qWords) {
194
+ const list: Array<{ stem: string; c: number }> = [];
195
+ for (const v of vocab) {
196
+ const c = closeness(qs, v);
197
+ if (c > 0) list.push({ stem: v, c });
198
+ }
199
+ list.sort((a, b) => b.c - a.c);
200
+ expansions.set(qs, list.slice(0, 12));
201
+ }
202
+ const n = indexed.length;
203
+ const idf = (s: string) => Math.log(1 + (n - (df.get(s) ?? 0) + 0.5) / ((df.get(s) ?? 0) + 0.5));
204
+ const phrase = q.toLowerCase().replace(/\s+/g, " ");
205
+
206
+ const scored: Array<{ x: (typeof indexed)[number]; score: number; matched: Set<string> }> = [];
207
+ for (const x of indexed) {
208
+ let score = 0;
209
+ let covered = 0;
210
+ const matched = new Set<string>();
211
+ for (const qs of qWords) {
212
+ let best = 0;
213
+ for (const { stem: s, c } of expansions.get(qs) ?? []) {
214
+ const f = x.tf.get(s);
215
+ if (!f) continue;
216
+ const part = c * idf(s) * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * x.len) / avgLen)));
217
+ if (part > best) best = part;
218
+ matched.add(s);
219
+ }
220
+ if (best > 0) covered += 1;
221
+ score += best;
222
+ }
223
+ if (score <= 0) continue;
224
+ // Every query word present beats a few repeated ones (a hit on one word of three is weak).
225
+ const coverage = covered / qWords.length;
226
+ score *= coverage * coverage;
227
+ if (qWords.length > 1 && x.d.fields.some((f) => f.text.toLowerCase().replace(/\s+/g, " ").includes(phrase))) score *= 1.6;
228
+ score *= ROLE_WEIGHT[x.d.role] ?? 1;
229
+ scored.push({ x, score, matched });
230
+ }
231
+ scored.sort((a, b) => b.score - a.score);
232
+ const seen = new Set<string>();
233
+ const unique = scored.filter(({ x }) => (seen.has(x.d.id) ? false : (seen.add(x.d.id), true)));
234
+ return unique.slice(0, limit).map(({ x, score, matched }) => ({
235
+ id: x.d.id,
236
+ role: x.d.role,
237
+ ...snippet(x.d, matched, phrase),
238
+ score: Math.round(score * 1000) / 1000,
239
+ ...(x.d.timestamp ? { timestamp: x.d.timestamp } : {}),
240
+ }));
241
+ }
242
+
243
+ const SNIPPET = 160;
244
+
245
+ function snippet(d: Doc, matched: Set<string>, phrase: string): { snippet: string; marks: Array<[number, number]> } {
246
+ // The best field: the first one with a match, by weight.
247
+ const fields = [...d.fields].sort((a, b) => b.weight - a.weight);
248
+ for (const f of fields) {
249
+ const flat = f.text.replace(/\s+/g, " ").trim();
250
+ const ws = words(flat).filter((w) => matched.has(w.stem));
251
+ if (!ws.length) continue;
252
+ const at = flat.toLowerCase().indexOf(phrase);
253
+ const anchor = at >= 0 ? at : ws[0]!.start;
254
+ let start = Math.max(0, anchor - 50);
255
+ if (start > 0) {
256
+ const space = flat.indexOf(" ", start);
257
+ if (space >= 0 && space < anchor) start = space + 1;
258
+ }
259
+ const end = Math.min(flat.length, start + SNIPPET);
260
+ const pre = start > 0 ? "…" : "";
261
+ const text = pre + flat.slice(start, end) + (end < flat.length ? "…" : "");
262
+ const marks: Array<[number, number]> = ws
263
+ .filter((w) => w.start >= start && w.end <= end)
264
+ .map((w) => [w.start - start + pre.length, w.end - start + pre.length]);
265
+ return { snippet: text, marks };
266
+ }
267
+ const flat = (fields[0]?.text ?? "").replace(/\s+/g, " ").trim();
268
+ return { snippet: flat.slice(0, SNIPPET) + (flat.length > SNIPPET ? "…" : ""), marks: [] };
269
+ }
@@ -229,21 +229,38 @@ export function fitLine(msg: object): string {
229
229
  * that fit `budget`, oldest first. `more` = older entries remain.
230
230
  * `before` not on the branch → empty page, more false.
231
231
  */
232
- export function historyPage(branch: readonly unknown[], before: string, budget = ENTRIES_BUDGET): { entries: Json[]; more: boolean } {
232
+ export function historyPage(branch: readonly unknown[], before: string, budget = ENTRIES_BUDGET, until?: string): { entries: Json[]; more: boolean } {
233
233
  const end = branch.findIndex((e) => (e as { id?: string })?.id === before);
234
234
  if (end <= 0) return { entries: [], more: false };
235
235
  const hidden = editedAway(branch);
236
+ // UNI-275 "navigate to": page back at least to `until` (+ a little context
237
+ // above it); entries are clipped harder once the budget is spent so the
238
+ // page still fits one frame.
239
+ const target = until ? branch.findIndex((e) => (e as { id?: string })?.id === until) : -1;
240
+ const stopAt = target >= 0 && target < end ? Math.max(0, target - 6) : -1;
236
241
  const out: Json[] = [];
237
242
  let used = 0;
238
243
  let i = end - 1;
239
244
  for (; i >= 0; i--) {
240
245
  const e = branch[i];
241
246
  if (!wantedEntry(e) || hidden.has((e as { id?: string }).id ?? "")) continue;
242
- const safe = phoneSafe(e);
243
- const size = jsonBytes(safe) + 1;
244
- if (used + size > budget && out.length > 0) break;
247
+ const reaching = stopAt >= 0 && i >= stopAt;
248
+ let safe = phoneSafe(e);
249
+ let size = jsonBytes(safe) + 1;
250
+ if (used + size > budget && out.length > 0) {
251
+ if (!reaching) break;
252
+ // Reaching for the target: keep going with small copies.
253
+ safe = phoneSafe(e, 1024);
254
+ size = jsonBytes(safe) + 1;
255
+ // Never past one relay frame; the app keeps paging toward the target.
256
+ if (used + size > Math.max(budget, LINE_BUDGET - 96 * 1024)) break;
257
+ }
245
258
  out.push(size > budget ? phoneSafe(e, 8 * 1024) : safe);
246
259
  used += size;
260
+ if (stopAt >= 0 && i <= stopAt && used >= budget / 4) {
261
+ i -= 1;
262
+ break;
263
+ }
247
264
  }
248
265
  // Any wanted entry left before i?
249
266
  let more = false;
@@ -281,8 +281,12 @@ export type OutMsg =
281
281
  | { t: "reconnect"; reason: string }
282
282
  /** A page of older entries (oldest first) ending right before `before`. */
283
283
  | { t: "history"; before: string; entries: unknown[]; more: boolean; ref?: string }
284
+ /** UNI-275: similarity search over this session's branch (newest-first ties). */
285
+ | { t: "search"; query: string; hits: Array<{ id: string; role: string; snippet: string; marks: Array<[number, number]>; score: number; timestamp?: string }>; ref?: string }
284
286
  /** `@` file suggestions for `query` (pi's own finder: fd, .gitignore aware). */
285
287
  | { t: "files"; query: string; items: FileItem[]; ref?: string }
288
+ /** UNI-276: a command's argument suggestions (its getArgumentCompletions), for `/name <prefix>`. */
289
+ | { t: "arg_completions"; command: string; prefix: string; items: Array<{ value: string; label: string; description?: string }>; ref?: string }
286
290
  /** Reply to `paths_stat{paths}` (UNI-204): one item per requested path, same order. */
287
291
  | { t: "paths_stat"; items: Array<{ path: string; resolved: string; kind: "file" | "dir" | "missing" }>; ref?: string }
288
292
  /** Sessions matching `sessions{scope, query?}`, newest first, capped. */
@@ -387,9 +391,11 @@ export type InMsg =
387
391
  | { t: "compact"; instructions?: string; ref?: string }
388
392
  | { t: "resync"; ref?: string }
389
393
  /** Older history: entries before the entry `before` on the active branch. */
390
- | { t: "history"; before: string; ref?: string }
394
+ | { t: "history"; before: string; until?: string; ref?: string }
395
+ | { t: "search"; query: string; ref?: string }
391
396
  /** `@` file suggestions; `query` is the text after `@` (may start with `"`). */
392
397
  | { t: "files"; query: string; ref?: string }
398
+ | { t: "arg_completions"; command: string; prefix: string; ref?: string }
393
399
  /** List sessions to resume: this project's or every project's, optionally filtered. */
394
400
  | { t: "sessions"; scope: "cwd" | "all"; query?: string; ref?: string }
395
401
  /** Start a brand-new session. `force`: abort the current run first. */
@@ -476,7 +482,9 @@ const IN_TYPES = new Set([
476
482
  "compact",
477
483
  "resync",
478
484
  "history",
485
+ "search",
479
486
  "files",
487
+ "arg_completions",
480
488
  "sessions",
481
489
  "session_new",
482
490
  "session_resume",
@@ -545,9 +553,15 @@ export function parseIn(line: string): InMsg | { bad: string; ref?: string } | u
545
553
  return { t: "set_thinking", level: m.level, ref };
546
554
  case "compact":
547
555
  return { t: "compact", instructions: typeof m.instructions === "string" ? m.instructions : undefined, ref };
556
+ case "search":
557
+ if (typeof m.query !== "string") return { bad: "search.query must be a string", ref };
558
+ return { t: "search", query: m.query.slice(0, 256).replace(/[\r\n]/g, " "), ref };
548
559
  case "history":
549
560
  if (typeof m.before !== "string" || !m.before) return { bad: "history.before must be an entry id", ref };
550
- return { t: "history", before: m.before.slice(0, 128), ref };
561
+ return { t: "history", before: m.before.slice(0, 128), ...(typeof m.until === "string" && m.until ? { until: m.until.slice(0, 128) } : {}), ref };
562
+ case "arg_completions":
563
+ if (typeof m.command !== "string" || !m.command || typeof m.prefix !== "string") return { bad: "arg_completions needs command and prefix", ref };
564
+ return { t: "arg_completions", command: m.command.slice(0, 128), prefix: m.prefix.slice(0, 512).replace(/[\r\n]/g, " "), ref };
551
565
  case "files":
552
566
  if (typeof m.query !== "string") return { bad: "files.query must be a string", ref };
553
567
  return { t: "files", query: m.query.slice(0, 512).replace(/[\r\n]/g, ""), ref };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
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",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@pi-unipi/core": "3.0.0-alpha.39"
45
+ "@pi-unipi/core": "3.0.0-alpha.40"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
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": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
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.87.1",
13
- "@pi-unipi/core": "3.0.0-alpha.39",
13
+ "@pi-unipi/core": "3.0.0-alpha.40",
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": "3.0.0-alpha.39",
3
+ "version": "3.0.0-alpha.40",
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.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.39"
40
+ "@pi-unipi/core": "3.0.0-alpha.40"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [