@pi-unipi/unipi 2.0.1 → 2.0.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Unipi
2
2
 
3
- 18 packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, and a live status bar — all wired together through a shared event system.
3
+ 20 workspace packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, command autocomplete, and a live status bar — all wired together through a shared event system.
4
4
 
5
5
  One command installs everything:
6
6
  ```bash
@@ -17,6 +17,8 @@ pi install npm:@pi-unipi/unipi
17
17
 
18
18
  **[Compactor](./packages/compactor/README.md)** — Zero-LLM context engine. 6-stage pipeline hits 95%+ token reduction at zero API cost. Session continuity, sandbox execution, FTS5 search.
19
19
 
20
+ **[CocoIndex](./packages/cocoindex/README.md)** — Project indexing and semantic code search backed by CocoIndex and LanceDB. Agent tools and slash commands for status, init, update, and search.
21
+
20
22
  **[Subagents](./packages/subagents/README.md)** — Parallel execution with file locking. Spawn background agents to research, fix, or build while the main agent keeps going.
21
23
 
22
24
  **[Web API](./packages/web-api/README.md)** — Web search, page reading, content summarization. Smart-fetch engine with browser-grade TLS fingerprinting — free, no API key. Paid providers as fallbacks.
@@ -43,6 +45,8 @@ pi install npm:@pi-unipi/unipi
43
45
 
44
46
  **[Input Shortcuts](./packages/input-shortcuts/README.md)** — Keyboard shortcuts via vim-style chord overlay. Stash/restore, undo/redo, clipboard, thinking toggle.
45
47
 
48
+ **[Command Enchantment](./packages/autocomplete/README.md)** — Enhanced `/unipi:*` autocomplete with package grouping, descriptions, colors, and registry audits that catch stale command docs before release.
49
+
46
50
  ## Architecture
47
51
 
48
52
  Packages discover each other through events, not direct imports. Core defines the event types and constants. Every package emits `MODULE_READY` on load and subscribes to events it cares about.
@@ -77,12 +81,13 @@ Coexists triggers enhance behavior when packages are installed together. Workflo
77
81
  | Workflow | `/unipi:` | brainstorm, plan, work, review-work, consolidate, quick-work, debug, fix |
78
82
  | Ralph | `/unipi:ralph` | start, stop, resume, status |
79
83
  | Memory | `/unipi:memory-` | process, search, consolidate, forget |
80
- | Compactor | `/unipi:` | lossless-compact, compact-stats, compact-settings, compact-preset, session-recall, content-index, content-search |
84
+ | Compactor | `/unipi:` | lossless-compact, session-recall, compact-stats, compact-settings, compact-preset, compact-help |
85
+ | CocoIndex | `/unipi:cocoindex-` | init, update, status, search, settings |
81
86
  | Notify | `/unipi:notify-` | settings, test, set-tg, set-ntfy |
82
87
  | MCP | `/unipi:mcp-` | add, settings, sync, status |
83
88
  | Web | `/unipi:web-` | settings, cache-clear |
84
- | BTW | `/btw` | question, new, tangent, inject, summarize |
85
- | Utility | `/unipi:` | env, doctor, status, cleanup, name-badge |
89
+ | BTW | `/unipi:btw` | question, btw-new, btw-tangent, btw-inject, btw-summarize |
90
+ | Utility | `/unipi:` | env, doctor, status, cleanup, badge-name |
86
91
  | Milestone | `/unipi:milestone-` | onboard, update |
87
92
  | Kanboard | `/unipi:kanboard` | toggle, doctor |
88
93
  | Footer | `/unipi:footer` | toggle, settings |
@@ -121,6 +126,7 @@ unipi/
121
126
  │ ├── ralph/ # Iterative loops
122
127
  │ ├── memory/ # SQLite + vector search
123
128
  │ ├── compactor/ # Context engine
129
+ │ ├── cocoindex/ # Project indexing and semantic search
124
130
  │ ├── subagents/ # Parallel execution
125
131
  │ ├── web-api/ # Web research
126
132
  │ ├── mcp/ # MCP server integration
@@ -134,6 +140,7 @@ unipi/
134
140
  │ ├── utility/ # Diagnostics, diff rendering
135
141
  │ ├── updater/ # Auto-update, browsers
136
142
  │ ├── input-shortcuts/ # Keyboard shortcuts
143
+ │ ├── autocomplete/ # Enhanced command autocomplete
137
144
  │ └── unipi/ # Umbrella package
138
145
  ├── .unipi/ # Runtime data (specs, plans, worktrees)
139
146
  └── CHANGELOG.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,6 +73,10 @@ When the user chooses a `new_session` option:
73
73
 
74
74
  The tool result is rendered as `queued compact → ...` or `queued direct → ...`. If automatic delivery fails, ask-user falls back to editor prefill and warns you to press Enter.
75
75
 
76
+ ### History Expansion
77
+
78
+ Completed `ask_user` calls stay readable in chat history. The collapsed result shows the selected answer; press Ctrl+O on the tool result to expand the original question, context, and options that were presented.
79
+
76
80
  ### Keyboard Controls
77
81
 
78
82
  | Mode | Keys |
@@ -621,7 +621,7 @@ export function createRenderCall() {
621
621
  * Create a renderResult function for the ask_user tool.
622
622
  */
623
623
  export function createRenderResult() {
624
- return (result: AgentToolResult<unknown>, _options: unknown, theme: Theme, _context: unknown) => {
624
+ return (result: AgentToolResult<unknown>, options: unknown, theme: Theme, _context: unknown) => {
625
625
  const details = result.details as Record<string, unknown> | undefined;
626
626
  if (!details) {
627
627
  const content = result.content as unknown as Array<Record<string, unknown>> | undefined;
@@ -634,103 +634,110 @@ export function createRenderResult() {
634
634
  return new Text(theme.fg("warning", "No response"), 0, 0);
635
635
  }
636
636
 
637
- switch (response.kind) {
638
- case "cancelled":
639
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
640
- case "timed_out":
641
- return new Text(theme.fg("warning", "Timed out"), 0, 0);
642
- case "freeform":
643
- return new Text(
644
- theme.fg("success", "✓ ") +
637
+ const renderOptionSummary = (): string[] => {
638
+ const rawOptions = (details as { options?: unknown }).options;
639
+ if (!Array.isArray(rawOptions) || rawOptions.length === 0) return [];
640
+ return rawOptions.map((opt, index) => {
641
+ if (typeof opt === "string") {
642
+ return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", opt);
643
+ }
644
+ const record = opt as Record<string, unknown>;
645
+ const label = String(record.label ?? record.value ?? `Option ${index + 1}`);
646
+ const value = typeof record.value === "string" && record.value !== label
647
+ ? theme.fg("dim", ` (${record.value})`)
648
+ : "";
649
+ const action = typeof record.action === "string" && record.action !== "select"
650
+ ? theme.fg("dim", ` [${record.action}]`)
651
+ : "";
652
+ const description = typeof record.description === "string" && record.description.trim()
653
+ ? `\n${theme.fg("muted", ` ${record.description}`)}`
654
+ : "";
655
+ return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", label) + value + action + description;
656
+ });
657
+ };
658
+
659
+ const answerText = (() => {
660
+ switch (response.kind) {
661
+ case "cancelled":
662
+ return theme.fg("warning", "Cancelled");
663
+ case "timed_out":
664
+ return theme.fg("warning", "Timed out");
665
+ case "freeform":
666
+ return theme.fg("success", "✓ ") +
645
667
  theme.fg("muted", "(wrote) ") +
646
- theme.fg("accent", response.text || ""),
647
- 0,
648
- 0,
649
- );
650
- case "selection": {
651
- const selections = response.selections || [];
652
- const display =
653
- selections.length === 1
668
+ theme.fg("accent", response.text || "");
669
+ case "selection": {
670
+ const selections = response.selections || [];
671
+ const display = selections.length === 1 ? selections[0] : selections.join(", ");
672
+ return theme.fg("success", "✓ ") + theme.fg("accent", display);
673
+ }
674
+ case "combined": {
675
+ const selections = response.selections || [];
676
+ const selDisplay = selections.length === 1
654
677
  ? selections[0]
655
678
  : selections.join(", ");
656
- return new Text(
657
- theme.fg("success", "✓ ") + theme.fg("accent", display),
658
- 0,
659
- 0,
660
- );
661
- }
662
- case "combined": {
663
- const selections = response.selections || [];
664
- const selDisplay = selections.length === 1
665
- ? selections[0]
666
- : selections.join(", ");
667
- return new Text(
668
- theme.fg("success", "✓ ") +
679
+ return theme.fg("success", "✓ ") +
669
680
  theme.fg("accent", selDisplay) +
670
681
  theme.fg("muted", " and wrote ") +
671
- theme.fg("accent", response.text || ""),
672
- 0,
673
- 0,
674
- );
675
- }
676
- case "end_turn":
677
- return new Text(
678
- theme.fg("success", "✓ ") + theme.fg("muted", "end turn"),
679
- 0,
680
- 0,
681
- );
682
- case "new_session": {
683
- const prefill = response.prefill || "";
684
- if (response.launchStatus === "editor_prefill") {
685
- const label = response.launchedWith === "compact"
686
- ? "⚠ compact editor prefill → "
687
- : "⚠ direct editor prefill → ";
688
- return new Text(
689
- theme.fg("warning", label) + theme.fg("accent", prefill),
690
- 0,
691
- 0,
692
- );
693
- }
694
- if (response.launchStatus === "failed") {
695
- const label = response.launchedWith === "compact"
696
- ? "handoff failed (compact) → "
697
- : "handoff failed (direct) → ";
698
- return new Text(
699
- theme.fg("error", label) + theme.fg("accent", prefill),
700
- 0,
701
- 0,
702
- );
682
+ theme.fg("accent", response.text || "");
703
683
  }
704
- if (response.launchedWith === "compact") {
705
- return new Text(
706
- theme.fg("success", "✓ queued compact → ") +
707
- theme.fg("accent", prefill),
708
- 0,
709
- 0,
710
- );
711
- }
712
- if (response.launchedWith === "direct") {
713
- return new Text(
714
- theme.fg("success", "✓ queued direct → ") +
715
- theme.fg("accent", prefill),
716
- 0,
717
- 0,
718
- );
719
- }
720
- return new Text(
721
- theme.fg("success", "✓ ") +
684
+ case "end_turn":
685
+ return theme.fg("success", "✓ ") + theme.fg("muted", "end turn");
686
+ case "new_session": {
687
+ const prefill = response.prefill || "";
688
+ if (response.launchStatus === "editor_prefill") {
689
+ const label = response.launchedWith === "compact"
690
+ ? "⚠ compact editor prefill → "
691
+ : "⚠ direct editor prefill → ";
692
+ return theme.fg("warning", label) + theme.fg("accent", prefill);
693
+ }
694
+ if (response.launchStatus === "failed") {
695
+ const label = response.launchedWith === "compact"
696
+ ? "handoff failed (compact) → "
697
+ : "handoff failed (direct) → ";
698
+ return theme.fg("error", label) + theme.fg("accent", prefill);
699
+ }
700
+ if (response.launchedWith === "compact") {
701
+ return theme.fg("success", "✓ queued compact → ") + theme.fg("accent", prefill);
702
+ }
703
+ if (response.launchedWith === "direct") {
704
+ return theme.fg("success", "✓ queued direct → ") + theme.fg("accent", prefill);
705
+ }
706
+ return theme.fg("success", "✓ ") +
722
707
  theme.fg("muted", "new session") +
723
- (prefill ? theme.fg("accent", `: ${prefill}`) : ""),
724
- 0,
725
- 0,
726
- );
708
+ (prefill ? theme.fg("accent", `: ${prefill}`) : "");
709
+ }
710
+ default:
711
+ return theme.fg("text", JSON.stringify(response));
727
712
  }
728
- default:
729
- return new Text(
730
- theme.fg("text", JSON.stringify(response)),
731
- 0,
732
- 0,
733
- );
713
+ })();
714
+
715
+ const expanded = typeof options === "object" && options !== null && "expanded" in options
716
+ ? Boolean((options as { expanded?: boolean }).expanded)
717
+ : false;
718
+ if (!expanded) {
719
+ const question = typeof (details as { question?: unknown }).question === "string"
720
+ ? (details as { question: string }).question
721
+ : "";
722
+ const expandHint = question
723
+ ? theme.fg("dim", " · Ctrl+O question/options")
724
+ : "";
725
+ return new Text(answerText + expandHint, 0, 0);
726
+ }
727
+
728
+ const lines = [answerText];
729
+ const question = (details as { question?: unknown }).question;
730
+ const context = (details as { context?: unknown }).context;
731
+ if (typeof question === "string" && question.trim()) {
732
+ lines.push(theme.fg("muted", "Question: ") + theme.fg("text", question));
734
733
  }
734
+ if (typeof context === "string" && context.trim()) {
735
+ lines.push(theme.fg("muted", "Context: ") + theme.fg("text", context));
736
+ }
737
+ const optionLines = renderOptionSummary();
738
+ if (optionLines.length > 0) {
739
+ lines.push(theme.fg("muted", "Options:"), ...optionLines);
740
+ }
741
+ return new Text(lines.join("\n"), 0, 0);
735
742
  };
736
743
  }
@@ -252,6 +252,13 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
252
252
  action: (opt.action as NormalizedOption["action"]) ?? "select",
253
253
  prefill: opt.prefill,
254
254
  }));
255
+ const detailsBase = {
256
+ question,
257
+ context,
258
+ options: normalizedOptions,
259
+ allowMultiple,
260
+ allowFreeform,
261
+ };
255
262
 
256
263
  // Emit ASK_USER_PROMPT event if notifyOnAsk is enabled
257
264
  if (settings.notifyOnAsk) {
@@ -286,8 +293,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
286
293
  },
287
294
  ],
288
295
  details: {
289
- question,
290
- options: normalizedOptions.map((o) => o.label),
296
+ ...detailsBase,
291
297
  response: {
292
298
  kind: "cancelled",
293
299
  } as AskUserResponse,
@@ -348,8 +354,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
348
354
  return {
349
355
  content: [{ type: "text", text: "User cancelled the session launch" }],
350
356
  details: {
351
- question,
352
- options: normalizedOptions.map((o) => o.label),
357
+ ...detailsBase,
353
358
  response: {
354
359
  kind: "cancelled",
355
360
  comment: "Session launcher cancelled",
@@ -374,8 +379,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
374
379
  return {
375
380
  content: [{ type: "text", text: "Session launch cancelled: no prefill message was provided." }],
376
381
  details: {
377
- question,
378
- options: normalizedOptions.map((o) => o.label),
382
+ ...detailsBase,
379
383
  response: {
380
384
  kind: "cancelled",
381
385
  comment: "Session launcher had no prefill to queue",
@@ -404,8 +408,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
404
408
  return {
405
409
  content: [{ type: "text", text: contentText }],
406
410
  details: {
407
- question,
408
- options: normalizedOptions.map((o) => o.label),
411
+ ...detailsBase,
409
412
  response: {
410
413
  ...response,
411
414
  prefill: handoff.prefill ?? prefill,
@@ -421,8 +424,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
421
424
  return {
422
425
  content: [{ type: "text", text: contentText }],
423
426
  details: {
424
- question,
425
- options: normalizedOptions.map((o) => o.label),
427
+ ...detailsBase,
426
428
  response,
427
429
  },
428
430
  };
@@ -0,0 +1,36 @@
1
+ # @pi-unipi/command-enchantment
2
+
3
+ Enhanced autocomplete for Unipi commands. It wraps Pi's base autocomplete provider and makes `/unipi:*` suggestions easier to scan with package grouping, stable sorting, short descriptions, and package colors.
4
+
5
+ ## Commands
6
+
7
+ Command Enchantment has no user commands. It improves the editor autocomplete experience automatically when the package is installed.
8
+
9
+ ## What It Does
10
+
11
+ - Groups `/unipi:*` commands by package so workflow, memory, web, footer, and other commands are visually distinct.
12
+ - Sorts matches in predictable tiers: exact Unipi matches first, then other Unipi matches, then system commands.
13
+ - Preserves dynamic argument completions from command providers, including workflow document and worktree suggestions.
14
+ - Ships an audit test that checks registered Unipi commands are represented in the autocomplete registry and have descriptions.
15
+
16
+ ## Development Checks
17
+
18
+ Run the registry audit before releases:
19
+
20
+ ```bash
21
+ npm --workspace packages/autocomplete test -- src/__tests__/command-registry.audit.test.ts
22
+ ```
23
+
24
+ Run all autocomplete tests:
25
+
26
+ ```bash
27
+ npm --workspace packages/autocomplete test
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ Autocomplete enhancement is enabled by default. The package stores its toggle in the Unipi config and can be disabled by setting `autocompleteEnhanced` to `false`.
33
+
34
+ ## License
35
+
36
+ MIT
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, globSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ function findRepoRoot(start: string): string {
6
+ let dir = start;
7
+ while (dir !== dirname(dir)) {
8
+ if (existsSync(join(dir, "packages", "autocomplete", "src", "constants.ts"))) return dir;
9
+ dir = dirname(dir);
10
+ }
11
+ return start;
12
+ }
13
+
14
+ const root = findRepoRoot(process.cwd());
15
+
16
+ function read(path: string): string {
17
+ return readFileSync(join(root, path), "utf-8");
18
+ }
19
+
20
+ function collectConstants(): Map<string, string> {
21
+ const constants = new Map<string, string>([["UNIPI_PREFIX", "unipi:"]]);
22
+
23
+ for (const path of globSync("packages/**/*.ts", { cwd: root })) {
24
+ const text = read(path);
25
+ for (const obj of text.matchAll(/(?:export\s+)?const\s+(\w+)\s*=\s*\{([\s\S]*?)\}\s*as\s+const/g)) {
26
+ const [, name, body] = obj;
27
+ for (const item of body.matchAll(/(\w+):\s*"([^"]+)"/g)) {
28
+ constants.set(`${name}.${item[1]}`, item[2]);
29
+ }
30
+ }
31
+ }
32
+
33
+ return constants;
34
+ }
35
+
36
+ function evaluateCommandExpression(expr: string, constants: Map<string, string>): string | null {
37
+ const trimmed = expr.trim();
38
+
39
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
40
+ const quote = trimmed[0];
41
+ const end = trimmed.indexOf(quote, 1);
42
+ return end > 0 ? trimmed.slice(1, end) : null;
43
+ }
44
+
45
+ if (trimmed.startsWith("`")) {
46
+ const end = trimmed.lastIndexOf("`");
47
+ if (end <= 0) return null;
48
+ return trimmed.slice(1, end).replace(/\$\{([^}]+)\}/g, (_match, key: string) => {
49
+ const resolved = constants.get(key.trim());
50
+ return resolved ?? `\${${key}}`;
51
+ });
52
+ }
53
+
54
+ return constants.get(trimmed) ?? null;
55
+ }
56
+
57
+ function registeredCommands(): { commands: Set<string>; nonUnipi: string[]; unresolved: string[] } {
58
+ const constants = collectConstants();
59
+ const commands = new Set<string>();
60
+ const nonUnipi: string[] = [];
61
+ const unresolved: string[] = [];
62
+
63
+ for (const path of globSync("packages/**/*.ts", { cwd: root }).sort()) {
64
+ const text = read(path);
65
+ if (!text.includes("registerCommand")) continue;
66
+
67
+ for (const match of text.matchAll(/\.registerCommand\(\s*([^,\n]+)/g)) {
68
+ const expr = match[1].trim();
69
+
70
+ // Workflow registers a loop over WORKFLOW_COMMANDS via local `fullCommand`.
71
+ if (path === "packages/workflow/commands.ts" && expr === "fullCommand") {
72
+ for (const [key, value] of constants) {
73
+ if (key.startsWith("WORKFLOW_COMMANDS.")) commands.add(`unipi:${value}`);
74
+ }
75
+ continue;
76
+ }
77
+
78
+ const command = evaluateCommandExpression(expr, constants);
79
+ if (!command || command.includes("${")) {
80
+ unresolved.push(`${path}: ${expr}`);
81
+ continue;
82
+ }
83
+
84
+ if (command.startsWith("unipi:")) {
85
+ commands.add(command);
86
+ } else {
87
+ nonUnipi.push(`${command} (${path})`);
88
+ }
89
+ }
90
+ }
91
+
92
+ return { commands, nonUnipi, unresolved };
93
+ }
94
+
95
+ function autocompleteRegistry(): { registry: Set<string>; descriptions: Set<string>; registryPackages: Set<string>; labels: Set<string> } {
96
+ const text = read("packages/autocomplete/src/constants.ts");
97
+ const registryBody = text.match(/export const COMMAND_REGISTRY[^=]*= \{([\s\S]*?)\n\};/)?.[1] ?? "";
98
+ const descriptionsBody = text.match(/export const COMMAND_DESCRIPTIONS[^=]*= \{([\s\S]*?)\n\};/)?.[1] ?? "";
99
+ const labelsBody = text.match(/export const PACKAGE_LABELS[^=]*= \{([\s\S]*?)\n\};/)?.[1] ?? "";
100
+
101
+ const registry = new Set([...registryBody.matchAll(/"(unipi:[^"]+)"\s*:/g)].map((m) => m[1]));
102
+ const descriptions = new Set([...descriptionsBody.matchAll(/"(unipi:[^"]+)"\s*:/g)].map((m) => m[1]));
103
+ const registryPackages = new Set([...registryBody.matchAll(/"unipi:[^"]+"\s*:\s*"([^"]+)"/g)].map((m) => m[1]));
104
+ const labels = new Set([...labelsBody.matchAll(/^\s*"?([a-z][a-z0-9-]*)"?:\s*"/gm)].map((m) => m[1]));
105
+
106
+ return { registry, descriptions, registryPackages, labels };
107
+ }
108
+
109
+ describe("autocomplete command registry audit", () => {
110
+ it("mirrors every registered /unipi:* command and has no non-unipi package commands", () => {
111
+ const registered = registeredCommands();
112
+ const autocomplete = autocompleteRegistry();
113
+
114
+ expect(registered.unresolved, "unresolved registerCommand expressions").toEqual([]);
115
+ expect(registered.nonUnipi, "package commands must use the unipi: prefix").toEqual([]);
116
+ expect([...registered.commands].sort()).toEqual([...autocomplete.registry].sort());
117
+ expect([...autocomplete.registry].sort()).toEqual([...autocomplete.descriptions].sort());
118
+ expect([...autocomplete.registryPackages].filter((pkg) => !autocomplete.labels.has(pkg)).sort()).toEqual([]);
119
+ });
120
+ });
@@ -20,6 +20,7 @@ export const PACKAGE_ORDER: string[] = [
20
20
  "workflow",
21
21
  "ralph",
22
22
  "memory",
23
+ "btw",
23
24
  "milestone",
24
25
  "mcp",
25
26
  "utility",
@@ -41,6 +42,7 @@ export const PACKAGE_COLORS: Record<string, string> = {
41
42
  workflow: `${ESC}[91m`, // Bright Red
42
43
  ralph: `${ESC}[33m`, // Yellow/Orange
43
44
  memory: `${ESC}[93m`, // Bright Yellow
45
+ btw: `${ESC}[95m`, // Bright Magenta
44
46
  milestone: `${ESC}[32m`, // Green
45
47
  mcp: `${ESC}[32m`, // Green
46
48
  utility: `${ESC}[36m`, // Cyan
@@ -57,7 +59,7 @@ export const PACKAGE_COLORS: Record<string, string> = {
57
59
  };
58
60
 
59
61
  // ─── Command Registry ────────────────────────────────────────────────
60
- /** Mapping of full command name → package name (80 verified commands) */
62
+ /** Mapping of full command name → package name (88 verified commands) */
61
63
  export const COMMAND_REGISTRY: Record<string, string> = {
62
64
  // workflow (20 commands)
63
65
  "unipi:brainstorm": "workflow",
@@ -95,6 +97,14 @@ export const COMMAND_REGISTRY: Record<string, string> = {
95
97
  "unipi:global-memory-list": "memory",
96
98
  "unipi:memory-settings": "memory",
97
99
 
100
+ // btw (6 commands)
101
+ "unipi:btw": "btw",
102
+ "unipi:btw-tangent": "btw",
103
+ "unipi:btw-new": "btw",
104
+ "unipi:btw-clear": "btw",
105
+ "unipi:btw-inject": "btw",
106
+ "unipi:btw-summarize": "btw",
107
+
98
108
  // mcp (5 commands)
99
109
  "unipi:mcp-status": "mcp",
100
110
  "unipi:mcp-sync": "mcp",
@@ -126,14 +136,16 @@ export const COMMAND_REGISTRY: Record<string, string> = {
126
136
  "unipi:web-settings": "web-api",
127
137
  "unipi:web-cache-clear": "web-api",
128
138
 
129
- // compact (7 commands)
139
+ // compact (9 commands)
130
140
  "unipi:lossless-compact": "compact",
131
141
  "unipi:compact": "compact",
142
+ "unipi:session-recall": "compact",
132
143
  "unipi:compact-recall": "compact",
133
144
  "unipi:compact-stats": "compact",
134
145
  "unipi:compact-doctor": "compact",
135
146
  "unipi:compact-settings": "compact",
136
147
  "unipi:compact-preset": "compact",
148
+ "unipi:compact-help": "compact",
137
149
 
138
150
  // cocoindex (5 commands)
139
151
  "unipi:cocoindex-update": "cocoindex",
@@ -208,6 +220,13 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
208
220
  "unipi:global-memory-list": "List all project memories",
209
221
  "unipi:memory-settings": "Configure memory settings",
210
222
 
223
+ "unipi:btw": "Run a parallel side conversation",
224
+ "unipi:btw-tangent": "Start a contextless BTW tangent thread",
225
+ "unipi:btw-new": "Start a fresh BTW thread with session context",
226
+ "unipi:btw-clear": "Dismiss and clear the BTW thread",
227
+ "unipi:btw-inject": "Inject the BTW thread into the main agent",
228
+ "unipi:btw-summarize": "Summarize and inject the BTW thread",
229
+
211
230
  "unipi:mcp-status": "Show MCP server status",
212
231
  "unipi:mcp-sync": "Sync MCP server connections",
213
232
  "unipi:mcp-add": "Add a new MCP server",
@@ -238,11 +257,13 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
238
257
 
239
258
  "unipi:lossless-compact": "Immediate zero-LLM compaction",
240
259
  "unipi:compact": "(DEPRECATED) Use /unipi:lossless-compact instead",
241
- "unipi:compact-recall": "Recall a compacted session",
260
+ "unipi:session-recall": "Search session history, including compacted-away messages",
261
+ "unipi:compact-recall": "(DEPRECATED) Use /unipi:session-recall instead",
242
262
  "unipi:compact-stats": "Show compaction statistics",
243
263
  "unipi:compact-doctor": "Diagnose compaction issues",
244
264
  "unipi:compact-settings": "Configure compaction settings",
245
265
  "unipi:compact-preset": "Manage compaction presets",
266
+ "unipi:compact-help": "Show compactor command help",
246
267
  "unipi:cocoindex-update": "Run CocoIndex update to index project",
247
268
  "unipi:cocoindex-status": "Show CocoIndex indexing status",
248
269
  "unipi:cocoindex-init": "Initialize CocoIndex pipeline",
@@ -276,6 +297,7 @@ export const PACKAGE_LABELS: Record<string, string> = {
276
297
  workflow: "workflow",
277
298
  ralph: "ralph",
278
299
  memory: "memory",
300
+ btw: "btw",
279
301
  milestone: "milestone",
280
302
  mcp: "mcp",
281
303
  utility: "utility",
@@ -288,4 +310,5 @@ export const PACKAGE_LABELS: Record<string, string> = {
288
310
  footer: "footer",
289
311
  updater: "updater",
290
312
  "input-shortcuts": "input-shortcuts",
313
+ cocoindex: "cocoindex",
291
314
  };
@@ -1,6 +1,6 @@
1
1
  # @pi-unipi/btw
2
2
 
3
- Side conversations that run in parallel. Ask a question using `/btw` while the main agent keeps working — the answer streams into a modal overlay without interrupting the current task.
3
+ Side conversations that run in parallel. Ask a question using `/unipi:btw` while the main agent keeps working — the answer streams into a modal overlay without interrupting the current task.
4
4
 
5
5
  BTW opens a real Pi sub-session with coding-tool access. Use it to clarify something, explore an idea, or think through next steps without derailing the main turn. When you're ready, inject the thread back or summarize it.
6
6
 
@@ -10,12 +10,12 @@ Based on [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
10
10
 
11
11
  | Command | Description |
12
12
  |---------|-------------|
13
- | `/btw [--save] <question>` | Ask a question in a side thread |
14
- | `/btw:new [question]` | Start a fresh thread with main-session context |
15
- | `/btw:tangent [--save] <question>` | Contextless tangent thread |
16
- | `/btw:clear` | Dismiss modal and clear thread |
17
- | `/btw:inject [instructions]` | Send full thread to main agent |
18
- | `/btw:summarize [instructions]` | Summarize thread and inject into main agent |
13
+ | `/unipi:btw [--save] <question>` | Ask a question in a side thread |
14
+ | `/unipi:btw-new [question]` | Start a fresh thread with main-session context |
15
+ | `/unipi:btw-tangent [--save] <question>` | Contextless tangent thread |
16
+ | `/unipi:btw-clear` | Dismiss modal and clear thread |
17
+ | `/unipi:btw-inject [instructions]` | Send full thread to main agent |
18
+ | `/unipi:btw-summarize [instructions]` | Summarize thread and inject into main agent |
19
19
 
20
20
  ### Keyboard Shortcuts
21
21
 
@@ -29,13 +29,13 @@ Based on [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
29
29
  ### Examples
30
30
 
31
31
  ```text
32
- /btw what file defines this route?
33
- /btw how would you refactor this parser?
34
- /btw --save summarize the last error in one sentence
35
- /btw:new let's start a fresh thread about auth
36
- /btw:tangent brainstorm from first principles without using the current chat context
37
- /btw:inject implement the plan we just discussed
38
- /btw:summarize turn that side thread into a short handoff
32
+ /unipi:btw what file defines this route?
33
+ /unipi:btw how would you refactor this parser?
34
+ /unipi:btw --save summarize the last error in one sentence
35
+ /unipi:btw-new let's start a fresh thread about auth
36
+ /unipi:btw-tangent brainstorm from first principles without using the current chat context
37
+ /unipi:btw-inject implement the plan we just discussed
38
+ /unipi:btw-summarize turn that side thread into a short handoff
39
39
  ```
40
40
 
41
41
  ## Special Triggers
@@ -46,14 +46,14 @@ The BTW overlay opens top-centered so the main session remains visible underneat
46
46
 
47
47
  ## How It Works
48
48
 
49
- 1. `/btw` creates or reuses a BTW sub-session
49
+ 1. `/unipi:btw` creates or reuses a BTW sub-session
50
50
  2. Your question runs in a real Pi session with tool access
51
51
  3. The answer streams into the BTW modal overlay
52
52
  4. The thread continues until you clear it or inject it back
53
53
 
54
- `/btw:inject` sends the full thread to the main agent as a user message. If Pi is busy, it queues as a follow-up. `/btw:summarize` does the same but summarizes first.
54
+ `/unipi:btw-inject` sends the full thread to the main agent as a user message. If Pi is busy, it queues as a follow-up. `/unipi:btw-summarize` does the same but summarizes first.
55
55
 
56
- `/btw:tangent` starts a separate thread that doesn't inherit the main session's conversation context. Use it for unrelated exploration.
56
+ `/unipi:btw-tangent` starts a separate thread that doesn't inherit the main session's conversation context. Use it for unrelated exploration.
57
57
 
58
58
  The `--save` flag saves that single exchange as a visible session note.
59
59
 
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * @pi-unipi/btw — Side Conversation Extension
3
3
  *
4
- * A /btw side conversation channel that opens a real pi sub-session
4
+ * A /unipi:btw side conversation channel that opens a real pi sub-session
5
5
  * with coding-tool access, running immediately even while the main
6
6
  * agent is still busy.
7
7
  *
8
8
  * Based on pi-btw by Dan Bachelder, adapted for the Unipi suite.
9
9
  *
10
10
  * Commands:
11
- * /btw [--save] <question> - Side conversation (contextual)
12
- * /btw:tangent [--save] <q> - Contextless tangent thread
13
- * /btw:new [question] - Fresh thread with main-session context
14
- * /btw:clear - Dismiss and clear thread
15
- * /btw:inject [instructions] - Send full thread to main agent
16
- * /btw:summarize [instr] - Summarize and inject into main agent
11
+ * /unipi:btw [--save] <question> - Side conversation (contextual)
12
+ * /unipi:btw-tangent [--save] <q> - Contextless tangent thread
13
+ * /unipi:btw-new [question] - Fresh thread with main-session context
14
+ * /unipi:btw-clear - Dismiss and clear thread
15
+ * /unipi:btw-inject [instructions] - Send full thread to main agent
16
+ * /unipi:btw-summarize [instructions] - Summarize and inject into main agent
17
17
  */
18
18
 
19
19
  import {
@@ -44,6 +44,7 @@ import {
44
44
  type OverlayHandle,
45
45
  type TUI,
46
46
  } from "@mariozechner/pi-tui";
47
+ import { BTW_COMMANDS, UNIPI_PREFIX } from "@pi-unipi/core";
47
48
 
48
49
  // ─── Constants ──────────────────────────────────────────────────────────────
49
50
 
@@ -1615,15 +1616,23 @@ export default function (pi: ExtensionAPI) {
1615
1616
 
1616
1617
  function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
1617
1618
  const trimmed = value.trim();
1618
- const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/);
1619
- if (!match) {
1620
- return null;
1619
+ const legacy = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/);
1620
+ if (legacy) {
1621
+ return {
1622
+ name: legacy[1],
1623
+ args: legacy[2]?.trim() ?? "",
1624
+ };
1621
1625
  }
1622
1626
 
1623
- return {
1624
- name: match[1],
1625
- args: match[2]?.trim() ?? "",
1626
- };
1627
+ const unipi = trimmed.match(/^\/unipi:btw-(new|tangent|clear|inject|summarize)(?:\s+(.*))?$/);
1628
+ if (unipi) {
1629
+ return {
1630
+ name: `btw:${unipi[1]}`,
1631
+ args: unipi[2]?.trim() ?? "",
1632
+ };
1633
+ }
1634
+
1635
+ return null;
1627
1636
  }
1628
1637
 
1629
1638
  async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise<void> {
@@ -1928,42 +1937,42 @@ export default function (pi: ExtensionAPI) {
1928
1937
  });
1929
1938
  }
1930
1939
 
1931
- pi.registerCommand("btw", {
1940
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.BTW}`, {
1932
1941
  description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
1933
1942
  handler: async (args, ctx) => {
1934
1943
  await dispatchBtwCommand("btw", args, ctx);
1935
1944
  },
1936
1945
  });
1937
1946
 
1938
- pi.registerCommand("btw:tangent", {
1947
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.TANGENT}`, {
1939
1948
  description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
1940
1949
  handler: async (args, ctx) => {
1941
1950
  await dispatchBtwCommand("btw:tangent", args, ctx);
1942
1951
  },
1943
1952
  });
1944
1953
 
1945
- pi.registerCommand("btw:new", {
1954
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.NEW}`, {
1946
1955
  description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
1947
1956
  handler: async (args, ctx) => {
1948
1957
  await dispatchBtwCommand("btw:new", args, ctx);
1949
1958
  },
1950
1959
  });
1951
1960
 
1952
- pi.registerCommand("btw:clear", {
1961
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.CLEAR}`, {
1953
1962
  description: "Dismiss the BTW modal/widget and clear the current thread.",
1954
1963
  handler: async (args, ctx) => {
1955
1964
  await dispatchBtwCommand("btw:clear", args, ctx);
1956
1965
  },
1957
1966
  });
1958
1967
 
1959
- pi.registerCommand("btw:inject", {
1968
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.INJECT}`, {
1960
1969
  description: "Inject the full BTW thread into the main agent as a user message.",
1961
1970
  handler: async (args, ctx) => {
1962
1971
  await dispatchBtwCommand("btw:inject", args, ctx);
1963
1972
  },
1964
1973
  });
1965
1974
 
1966
- pi.registerCommand("btw:summarize", {
1975
+ pi.registerCommand(`${UNIPI_PREFIX}${BTW_COMMANDS.SUMMARIZE}`, {
1967
1976
  description: "Summarize the BTW thread, then inject the summary into the main agent.",
1968
1977
  handler: async (args, ctx) => {
1969
1978
  await dispatchBtwCommand("btw:summarize", args, ctx);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: btw
3
- description: Helps you use the /btw side-conversation workflow effectively. Use when you want to think in parallel, ask side questions without interrupting ongoing work, or inject a side thread back into the main agent.
3
+ description: Helps you use the /unipi:btw side-conversation workflow effectively. Use when you want to think in parallel, ask side questions without interrupting ongoing work, or inject a side thread back into the main agent.
4
4
  ---
5
5
 
6
6
  # BTW
@@ -21,14 +21,14 @@ Prefer the BTW workflow when the user wants to:
21
21
  Use these commands in your guidance to the user:
22
22
 
23
23
  ```text
24
- /btw <question>
25
- /btw --save <question>
26
- /btw:new [question]
27
- /btw:tangent <question>
28
- /btw:tangent --save <question>
29
- /btw:clear
30
- /btw:inject [instructions]
31
- /btw:summarize [instructions]
24
+ /unipi:btw <question>
25
+ /unipi:btw --save <question>
26
+ /unipi:btw-new [question]
27
+ /unipi:btw-tangent <question>
28
+ /unipi:btw-tangent --save <question>
29
+ /unipi:btw-clear
30
+ /unipi:btw-inject [instructions]
31
+ /unipi:btw-summarize [instructions]
32
32
  ```
33
33
 
34
34
  ## How to guide the user
@@ -38,7 +38,7 @@ Use these commands in your guidance to the user:
38
38
  Recommend:
39
39
 
40
40
  ```text
41
- /btw <question>
41
+ /unipi:btw <question>
42
42
  ```
43
43
 
44
44
  Use this when the user wants an immediate aside and does not need a visible saved note.
@@ -48,7 +48,7 @@ Use this when the user wants an immediate aside and does not need a visible save
48
48
  Recommend:
49
49
 
50
50
  ```text
51
- /btw --save <question>
51
+ /unipi:btw --save <question>
52
52
  ```
53
53
 
54
54
  Use this when the user wants the exchange to appear as a visible BTW note in the session transcript.
@@ -58,13 +58,13 @@ Use this when the user wants the exchange to appear as a visible BTW note in the
58
58
  Recommend:
59
59
 
60
60
  ```text
61
- /btw:new
61
+ /unipi:btw-new
62
62
  ```
63
63
 
64
64
  or
65
65
 
66
66
  ```text
67
- /btw:new <question>
67
+ /unipi:btw-new <question>
68
68
  ```
69
69
 
70
70
  Use this when the previous BTW discussion is no longer relevant, but you still want the new side thread to inherit the current main-session context.
@@ -74,13 +74,13 @@ Use this when the previous BTW discussion is no longer relevant, but you still w
74
74
  Recommend:
75
75
 
76
76
  ```text
77
- /btw:tangent <question>
77
+ /unipi:btw-tangent <question>
78
78
  ```
79
79
 
80
80
  or
81
81
 
82
82
  ```text
83
- /btw:tangent --save <question>
83
+ /unipi:btw-tangent --save <question>
84
84
  ```
85
85
 
86
86
  Use this when the user wants a side conversation that does not include the current main-session context.
@@ -90,7 +90,7 @@ Use this when the user wants a side conversation that does not include the curre
90
90
  Recommend:
91
91
 
92
92
  ```text
93
- /btw:inject <instructions>
93
+ /unipi:btw-inject <instructions>
94
94
  ```
95
95
 
96
96
  Use this when the exact discussion matters and the user wants the main agent to act on it.
@@ -100,19 +100,19 @@ Use this when the exact discussion matters and the user wants the main agent to
100
100
  Recommend:
101
101
 
102
102
  ```text
103
- /btw:summarize <instructions>
103
+ /unipi:btw-summarize <instructions>
104
104
  ```
105
105
 
106
106
  Use this when the thread is long and only the distilled outcome should go back into the main agent.
107
107
 
108
108
  ## Recommendation rules
109
109
 
110
- - Prefer `/btw` over normal chat when the user explicitly wants a side conversation.
111
- - Prefer `/btw:tangent` when the user wants that side conversation to be contextless.
112
- - Prefer `/btw:summarize` over `/btw:inject` for long exploratory threads.
113
- - Prefer `/btw:inject` when precise wording, detailed tradeoffs, or a full plan matters.
114
- - Suggest `/btw:new` before starting a totally unrelated side topic when main-session context is still useful.
115
- - Suggest `/btw:clear` when the widget/thread should be dismissed.
110
+ - Prefer `/unipi:btw` over normal chat when the user explicitly wants a side conversation.
111
+ - Prefer `/unipi:btw-tangent` when the user wants that side conversation to be contextless.
112
+ - Prefer `/unipi:btw-summarize` over `/unipi:btw-inject` for long exploratory threads.
113
+ - Prefer `/unipi:btw-inject` when precise wording, detailed tradeoffs, or a full plan matters.
114
+ - Suggest `/unipi:btw-new` before starting a totally unrelated side topic when main-session context is still useful.
115
+ - Suggest `/unipi:btw-clear` when the widget/thread should be dismissed.
116
116
 
117
117
  ## Response style
118
118
 
@@ -127,23 +127,23 @@ When helping the user use BTW:
127
127
  ### Example: brainstorm while coding continues
128
128
 
129
129
  ```text
130
- /btw what are the risks of switching this to optimistic updates?
130
+ /unipi:btw what are the risks of switching this to optimistic updates?
131
131
  ```
132
132
 
133
133
  ### Example: create a clean new thread
134
134
 
135
135
  ```text
136
- /btw:new sketch a safer migration plan
136
+ /unipi:btw-new sketch a safer migration plan
137
137
  ```
138
138
 
139
139
  ### Example: start a contextless tangent
140
140
 
141
141
  ```text
142
- /btw:tangent think through this from first principles without using the current chat context
142
+ /unipi:btw-tangent think through this from first principles without using the current chat context
143
143
  ```
144
144
 
145
145
  ### Example: send the result back
146
146
 
147
147
  ```text
148
- /btw:summarize implement the recommended migration plan
148
+ /unipi:btw-summarize implement the recommended migration plan
149
149
  ```
@@ -13,6 +13,8 @@ import { getLastCompactionStats } from "../compaction/hooks.js";
13
13
  import { vccRecall } from "../tools/vcc-recall.js";
14
14
  import { ctxStats } from "../tools/ctx-stats.js";
15
15
  import { ctxDoctor } from "../tools/ctx-doctor.js";
16
+ import { recallBlocksFromContext } from "../session/recall-blocks.js";
17
+ import { filterNoise } from "../compaction/filter-noise.js";
16
18
  import type { SessionDB } from "../session/db.js";
17
19
  import type { NormalizedBlock, RuntimeCounters } from "../types.js";
18
20
 
@@ -90,13 +92,19 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
90
92
  });
91
93
 
92
94
  // ── /unipi:session-recall (new) ─────────────────────
93
- const sessionRecallHandler = async (args: string, ctx: ExtensionCommandContext) => {
95
+ const sessionRecallHandler = async (args: string, ctx: ExtensionCommandContext, commandName = "/unipi:session-recall") => {
94
96
  const query = args.trim();
95
97
  if (!query) {
96
- ctx.ui.notify("Usage: /unipi:session-recall <query>", "warning");
98
+ const suffix = commandName === "/unipi:compact-recall" ? " (deprecated; use /unipi:session-recall <query>)" : "";
99
+ ctx.ui.notify(`Usage: ${commandName} <query>${suffix}`, "warning");
97
100
  return;
98
101
  }
99
- const blocks = deps?.getBlocks() ?? [];
102
+
103
+ // Prefer the live session branch over cached blocks. The branch includes raw
104
+ // pre-compaction messages that are omitted from the compacted LLM context.
105
+ const config = loadConfig((ctx as any).cwd ?? process.cwd());
106
+ const liveBlocks = filterNoise(recallBlocksFromContext(ctx), config.pipeline?.customNoisePatterns);
107
+ const blocks = liveBlocks.length > 0 ? liveBlocks : (deps?.getBlocks() ?? []);
100
108
  if (blocks.length === 0) {
101
109
  ctx.ui.notify("No session history available for search.", "warning");
102
110
  return;
@@ -120,7 +128,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
120
128
  description: "(DEPRECATED) Search session history — use /unipi:session-recall instead",
121
129
  handler: async (args: string, ctx: ExtensionCommandContext) => {
122
130
  deprecationLog("/unipi:compact-recall", "/unipi:session-recall");
123
- return sessionRecallHandler(args, ctx);
131
+ return sessionRecallHandler(args, ctx, "/unipi:compact-recall");
124
132
  },
125
133
  });
126
134
 
@@ -14,6 +14,7 @@ import { registerCommands } from "./commands/index.js";
14
14
  import { registerCompactorTools } from "./tools/register.js";
15
15
  import { normalizeMessages } from "./compaction/normalize.js";
16
16
  import { filterNoise } from "./compaction/filter-noise.js";
17
+ import { recallBlocksFromContext } from "./session/recall-blocks.js";
17
18
  import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters } from "./types.js";
18
19
  import type { RuntimeStats } from "./session/analytics.js";
19
20
 
@@ -242,12 +243,20 @@ export default function compactorExtension(pi: ExtensionAPI): void {
242
243
  // Non-fatal
243
244
  }
244
245
 
245
- // Re-cache normalized blocks for vcc_recall
246
+ // Re-cache normalized blocks for session_recall/vcc_recall.
247
+ // Command/event contexts do not expose ctx.messages; use the append-only
248
+ // session branch so recall can find raw messages hidden by compaction.
246
249
  try {
247
- const messages = (ctx as any).messages ?? [];
248
- if (messages.length > 0) {
249
- const normalized = normalizeMessages(messages);
250
- cachedBlocks = filterNoise(normalized, config.pipeline?.customNoisePatterns);
250
+ const sessionBlocks = recallBlocksFromContext(ctx);
251
+ if (sessionBlocks.length > 0) {
252
+ cachedBlocks = filterNoise(sessionBlocks, config.pipeline?.customNoisePatterns);
253
+ } else {
254
+ // Defensive fallback for older Pi contexts that happened to expose messages.
255
+ const messages = (ctx as any).messages ?? [];
256
+ if (messages.length > 0) {
257
+ const normalized = normalizeMessages(messages);
258
+ cachedBlocks = filterNoise(normalized, config.pipeline?.customNoisePatterns);
259
+ }
251
260
  }
252
261
  } catch {
253
262
  // Non-fatal: recall will work on empty blocks
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Build searchable recall blocks from Pi session entries.
3
+ *
4
+ * `ctx.messages` is not available to command handlers and compacted session
5
+ * context only contains summaries/kept messages. The append-only session branch
6
+ * still contains the raw pre-compaction messages, so recall should index that
7
+ * branch directly.
8
+ */
9
+
10
+ import type { SessionEntry } from "@mariozechner/pi-coding-agent";
11
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
+ import { normalizeMessages } from "../compaction/normalize.js";
13
+ import { sanitize } from "../compaction/sanitize.js";
14
+ import { textOf } from "../compaction/content.js";
15
+ import type { NormalizedBlock } from "../types.js";
16
+
17
+ function block(kind: "user" | "assistant", text: string, sourceIndex?: number): NormalizedBlock[] {
18
+ const clean = sanitize(text);
19
+ return clean ? [{ kind, text: clean, sourceIndex }] : [];
20
+ }
21
+
22
+ function normalizeAgentMessage(message: AgentMessage, sourceIndex: number): NormalizedBlock[] {
23
+ const role = (message as { role?: string }).role;
24
+
25
+ // Standard LLM message roles are handled by the existing normalizer.
26
+ if (role === "user" || role === "assistant" || role === "toolResult") {
27
+ return normalizeMessages([message as Parameters<typeof normalizeMessages>[0][number]]).map((b) => ({
28
+ ...b,
29
+ sourceIndex,
30
+ }));
31
+ }
32
+
33
+ // Pi-specific session message roles that do not participate in pi-ai Message.
34
+ if (role === "bashExecution") {
35
+ const msg = message as AgentMessage & {
36
+ command?: string;
37
+ output?: string;
38
+ exitCode?: number;
39
+ cancelled?: boolean;
40
+ };
41
+ const text = [`$ ${msg.command ?? ""}`, msg.output ?? ""].filter(Boolean).join("\n");
42
+ return text
43
+ ? [{ kind: "tool_result", name: "bash", text: sanitize(text), isError: Boolean(msg.cancelled || (msg.exitCode ?? 0) !== 0), sourceIndex }]
44
+ : [];
45
+ }
46
+
47
+ if (role === "custom") {
48
+ const msg = message as AgentMessage & { customType?: string; content?: unknown };
49
+ return block("user", [msg.customType, textOf(msg.content)].filter(Boolean).join("\n"), sourceIndex);
50
+ }
51
+
52
+ if (role === "branchSummary") {
53
+ const msg = message as AgentMessage & { summary?: string };
54
+ return block("assistant", msg.summary ?? "", sourceIndex);
55
+ }
56
+
57
+ if (role === "compactionSummary") {
58
+ const msg = message as AgentMessage & { summary?: string };
59
+ return block("assistant", msg.summary ?? "", sourceIndex);
60
+ }
61
+
62
+ return [];
63
+ }
64
+
65
+ /** Convert the active append-only session branch into searchable blocks. */
66
+ export function recallBlocksFromSessionEntries(entries: SessionEntry[]): NormalizedBlock[] {
67
+ return entries.flatMap((entry, i) => {
68
+ if (entry.type === "message") {
69
+ return normalizeAgentMessage(entry.message, i);
70
+ }
71
+
72
+ if (entry.type === "custom_message") {
73
+ return block("user", [entry.customType, textOf(entry.content)].filter(Boolean).join("\n"), i);
74
+ }
75
+
76
+ if (entry.type === "branch_summary") {
77
+ return block("assistant", entry.summary, i);
78
+ }
79
+
80
+ if (entry.type === "compaction") {
81
+ return block("assistant", entry.summary, i);
82
+ }
83
+
84
+ return [];
85
+ });
86
+ }
87
+
88
+ /** Best-effort read of searchable blocks from an extension context. */
89
+ export function recallBlocksFromContext(ctx: unknown): NormalizedBlock[] {
90
+ const sessionManager = (ctx as { sessionManager?: { getBranch?: () => SessionEntry[]; buildSessionContext?: () => { messages?: AgentMessage[] } } })?.sessionManager;
91
+
92
+ try {
93
+ const entries = sessionManager?.getBranch?.();
94
+ if (Array.isArray(entries) && entries.length > 0) {
95
+ const blocks = recallBlocksFromSessionEntries(entries);
96
+ if (blocks.length > 0) return blocks;
97
+ }
98
+ } catch {
99
+ // Fall through to compacted context fallback.
100
+ }
101
+
102
+ try {
103
+ const messages = sessionManager?.buildSessionContext?.().messages ?? [];
104
+ if (messages.length > 0) {
105
+ return messages.flatMap((message, i) => normalizeAgentMessage(message, i));
106
+ }
107
+ } catch {
108
+ // No session context available.
109
+ }
110
+
111
+ return [];
112
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { Type } from "@sinclair/typebox";
17
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
18
18
  import { compactTool } from "./compact.js";
19
19
  import { vccRecall, type RecallInput } from "./vcc-recall.js";
20
20
  import { ctxExecute, type CtxExecuteInput } from "./ctx-execute.js";
@@ -23,6 +23,9 @@ import { ctxBatchExecute, type BatchItem } from "./ctx-batch-execute.js";
23
23
  import { ctxStats, type CtxStatsResult } from "./ctx-stats.js";
24
24
  import { ctxDoctor, type DoctorResult } from "./ctx-doctor.js";
25
25
  import { contextBudgetTool } from "./context-budget.js";
26
+ import { recallBlocksFromContext } from "../session/recall-blocks.js";
27
+ import { filterNoise } from "../compaction/filter-noise.js";
28
+ import { loadConfig } from "../config/manager.js";
26
29
  import type { SessionDB } from "../session/db.js";
27
30
  import type { NormalizedBlock, RuntimeCounters } from "../types.js";
28
31
 
@@ -157,10 +160,12 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
157
160
  } as any));
158
161
 
159
162
  // 2. session_recall (new) / vcc_recall (deprecated) — search session history
160
- const recallExec = async (_toolCallId: string, params: any): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> => {
163
+ const recallExec = async (_toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: unknown, ctx?: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> => {
161
164
  const c = deps.getCounters?.();
162
165
  if (c) { c.recallQueries++; }
163
- const blocks = deps.getBlocks();
166
+ const config = loadConfig(ctx?.cwd ?? process.cwd());
167
+ const liveBlocks = ctx ? filterNoise(recallBlocksFromContext(ctx), config.pipeline?.customNoisePatterns) : [];
168
+ const blocks = liveBlocks.length > 0 ? liveBlocks : deps.getBlocks();
164
169
  const input: RecallInput = {
165
170
  query: params.query,
166
171
  mode: params.mode,
@@ -236,11 +236,13 @@ export const COMPACTOR_TOOLS = {
236
236
  export const COMPACTOR_COMMANDS = {
237
237
  LOSSLESS_COMPACT: "lossless-compact",
238
238
  COMPACT: "compact",
239
+ SESSION_RECALL: "session-recall",
239
240
  COMPACT_RECALL: "compact-recall",
240
241
  COMPACT_STATS: "compact-stats",
241
242
  COMPACT_DOCTOR: "compact-doctor",
242
243
  COMPACT_SETTINGS: "compact-settings",
243
244
  COMPACT_PRESET: "compact-preset",
245
+ COMPACT_HELP: "compact-help",
244
246
  } as const;
245
247
 
246
248
  /** Compactor directory paths */
@@ -287,6 +289,16 @@ export const NOTIFY_DIRS = {
287
289
  CONFIG: "~/.unipi/config/notify",
288
290
  } as const;
289
291
 
292
+ /** BTW command names */
293
+ export const BTW_COMMANDS = {
294
+ BTW: "btw",
295
+ TANGENT: "btw-tangent",
296
+ NEW: "btw-new",
297
+ CLEAR: "btw-clear",
298
+ INJECT: "btw-inject",
299
+ SUMMARIZE: "btw-summarize",
300
+ } as const;
301
+
290
302
  /** Milestone command names */
291
303
  export const MILESTONE_COMMANDS = {
292
304
  ONBOARD: "milestone-onboard",
@@ -97,6 +97,8 @@ For small tasks that skip the full flow:
97
97
  /unipi:worktree-merge feat/new-feature
98
98
  ```
99
99
 
100
+ Worktree command arguments autocomplete from `.unipi/worktrees`. Suggestions are cached after the first scan in a Pi session so large worktree directories do not slow down repeated `/unipi:worktree-merge` completions.
101
+
100
102
  ## Special Triggers
101
103
 
102
104
  Workflow skills detect installed packages and enhance their behavior automatically. This is the coexists system — each package adds capabilities without requiring configuration.
@@ -10,6 +10,8 @@ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
10
10
  import { join, basename } from "path";
11
11
  import { UNIPI_PREFIX, WORKFLOW_COMMANDS, getToolsForCommand, getSandboxLevel, type SandboxLevel } from "@pi-unipi/core";
12
12
 
13
+ type CompletionItem = { value: string; label: string; description: string };
14
+
13
15
  /** Options for command registration */
14
16
  export interface WorkflowCommandOptions {
15
17
  /** Check if ralph module is detected */
@@ -36,7 +38,7 @@ interface WorkflowCommand {
36
38
  /**
37
39
  * Suggest spec files from .unipi/docs/specs/ for plan command.
38
40
  */
39
- function suggestSpecFiles(prefix: string): { value: string; label: string; description: string }[] {
41
+ function suggestSpecFiles(prefix: string): CompletionItem[] {
40
42
  const specsDir = join(process.cwd(), ".unipi", "docs", "specs");
41
43
  if (!existsSync(specsDir)) return [];
42
44
 
@@ -61,7 +63,7 @@ function suggestSpecFiles(prefix: string): { value: string; label: string; descr
61
63
  /**
62
64
  * Suggest plan files from .unipi/docs/plans/ for work and review-work commands.
63
65
  */
64
- function suggestPlanFiles(prefix: string): { value: string; label: string; description: string }[] {
66
+ function suggestPlanFiles(prefix: string): CompletionItem[] {
65
67
  const plansDir = join(process.cwd(), ".unipi", "docs", "plans");
66
68
  if (!existsSync(plansDir)) return [];
67
69
 
@@ -86,7 +88,7 @@ function suggestPlanFiles(prefix: string): { value: string; label: string; descr
86
88
  /**
87
89
  * Suggest debug files from .unipi/docs/debug/ for fix command.
88
90
  */
89
- function suggestDebugFiles(prefix: string): { value: string; label: string; description: string }[] {
91
+ function suggestDebugFiles(prefix: string): CompletionItem[] {
90
92
  const debugDir = join(process.cwd(), ".unipi", "docs", "debug");
91
93
  if (!existsSync(debugDir)) return [];
92
94
 
@@ -111,7 +113,7 @@ function suggestDebugFiles(prefix: string): { value: string; label: string; desc
111
113
  /**
112
114
  * Suggest chore files from .unipi/docs/chore/ for chore-execute command.
113
115
  */
114
- function suggestChoreFiles(prefix: string): { value: string; label: string; description: string }[] {
116
+ function suggestChoreFiles(prefix: string): CompletionItem[] {
115
117
  const choreDir = join(process.cwd(), ".unipi", "docs", "chore");
116
118
  if (!existsSync(choreDir)) return [];
117
119
 
@@ -133,16 +135,29 @@ function suggestChoreFiles(prefix: string): { value: string; label: string; desc
133
135
  }
134
136
  }
135
137
 
138
+ /** Cached per-cwd worktree suggestions. Worktree autocomplete can be invoked on every
139
+ * keystroke, so the recursive scan is intentionally paid only once per session/cwd.
140
+ */
141
+ let worktreeSuggestionsCache: { cwd: string; items: CompletionItem[] } | null = null;
142
+
136
143
  /**
137
144
  * Suggest existing worktree names for merge/list commands.
138
145
  * Recursively scans for actual git worktrees (directories containing .git files).
139
146
  */
140
- function suggestWorktrees(): { value: string; label: string; description: string }[] {
141
- const worktreesDir = join(process.cwd(), ".unipi", "worktrees");
142
- if (!existsSync(worktreesDir)) return [];
147
+ function suggestWorktrees(): CompletionItem[] {
148
+ const cwd = process.cwd();
149
+ if (worktreeSuggestionsCache?.cwd === cwd) {
150
+ return worktreeSuggestionsCache.items;
151
+ }
152
+
153
+ const worktreesDir = join(cwd, ".unipi", "worktrees");
154
+ if (!existsSync(worktreesDir)) {
155
+ worktreeSuggestionsCache = { cwd, items: [] };
156
+ return worktreeSuggestionsCache.items;
157
+ }
143
158
 
144
159
  try {
145
- const results: { value: string; label: string; description: string }[] = [];
160
+ const results: CompletionItem[] = [];
146
161
 
147
162
  /**
148
163
  * Recursively find worktree directories (those containing a .git file).
@@ -176,9 +191,11 @@ function suggestWorktrees(): { value: string; label: string; description: string
176
191
  }
177
192
 
178
193
  findWorktrees(worktreesDir, "");
179
- return results;
194
+ worktreeSuggestionsCache = { cwd, items: results };
195
+ return worktreeSuggestionsCache.items;
180
196
  } catch {
181
- return [];
197
+ worktreeSuggestionsCache = { cwd, items: [] };
198
+ return worktreeSuggestionsCache.items;
182
199
  }
183
200
  }
184
201
 
@@ -334,7 +351,7 @@ export function registerWorkflowCommands(
334
351
  pi.registerCommand(fullCommand, {
335
352
  description: cmd.description,
336
353
  getArgumentCompletions: (prefix: string) => {
337
- let items: { value: string; label: string; description: string }[] | null = null;
354
+ let items: CompletionItem[] | null = null;
338
355
 
339
356
  // Plan command: suggest spec files
340
357
  if (cmd.name === WORKFLOW_COMMANDS.PLAN) {