@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.
- package/CHANGELOG.md +18 -0
- package/package.json +23 -23
- package/packages/app-bridge/package.json +2 -2
- package/packages/app-bridge/src/bridge.ts +50 -4
- package/packages/app-bridge/src/search.ts +269 -0
- package/packages/app-bridge/src/snapshot.ts +21 -4
- package/packages/app-bridge/src/wire.ts +16 -2
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/commands/index.ts +1 -1
- package/packages/core/command-echo.ts +74 -1
- package/packages/core/package.json +1 -1
- package/packages/dream/extensions/dream.ts +13 -0
- package/packages/dream/package.json +2 -2
- package/packages/footer/package.json +3 -3
- package/packages/fusion/package.json +4 -4
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +7 -7
- package/packages/kanboard/src/commands.ts +17 -3
- package/packages/long-horizon/package.json +2 -2
- package/packages/long-horizon/src/commands.ts +39 -10
- package/packages/long-horizon/src/progress-view.ts +79 -82
- package/packages/long-horizon/src/visualize.ts +3 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/commands.ts +12 -2
- package/packages/memory/index.ts +21 -1
- package/packages/memory/package.json +3 -3
- package/packages/notify/package.json +2 -2
- package/packages/skill-registry/package.json +2 -2
- package/packages/subagents/package.json +3 -3
- package/packages/unipi/bundled.js +641 -275
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/channel.ts +13 -0
- package/packages/updater/src/checker.ts +8 -4
- package/packages/updater/src/tui/update-overlay.ts +2 -1
- package/packages/updater/types.ts +5 -0
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/summarize/index.ts +7 -6
- package/packages/watchdog/package.json +3 -3
- package/packages/web-api/package.json +2 -2
- 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.
|
|
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.
|
|
84
|
-
"@pi-unipi/ask-user": "3.0.0-alpha.
|
|
85
|
-
"@pi-unipi/background-tasks": "3.0.0-alpha.
|
|
86
|
-
"@pi-unipi/btw": "3.0.0-alpha.
|
|
87
|
-
"@pi-unipi/command-enchantment": "3.0.0-alpha.
|
|
88
|
-
"@pi-unipi/compactor": "3.0.0-alpha.
|
|
89
|
-
"@pi-unipi/core": "3.0.0-alpha.
|
|
90
|
-
"@pi-unipi/dream": "3.0.0-alpha.
|
|
91
|
-
"@pi-unipi/footer": "3.0.0-alpha.
|
|
92
|
-
"@pi-unipi/fusion": "3.0.0-alpha.
|
|
93
|
-
"@pi-unipi/info-screen": "3.0.0-alpha.
|
|
94
|
-
"@pi-unipi/input-shortcuts": "3.0.0-alpha.
|
|
95
|
-
"@pi-unipi/kanboard": "3.0.0-alpha.
|
|
96
|
-
"@pi-unipi/mcp": "3.0.0-alpha.
|
|
97
|
-
"@pi-unipi/memory": "3.0.0-alpha.
|
|
98
|
-
"@pi-unipi/notify": "3.0.0-alpha.
|
|
99
|
-
"@pi-unipi/skill-registry": "3.0.0-alpha.
|
|
100
|
-
"@pi-unipi/subagents": "3.0.0-alpha.
|
|
101
|
-
"@pi-unipi/updater": "3.0.0-alpha.
|
|
102
|
-
"@pi-unipi/utility": "3.0.0-alpha.
|
|
103
|
-
"@pi-unipi/web-api": "3.0.0-alpha.
|
|
104
|
-
"@pi-unipi/workflow": "3.0.0-alpha.
|
|
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.
|
|
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.
|
|
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) =>
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
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.
|
|
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/background-tasks",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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.
|
|
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.
|
|
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.
|
|
40
|
+
"@pi-unipi/core": "3.0.0-alpha.40"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
|
43
43
|
"extensions": [
|