@pi-unipi/unipi 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +21 -22
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/ask-user/settings-tui.ts +18 -34
  5. package/packages/autocomplete/package.json +1 -1
  6. package/packages/autocomplete/src/constants.ts +2 -15
  7. package/packages/autocomplete/src/provider.ts +3 -17
  8. package/packages/btw/extensions/btw.ts +18 -45
  9. package/packages/btw/package.json +2 -2
  10. package/packages/compactor/README.md +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/compactor/skills/compactor/SKILL.md +1 -1
  13. package/packages/compactor/skills/compactor-detail/SKILL.md +3 -5
  14. package/packages/compactor/skills/compactor-doctor/SKILL.md +1 -1
  15. package/packages/compactor/skills/compactor-stats/SKILL.md +1 -1
  16. package/packages/compactor/src/commands/index.ts +47 -78
  17. package/packages/compactor/src/compaction/brief.ts +161 -90
  18. package/packages/compactor/src/compaction/build-sections.ts +3 -4
  19. package/packages/compactor/src/compaction/compact-args.ts +86 -0
  20. package/packages/compactor/src/compaction/cut.ts +270 -28
  21. package/packages/compactor/src/compaction/drill-down.ts +261 -0
  22. package/packages/compactor/src/compaction/format-recall.ts +96 -0
  23. package/packages/compactor/src/compaction/format.ts +8 -3
  24. package/packages/compactor/src/compaction/hooks.ts +248 -72
  25. package/packages/compactor/src/compaction/merge.ts +34 -4
  26. package/packages/compactor/src/compaction/rank.ts +270 -0
  27. package/packages/compactor/src/compaction/recall-scope.ts +28 -0
  28. package/packages/compactor/src/compaction/search-entries.ts +333 -96
  29. package/packages/compactor/src/compaction/skill-collapse.ts +35 -0
  30. package/packages/compactor/src/compaction/summarize.ts +37 -6
  31. package/packages/compactor/src/compaction/token-estimate.ts +104 -0
  32. package/packages/compactor/src/compaction/touched-files.ts +35 -0
  33. package/packages/compactor/src/config/manager.ts +2 -27
  34. package/packages/compactor/src/config/presets.ts +0 -2
  35. package/packages/compactor/src/config/schema.ts +2 -16
  36. package/packages/compactor/src/executor/executor.ts +6 -15
  37. package/packages/compactor/src/executor/runtime.ts +2 -12
  38. package/packages/compactor/src/index.ts +4 -121
  39. package/packages/compactor/src/info-screen.ts +3 -10
  40. package/packages/compactor/src/security/evaluator.ts +0 -53
  41. package/packages/compactor/src/security/policy.ts +7 -8
  42. package/packages/compactor/src/session/db.ts +0 -6
  43. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -5
  44. package/packages/compactor/src/tools/register.ts +27 -50
  45. package/packages/compactor/src/tools/vcc-recall.ts +86 -48
  46. package/packages/compactor/src/tui/settings-overlay.ts +20 -40
  47. package/packages/compactor/src/types.ts +43 -100
  48. package/packages/core/constants.ts +1 -28
  49. package/packages/core/events.ts +1 -83
  50. package/packages/core/index.ts +1 -0
  51. package/packages/core/model-cache.ts +18 -12
  52. package/packages/core/package.json +3 -1
  53. package/packages/core/tui-overlay.ts +89 -0
  54. package/packages/core/tui-width.ts +18 -0
  55. package/packages/core/utils.ts +43 -0
  56. package/packages/footer/package.json +2 -2
  57. package/packages/footer/src/help.ts +2 -2
  58. package/packages/footer/src/index.ts +9 -10
  59. package/packages/footer/src/presets.ts +1 -27
  60. package/packages/footer/src/registry/index.ts +0 -23
  61. package/packages/footer/src/rendering/icons.ts +0 -13
  62. package/packages/footer/src/rendering/renderer.ts +5 -40
  63. package/packages/footer/src/rendering/separators.ts +0 -10
  64. package/packages/footer/src/rendering/theme.ts +1 -15
  65. package/packages/footer/src/segments/compactor.ts +7 -14
  66. package/packages/footer/src/segments/core.ts +15 -54
  67. package/packages/footer/src/segments/kanboard.ts +5 -5
  68. package/packages/footer/src/segments/mcp.ts +5 -5
  69. package/packages/footer/src/segments/memory.ts +4 -4
  70. package/packages/footer/src/segments/notify.ts +2 -2
  71. package/packages/footer/src/segments/ralph.ts +3 -3
  72. package/packages/footer/src/segments/status-ext.ts +1 -1
  73. package/packages/footer/src/segments/workflow.ts +3 -3
  74. package/packages/footer/src/tps-tracker.ts +0 -8
  75. package/packages/footer/src/tui/settings-tui.ts +14 -31
  76. package/packages/footer/src/types.ts +0 -15
  77. package/packages/image/package.json +2 -2
  78. package/packages/image/src/register-providers.ts +0 -11
  79. package/packages/image/src/tools.ts +0 -3
  80. package/packages/image/src/tui/model-selector.ts +36 -71
  81. package/packages/info-screen/config.ts +0 -16
  82. package/packages/info-screen/core-groups.ts +0 -10
  83. package/packages/info-screen/index.ts +22 -13
  84. package/packages/info-screen/package.json +2 -2
  85. package/packages/info-screen/registry.ts +1 -72
  86. package/packages/info-screen/tui/info-overlay.ts +85 -100
  87. package/packages/info-screen/usage-parser.ts +12 -47
  88. package/packages/input-shortcuts/package.json +2 -2
  89. package/packages/input-shortcuts/src/index.ts +3 -3
  90. package/packages/input-shortcuts/src/registers.ts +0 -8
  91. package/packages/input-shortcuts/src/settings-overlay.ts +4 -13
  92. package/packages/input-shortcuts/src/undo-redo.ts +0 -12
  93. package/packages/kanboard/package.json +2 -2
  94. package/packages/mcp/package.json +2 -2
  95. package/packages/mcp/src/bridge/client.ts +0 -15
  96. package/packages/mcp/src/config/manager.ts +0 -38
  97. package/packages/mcp/src/config/schema.ts +0 -27
  98. package/packages/mcp/src/config/sync.ts +0 -29
  99. package/packages/mcp/src/index.ts +1 -1
  100. package/packages/mcp/src/tui/add-overlay.ts +6 -6
  101. package/packages/mcp/src/types.ts +0 -6
  102. package/packages/memory/embedding.ts +0 -31
  103. package/packages/memory/mempalace.ts +0 -16
  104. package/packages/memory/package.json +4 -8
  105. package/packages/memory/storage.ts +56 -753
  106. package/packages/milestone/hooks.ts +1 -6
  107. package/packages/milestone/milestone.ts +2 -41
  108. package/packages/milestone/package.json +2 -2
  109. package/packages/notify/README.md +1 -0
  110. package/packages/notify/commands.ts +61 -3
  111. package/packages/notify/events.ts +0 -10
  112. package/packages/notify/package.json +2 -2
  113. package/packages/notify/settings.ts +0 -19
  114. package/packages/notify/tui/gotify-setup.ts +108 -143
  115. package/packages/notify/tui/ntfy-setup.ts +137 -172
  116. package/packages/notify/tui/recap-model-selector.ts +107 -101
  117. package/packages/notify/tui/settings-overlay.ts +94 -117
  118. package/packages/notify/tui/telegram-setup.ts +48 -79
  119. package/packages/notify/types.ts +0 -2
  120. package/packages/ralph/index.ts +11 -35
  121. package/packages/ralph/package.json +3 -3
  122. package/packages/ralph/ralph-loop.ts +3 -44
  123. package/packages/subagents/package.json +2 -1
  124. package/packages/subagents/src/agent-manager.ts +0 -51
  125. package/packages/subagents/src/agent-runner.ts +6 -80
  126. package/packages/subagents/src/conversation-viewer.ts +1 -19
  127. package/packages/subagents/src/core-compat.ts +8 -25
  128. package/packages/subagents/src/custom-agents.ts +0 -1
  129. package/packages/subagents/src/index.ts +3 -48
  130. package/packages/subagents/src/types.ts +0 -11
  131. package/packages/subagents/src/widget.ts +5 -5
  132. package/packages/unipi/bundled.js +4468 -7769
  133. package/packages/unipi/index.ts +6 -3
  134. package/packages/updater/package.json +2 -2
  135. package/packages/updater/src/changelog.ts +1 -16
  136. package/packages/updater/src/index.ts +0 -5
  137. package/packages/updater/src/installer.ts +4 -26
  138. package/packages/updater/src/markdown.ts +6 -152
  139. package/packages/updater/src/settings.ts +0 -15
  140. package/packages/updater/src/tui/changelog-overlay.ts +42 -234
  141. package/packages/updater/src/tui/list-detail-overlay.ts +260 -0
  142. package/packages/updater/src/tui/readme-overlay.ts +53 -210
  143. package/packages/updater/src/tui/settings-overlay.ts +13 -26
  144. package/packages/updater/src/version.ts +2 -31
  145. package/packages/utility/package.json +2 -4
  146. package/packages/utility/src/commands.ts +4 -5
  147. package/packages/utility/src/herdr-sync.ts +136 -0
  148. package/packages/utility/src/index.ts +11 -68
  149. package/packages/utility/src/lifecycle/cleanup.ts +2 -16
  150. package/packages/utility/src/{diff/settings.ts → settings.ts} +42 -80
  151. package/packages/utility/src/tools/env.ts +0 -0
  152. package/packages/utility/src/tui/name-badge-state.ts +18 -2
  153. package/packages/utility/src/tui/name-badge.ts +5 -4
  154. package/packages/utility/src/tui/util-settings-tui.ts +41 -224
  155. package/packages/web-api/package.json +2 -6
  156. package/packages/web-api/src/engine/dependencies.ts +13 -134
  157. package/packages/web-api/src/engine/extract.ts +9 -99
  158. package/packages/web-api/src/engine/format.ts +0 -75
  159. package/packages/web-api/src/engine/types.ts +0 -39
  160. package/packages/web-api/src/index.ts +1 -2
  161. package/packages/web-api/src/providers/base.ts +0 -10
  162. package/packages/web-api/src/providers/duckduckgo.ts +0 -1
  163. package/packages/web-api/src/providers/firecrawl.ts +0 -9
  164. package/packages/web-api/src/providers/jina-reader.ts +0 -9
  165. package/packages/web-api/src/providers/jina-search.ts +0 -9
  166. package/packages/web-api/src/providers/perplexity.ts +0 -9
  167. package/packages/web-api/src/providers/registry.ts +0 -80
  168. package/packages/web-api/src/providers/serpapi.ts +0 -9
  169. package/packages/web-api/src/providers/tavily.ts +0 -9
  170. package/packages/web-api/src/providers/wigolo-client.ts +0 -30
  171. package/packages/web-api/src/providers/wigolo.ts +0 -1
  172. package/packages/web-api/src/settings.ts +17 -77
  173. package/packages/web-api/src/tools.ts +21 -24
  174. package/packages/web-api/src/tui/settings-dialog.ts +0 -1
  175. package/packages/workflow/commands.ts +38 -122
  176. package/packages/workflow/index.ts +1 -5
  177. package/packages/workflow/package.json +2 -2
  178. package/packages/cocoindex/README.md +0 -94
  179. package/packages/cocoindex/bridge.ts +0 -842
  180. package/packages/cocoindex/commands.ts +0 -175
  181. package/packages/cocoindex/index.ts +0 -48
  182. package/packages/cocoindex/installer.ts +0 -397
  183. package/packages/cocoindex/package.json +0 -49
  184. package/packages/cocoindex/skills/cocoindex/SKILL.md +0 -88
  185. package/packages/cocoindex/tools.ts +0 -157
  186. package/packages/compactor/src/display/diff-renderer.ts +0 -281
  187. package/packages/compactor/src/display/line-width-safety.ts +0 -28
  188. package/packages/compactor/src/display/render-utils.ts +0 -52
  189. package/packages/compactor/src/display/thinking-label.ts +0 -18
  190. package/packages/compactor/src/display/tool-overrides.ts +0 -136
  191. package/packages/compactor/src/tools/compact.ts +0 -20
  192. package/packages/subagents/src/file-lock.ts +0 -102
  193. package/packages/utility/src/cache/ttl-cache.ts +0 -311
  194. package/packages/utility/src/diff/highlighter.ts +0 -352
  195. package/packages/utility/src/diff/parser.ts +0 -191
  196. package/packages/utility/src/diff/renderer.ts +0 -395
  197. package/packages/utility/src/diff/theme.ts +0 -316
  198. package/packages/utility/src/diff/wrapper.ts +0 -339
  199. package/packages/utility/src/display/capabilities.ts +0 -214
  200. package/packages/utility/src/display/width.ts +0 -226
  201. package/packages/utility/src/tools/batch.ts +0 -229
  202. package/packages/utility/src/tui/badge-settings-tui.ts +0 -388
  203. package/packages/utility/src/tui/badge-settings.ts +0 -67
  204. package/packages/utility/src/tui/settings-inspector.ts +0 -303
  205. package/packages/web-api/src/providers/llm-summarize.ts +0 -71
@@ -8,13 +8,14 @@
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { loadConfig, saveConfig } from "../config/manager.js";
10
10
  import { applyPreset, parsePreset } from "../config/presets.js";
11
- import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
12
- import { getLastCompactionStats } from "../compaction/hooks.js";
11
+ import { COMPACTOR_INSTRUCTION, formatTokens } from "@pi-unipi/core";
12
+ import { getLastCompactionStats, formatCompactionStats } 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
16
  import { recallBlocksFromContext } from "../session/recall-blocks.js";
17
17
  import { filterNoise } from "../compaction/filter-noise.js";
18
+ import { parseRecallScope } from "../compaction/recall-scope.js";
18
19
  import type { SessionDB } from "../session/db.js";
19
20
  import type { NormalizedBlock, RuntimeCounters } from "../types.js";
20
21
 
@@ -25,77 +26,55 @@ export interface CommandDeps {
25
26
  getCounters?: () => RuntimeCounters;
26
27
  }
27
28
 
28
- function deprecationLog(_oldName: string, _newName: string): void {
29
- // Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
30
- }
31
-
32
- const formatTokens = (n: number): string => {
33
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
34
- return String(n);
35
- };
36
-
37
29
  export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
38
30
  // ── /unipi:lossless-compact ──────────────────────────
31
+ const losslessCompactHandler = async (_args: string, ctx: ExtensionCommandContext) => {
32
+ ctx.compact({
33
+ customInstructions: COMPACTOR_INSTRUCTION,
34
+ onComplete: () => {
35
+ const stats = getLastCompactionStats();
36
+ if (stats) {
37
+ ctx.ui.notify(formatCompactionStats(stats), "info");
38
+ } else {
39
+ ctx.ui.notify("Compaction completed.", "info");
40
+ }
41
+ },
42
+ onError: (err: Error) => {
43
+ if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
44
+ ctx.ui.notify("Nothing to compact.", "info");
45
+ } else {
46
+ ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
47
+ }
48
+ },
49
+ });
50
+ };
39
51
  pi.registerCommand("unipi:lossless-compact", {
40
52
  description: "Immediate zero-LLM compaction — structured summary with full recall",
41
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
42
- ctx.compact({
43
- customInstructions: COMPACTOR_INSTRUCTION,
44
- onComplete: () => {
45
- const stats = getLastCompactionStats();
46
- if (stats) {
47
- ctx.ui.notify(
48
- `Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
49
- "info",
50
- );
51
- } else {
52
- ctx.ui.notify("Compaction completed.", "info");
53
- }
54
- },
55
- onError: (err: Error) => {
56
- if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
57
- ctx.ui.notify("Nothing to compact.", "info");
58
- } else {
59
- ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
60
- }
61
- },
62
- });
63
- },
53
+ handler: losslessCompactHandler,
64
54
  });
65
- // Deprecated alias — old name
66
55
  pi.registerCommand("unipi:compact", {
67
- description: "(DEPRECATED) Use /unipi:lossless-compact instead",
68
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
69
- deprecationLog("/unipi:compact", "/unipi:lossless-compact");
70
- ctx.compact({
71
- customInstructions: COMPACTOR_INSTRUCTION,
72
- onComplete: () => {
73
- const stats = getLastCompactionStats();
74
- if (stats) {
75
- ctx.ui.notify(
76
- `Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
77
- "info",
78
- );
79
- } else {
80
- ctx.ui.notify("Compaction completed.", "info");
81
- }
82
- },
83
- onError: (err: Error) => {
84
- if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
85
- ctx.ui.notify("Nothing to compact.", "info");
86
- } else {
87
- ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
88
- }
89
- },
90
- });
91
- },
56
+ description: "Alias for /unipi:lossless-compact",
57
+ handler: losslessCompactHandler,
92
58
  });
93
59
 
94
60
  // ── /unipi:session-recall (new) ─────────────────────
61
+ // pi-vcc parity: results are sent as a visible custom message AND fed to the
62
+ // agent as context (triggerTurn), so recall results drive the next turn.
95
63
  const sessionRecallHandler = async (args: string, ctx: ExtensionCommandContext, commandName = "/unipi:session-recall") => {
96
- const query = args.trim();
64
+ const raw = args.trim();
65
+ const suffix = commandName === "/unipi:compact-recall" ? " (deprecated; use /unipi:session-recall <query>)" : "";
66
+ if (!raw) {
67
+ ctx.ui.notify(`Usage: ${commandName} <query>${suffix}`, "warning");
68
+ return;
69
+ }
70
+
71
+ // Parse scope:all and page:N inline (pi-vcc parity)
72
+ const parsed = parseRecallScope(raw);
73
+ const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
74
+ const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
75
+ const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
76
+
97
77
  if (!query) {
98
- const suffix = commandName === "/unipi:compact-recall" ? " (deprecated; use /unipi:session-recall <query>)" : "";
99
78
  ctx.ui.notify(`Usage: ${commandName} <query>${suffix}`, "warning");
100
79
  return;
101
80
  }
@@ -109,25 +88,20 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
109
88
  ctx.ui.notify("No session history available for search.", "warning");
110
89
  return;
111
90
  }
112
- const result = vccRecall(blocks, { query, limit: 10 });
113
- if (result.hits.length === 0) {
114
- ctx.ui.notify(`No results for "${query}".`, "info");
115
- return;
116
- }
117
- const lines = result.hits.map(
118
- (h, i) => `[${i + 1}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text.slice(0, 200)}`,
91
+ const result = vccRecall(blocks, { query, scope: parsed.scope, page });
92
+ pi.sendMessage(
93
+ { customType: "compactor-recall", content: result.text, display: true },
94
+ { triggerTurn: true },
119
95
  );
120
- ctx.ui.notify(`Found ${result.total} results:\n${lines.join("\n\n")}`, "info");
121
96
  };
122
97
  pi.registerCommand("unipi:session-recall", {
123
- description: "Search session history (BM25 or regex)",
98
+ description: "Recall earlier parts of this session. Plain keywords work best; add scope:all to reach edited or retried turns.",
124
99
  handler: sessionRecallHandler,
125
100
  });
126
101
  // Deprecated alias
127
102
  pi.registerCommand("unipi:compact-recall", {
128
103
  description: "(DEPRECATED) Search session history — use /unipi:session-recall instead",
129
104
  handler: async (args: string, ctx: ExtensionCommandContext) => {
130
- deprecationLog("/unipi:compact-recall", "/unipi:session-recall");
131
105
  return sessionRecallHandler(args, ctx, "/unipi:compact-recall");
132
106
  },
133
107
  });
@@ -233,12 +207,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
233
207
  " /unipi:compact-preset <name> — apply preset\n" +
234
208
  "\n" +
235
209
  "Percentage trigger:\n" +
236
- " Disabled by default. Enable in /unipi:compact-settings to compact at a context % before Pi's reserve-token limit.\n" +
237
- "\n" +
238
- "Content indexing has moved to @pi-unipi/cocoindex:\n" +
239
- " /unipi:cocoindex-init — initialize pipeline\n" +
240
- " /unipi:cocoindex-update — index project files\n" +
241
- " cocoindex_search — search indexed content",
210
+ " Disabled by default. Enable in /unipi:compact-settings to compact at a context % before Pi's reserve-token limit.",
242
211
  "info",
243
212
  );
244
213
  },
@@ -3,11 +3,16 @@
3
3
  */
4
4
 
5
5
  import type { NormalizedBlock } from "../types.js";
6
- import { clip, firstLine } from "./content.js";
6
+ import { clip } from "./content.js";
7
7
  import { extractPath } from "./extract/files.js";
8
+ import { collapseSkillText } from "./skill-collapse.js";
8
9
 
9
10
  const TRUNCATE_USER = 256;
10
- const TRUNCATE_ASSISTANT = 200;
11
+ const SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS = 120;
12
+ const SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS = 120;
13
+ const ASSISTANT_HEAD_WORDS = 80;
14
+ const ASSISTANT_TAIL_WORDS = 120;
15
+ const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
11
16
 
12
17
  const SELF_TALK_PREFIX_RE =
13
18
  /^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i;
@@ -33,8 +38,15 @@ const STOP_WORDS = new Set([
33
38
  "if", "then", "than", "when", "where", "how", "just", "also",
34
39
  ]);
35
40
 
41
+ const normalizeForTokenBudget = (text: string): string =>
42
+ text
43
+ .replace(/\r\n?/g, "\n")
44
+ .replace(/[^\S\n]+/g, " ")
45
+ .replace(/\n{3,}/g, "\n\n")
46
+ .trim();
47
+
36
48
  const truncateTokens = (text: string, limit: number): string => {
37
- const flat = text.replace(/\s+/g, " ").trim();
49
+ const flat = normalizeForTokenBudget(text);
38
50
  let count = 0;
39
51
  let lastEnd = 0;
40
52
  for (const seg of segmenter.segment(flat)) {
@@ -51,17 +63,138 @@ const truncateTokens = (text: string, limit: number): string => {
51
63
  return flat;
52
64
  };
53
65
 
54
- const BASH_CAP = 120;
66
+ /** Word spans excluding stop words — positions into the normalized flat text */
67
+ const significantWordSpans = (flat: string): { start: number; end: number }[] => {
68
+ const words: { start: number; end: number }[] = [];
69
+ for (const seg of segmenter.segment(flat)) {
70
+ if (!isWord(seg)) continue;
71
+ if (STOP_WORDS.has(seg.segment.toLowerCase())) continue;
72
+ words.push({ start: seg.index, end: seg.index + seg.segment.length });
73
+ }
74
+ return words;
75
+ };
76
+
77
+ /** Keep the first headLimit and last tailLimit significant words; elide the middle */
78
+ const truncateTokensHeadTail = (text: string, headLimit: number, tailLimit: number): string => {
79
+ const flat = normalizeForTokenBudget(text);
80
+ if (headLimit <= 0 || tailLimit <= 0) return flat;
81
+ const words = significantWordSpans(flat);
82
+ if (words.length <= headLimit + tailLimit) return flat;
83
+ const head = flat.slice(0, words[headLimit - 1].end).trimEnd();
84
+ const tail = flat.slice(words[words.length - tailLimit].start).trimStart();
85
+ return `${head}\n...(middle truncated)...\n${tail}`;
86
+ };
87
+
88
+ const nextRenderableBlock = (blocks: NormalizedBlock[], index: number): NormalizedBlock | undefined => {
89
+ for (let i = index + 1; i < blocks.length; i++) {
90
+ if (blocks[i].kind !== "tool_result") return blocks[i];
91
+ }
92
+ return undefined;
93
+ };
94
+
95
+ const isSegmentClosingAssistant = (blocks: NormalizedBlock[], index: number): boolean => {
96
+ if (blocks[index]?.kind !== "assistant") return false;
97
+ const next = nextRenderableBlock(blocks, index);
98
+ return !next || next.kind === "user";
99
+ };
100
+
101
+ // ── bash command compression ──
102
+
103
+ const BASH_CAP = 240;
55
104
  const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
105
+ // Preamble/boilerplate lines that carry no durable fact on their own. Dropped
106
+ // when a script has more informative lines, so `set -euo pipefail\ngit commit`
107
+ // renders the commit rather than the shell option.
108
+ const TRIVIAL_LINE_RE = /^(?:set\s+[-+]|cd\s+\S+$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#)/;
109
+ // A real heredoc opener: `<<` at a command boundary — not preceded by a word
110
+ // char or `)`, so shift ops (`8 << 20`, `Rd<<8`) are not misread — with an
111
+ // identifier terminator starting [A-Za-z_], so numeric `<< 10` is rejected too.
112
+ export const HEREDOC_OPEN_RE = /(?<![\w)])<<-?\s*["']?([A-Za-z_]\w*)["']?/;
113
+ // File-writer heredocs (`cat > f <<EOF`, tee, dd) already name their target, so
114
+ // the opener alone is informative → body-only. EVERY other heredoc has a
115
+ // content-free opener (interpreters python3/node, remote shells `ssh host <<CMD`,
116
+ // sqlite3, ...) → we surface a one-line body preview.
117
+ const FILEWRITER_HEREDOC_RE = /(?:^|[|&;]\s*)(?:cat|tee|dd)\b/;
118
+ const HEREDOC_BODY_CAP = 80;
119
+ // Body lines that are pure boilerplate and make a poor preview.
120
+ const BODY_NOISE_RE = /^(?:import\s|from\s+\S+\s+import|require\(|const\s+\w+\s*=\s*require|#|\/\/|"""|'''|"use strict"|use\s+strict|<\?php)/;
56
121
 
57
- const compressBash = (raw: string): string => {
58
- let cmd = raw.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? raw;
59
- cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, "");
122
+ /**
123
+ * If `lines[i]` opens a heredoc whose terminator actually appears on a later
124
+ * line, return that terminator's line index; otherwise -1. Callers use -1 to
125
+ * leave following lines intact instead of treating a stray `<<` (a shift op, a
126
+ * quoted string, or a truncated body) as a heredoc and skipping real commands.
127
+ */
128
+ export const heredocCloseIndex = (lines: string[], i: number): number => {
129
+ const hd = lines[i].match(HEREDOC_OPEN_RE);
130
+ if (!hd) return -1;
131
+ const term = hd[1];
132
+ for (let j = i + 1; j < lines.length; j++) {
133
+ if (lines[j].trim() === term) return j;
134
+ }
135
+ return -1;
136
+ };
137
+
138
+ const stripCdPrefix = (line: string): string => line.replace(/^cd\s+\S+\s*&&\s*/, "").trim();
139
+ const stripPipeTail = (line: string): string => {
140
+ let c = line;
60
141
  for (let i = 0; i < 3; i++) {
61
- const stripped = cmd.replace(PIPE_TAIL_RE, "");
62
- if (stripped === cmd) break;
63
- cmd = stripped;
142
+ const stripped = c.replace(PIPE_TAIL_RE, "");
143
+ if (stripped === c) break;
144
+ c = stripped;
64
145
  }
146
+ return c.trim();
147
+ };
148
+
149
+ /**
150
+ * Semantic compression of a (possibly multi-line) bash command.
151
+ * 1. Drop heredoc BODIES (keep the opener line, e.g. `cat > f <<EOF` or
152
+ * `python3 - <<PY`): the body is prose/script content that bloats the brief
153
+ * without adding countable facts, while the opener still records what ran.
154
+ * 2. Drop trivial preamble lines (set -euo pipefail, cd-only, export, source,
155
+ * comments) unless they are the ONLY line, so real work below them surfaces.
156
+ * 3. Strip `cd <path> &&` prefixes and pipe-tail formatting per line.
157
+ * 4. Join the remaining meaningful lines with `; ` and cap length.
158
+ */
159
+ const compressBash = (raw: string): string => {
160
+ // Pass 1: keep heredoc opener lines, skip their bodies + terminators.
161
+ const rawLines = raw.split("\n");
162
+ const withoutHeredocBodies: string[] = [];
163
+ for (let i = 0; i < rawLines.length; i++) {
164
+ const line = rawLines[i];
165
+ withoutHeredocBodies.push(line);
166
+ // Only treat this as a heredoc when its terminator appears downstream; a
167
+ // stray `<<` (string/expression or truncated body) leaves later lines intact.
168
+ const close = heredocCloseIndex(rawLines, i);
169
+ if (close === -1) continue;
170
+ // File-writer heredocs (`cat > f <<EOF`) name their target → keep opener only.
171
+ // Every other heredoc has a content-free opener → grab the first meaningful
172
+ // body line as a preview (`python3 - <<PY`, `ssh host <<CMD`, `sqlite3 <<SQL`).
173
+ const wantPreview = !FILEWRITER_HEREDOC_RE.test(line);
174
+ let preview = "";
175
+ for (let j = i + 1; wantPreview && !preview && j < close; j++) {
176
+ const t = rawLines[j].trim();
177
+ if (t && !BODY_NOISE_RE.test(t)) preview = t;
178
+ }
179
+ if (preview) {
180
+ const clipped = preview.length > HEREDOC_BODY_CAP ? preview.slice(0, HEREDOC_BODY_CAP - 1) + "\u2026" : preview;
181
+ withoutHeredocBodies[withoutHeredocBodies.length - 1] = `${line.trim()} ${clipped}`;
182
+ }
183
+ i = close; // for-loop's i++ then skips past the terminator line
184
+ }
185
+
186
+ const lines = withoutHeredocBodies.map((l) => l.trim()).filter(Boolean);
187
+ if (lines.length === 0) return raw.trim();
188
+
189
+ const meaningful = lines
190
+ .filter((l) => !TRIVIAL_LINE_RE.test(l))
191
+ .map((l) => stripPipeTail(stripCdPrefix(l)))
192
+ .filter(Boolean);
193
+ // If everything was trivial (e.g. a bare `set -e` or `ls`), fall back to the
194
+ // first line so we never emit an empty marker.
195
+ const chosen = meaningful.length ? meaningful : [stripPipeTail(stripCdPrefix(lines[0]))].filter(Boolean);
196
+
197
+ const cmd = chosen.join("; ");
65
198
  if (cmd.length > BASH_CAP) {
66
199
  return cmd.slice(0, BASH_CAP - 3) + "...";
67
200
  }
@@ -97,15 +230,6 @@ export interface BriefLine {
97
230
  lines: string[];
98
231
  }
99
232
 
100
- export interface TranscriptEntry {
101
- role: "user" | "assistant" | "tool_error";
102
- text?: string;
103
- tool?: string;
104
- cmd?: string;
105
- ref?: string;
106
- count?: number;
107
- }
108
-
109
233
  export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
110
234
  const sections: BriefLine[] = [];
111
235
  let lastHeader = "";
@@ -119,14 +243,23 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
119
243
  lastHeader = header;
120
244
  };
121
245
 
122
- for (const b of blocks) {
246
+ const pushText = (header: string, text: string, ref = "") => {
247
+ const lines = text.split("\n");
248
+ if (ref && lines.length > 0) {
249
+ lines[lines.length - 1] = `${lines[lines.length - 1]}${ref}`;
250
+ }
251
+ for (const line of lines) push(header, line);
252
+ };
253
+
254
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
255
+ const b = blocks[blockIndex];
123
256
  switch (b.kind) {
124
257
  case "user": {
125
258
  if (!b.text.trim()) break;
126
- const text = truncateTokens(b.text, TRUNCATE_USER);
259
+ const text = truncateTokens(collapseSkillText(b.text), TRUNCATE_USER);
127
260
  if (text) {
128
261
  const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
129
- push("[user]", text + ref);
262
+ pushText("[user]", text, ref);
130
263
  }
131
264
  lastHeader = "[user]";
132
265
  break;
@@ -138,10 +271,12 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
138
271
  if (stripped === raw) break;
139
272
  raw = stripped;
140
273
  }
141
- const text = truncateTokens(raw, TRUNCATE_ASSISTANT);
274
+ const text = isSegmentClosingAssistant(blocks, blockIndex)
275
+ ? truncateTokensHeadTail(raw, SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS, SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS)
276
+ : truncateTokensHeadTail(raw, ASSISTANT_HEAD_WORDS, ASSISTANT_TAIL_WORDS);
142
277
  if (text) {
143
278
  const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
144
- push("[assistant]", text + ref);
279
+ pushText("[assistant]", text, ref);
145
280
  }
146
281
  break;
147
282
  }
@@ -154,7 +289,7 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
154
289
  }
155
290
  case "tool_result": {
156
291
  if (b.isError) {
157
- const body = firstLine(b.text, 150);
292
+ const body = b.text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
158
293
  if (!body || body === "(no output)") break;
159
294
  const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
160
295
  const header = `[tool_error] ${b.name}${ref}`;
@@ -265,70 +400,6 @@ export const stringifyBrief = (sections: BriefLine[]): string => {
265
400
  return out.join("\n");
266
401
  };
267
402
 
268
- const parseToolLine = (line: string): { tool: string; cmd?: string; ref?: string; count?: number } | null => {
269
- const m = line.match(/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/);
270
- if (!m) return null;
271
- return {
272
- tool: m[1],
273
- cmd: m[2] || undefined,
274
- ref: m[3] || undefined,
275
- count: m[4] ? parseInt(m[4]) : undefined,
276
- };
277
- };
278
-
279
- const extractRef = (text: string): { clean: string; ref?: string } => {
280
- const m = text.match(/\s*\(#(\d+)\)$/);
281
- if (!m) return { clean: text };
282
- return { clean: text.slice(0, m.index).trimEnd(), ref: `#${m[1]}` };
283
- };
284
-
285
- export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] => {
286
- const entries: TranscriptEntry[] = [];
287
-
288
- for (const sec of sections) {
289
- if (sec.header === "[user]") {
290
- for (const line of sec.lines) {
291
- const { clean, ref } = extractRef(line);
292
- entries.push({ role: "user", text: clean, ...(ref && { ref }) });
293
- }
294
- } else if (sec.header === "[assistant]") {
295
- for (const line of sec.lines) {
296
- if (line.startsWith("* ")) {
297
- const parsed = parseToolLine(line);
298
- if (parsed) {
299
- entries.push({
300
- role: "assistant",
301
- tool: parsed.tool,
302
- ...(parsed.cmd && { cmd: parsed.cmd }),
303
- ...(parsed.ref && { ref: parsed.ref }),
304
- ...(parsed.count && { count: parsed.count }),
305
- });
306
- } else {
307
- const { clean, ref } = extractRef(line.slice(2));
308
- entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
309
- }
310
- } else {
311
- const { clean, ref } = extractRef(line);
312
- entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
313
- }
314
- }
315
- } else if (sec.header.startsWith("[tool_error]")) {
316
- const headerMatch = sec.header.match(/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/);
317
- const tool = headerMatch?.[1] ?? "unknown";
318
- const ref = headerMatch?.[2] ? `#${headerMatch[2]}` : undefined;
319
- for (const line of sec.lines) {
320
- entries.push({
321
- role: "tool_error",
322
- tool,
323
- text: line,
324
- ...(ref && { ref }),
325
- });
326
- }
327
- }
328
- }
329
-
330
- return entries;
331
- };
332
-
403
+ /** Render a set of blocks to a brief transcript string (used by ranked selection cost calc) */
333
404
  export const compileBrief = (blocks: NormalizedBlock[]): string =>
334
405
  stringifyBrief(buildBriefSections(blocks));
@@ -8,7 +8,7 @@ import { extractGoals } from "./extract/goals.js";
8
8
  import { extractFiles } from "./extract/files.js";
9
9
  import { extractPreferences, dedupPreferencesAgainstGoals } from "./extract/preferences.js";
10
10
  import { extractCommits, formatCommits } from "./extract/commits.js";
11
- import { buildBriefSections, sectionsToTranscript, stringifyBrief } from "./brief.js";
11
+ import { buildBriefSections, stringifyBrief } from "./brief.js";
12
12
  import type { SectionData } from "../types.js";
13
13
  export type { SectionData } from "../types.js";
14
14
 
@@ -57,9 +57,9 @@ const formatFileActivity = (blocks: NormalizedBlock[]): string[] => {
57
57
  return lines;
58
58
  };
59
59
 
60
- export const buildSections = (input: { blocks: NormalizedBlock[] }): SectionData => {
60
+ export const buildSections = (input: { blocks: NormalizedBlock[]; briefBlocks?: NormalizedBlock[] }): SectionData => {
61
61
  const { blocks } = input;
62
- const briefSections = buildBriefSections(blocks);
62
+ const briefSections = buildBriefSections(input.briefBlocks ?? blocks);
63
63
  const sessionGoal = extractGoals(blocks);
64
64
  const userPreferences = dedupPreferencesAgainstGoals(
65
65
  extractPreferences(blocks),
@@ -72,6 +72,5 @@ export const buildSections = (input: { blocks: NormalizedBlock[] }): SectionData
72
72
  commits: formatCommits(extractCommits(blocks)),
73
73
  userPreferences,
74
74
  briefTranscript: stringifyBrief(briefSections),
75
- transcriptEntries: sectionsToTranscript(briefSections),
76
75
  };
77
76
  };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Parse keep:N and follow-up prompt from compaction custom instructions
3
+ * (ported from pi-vcc compact-args.ts; marker constant stays ours: COMPACTOR_INSTRUCTION)
4
+ */
5
+
6
+ import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
7
+
8
+ const KEEP_TOKEN_RE = /^keep:(\d+)$/;
9
+
10
+ export interface ParsedCompactionArgs {
11
+ followUpPrompt: string;
12
+ keepUserTurns: number | null;
13
+ keepUserTurnsExplicit: boolean;
14
+ }
15
+
16
+ const parseKeepUserTurns = (raw: string): number => {
17
+ const value = Number(raw);
18
+ return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
19
+ };
20
+
21
+ export const parseKeepAndPrompt = (args?: string): ParsedCompactionArgs => {
22
+ const trimmed = args?.trim() ?? "";
23
+ if (!trimmed) return { followUpPrompt: "", keepUserTurns: null, keepUserTurnsExplicit: false };
24
+
25
+ const startMatch = trimmed.match(/^keep:(\d+)(?:\s+|$)([\s\S]*)$/);
26
+ if (startMatch) {
27
+ return {
28
+ followUpPrompt: startMatch[2].trim(),
29
+ keepUserTurns: parseKeepUserTurns(startMatch[1]),
30
+ keepUserTurnsExplicit: true,
31
+ };
32
+ }
33
+
34
+ const parts = trimmed.split(/\s+/);
35
+ const endMatch = parts[parts.length - 1].match(KEEP_TOKEN_RE);
36
+ if (endMatch) {
37
+ return {
38
+ followUpPrompt: trimmed.slice(0, trimmed.length - parts[parts.length - 1].length).trim(),
39
+ keepUserTurns: parseKeepUserTurns(endMatch[1]),
40
+ keepUserTurnsExplicit: true,
41
+ };
42
+ }
43
+
44
+ return { followUpPrompt: trimmed, keepUserTurns: null, keepUserTurnsExplicit: false };
45
+ };
46
+
47
+ export interface ParsedCompactionInstructions {
48
+ isCompactor: boolean;
49
+ keepUserTurns: number;
50
+ keepUserTurnsExplicit: boolean;
51
+ followUpPrompt: string | null;
52
+ }
53
+
54
+ /**
55
+ * Parse customInstructions arriving at session_before_compact.
56
+ * - Exactly COMPACTOR_INSTRUCTION → default path (keep 1, not explicit).
57
+ * - COMPACTOR_INSTRUCTION + args → parse keep:N / prompt after the marker.
58
+ * - Anything else → not ours (parse for a trailing keep:N anyway, pi-vcc parity).
59
+ */
60
+ export const parseCompactionInstructions = (
61
+ customInstructions?: string,
62
+ ): ParsedCompactionInstructions => {
63
+ const trimmed = customInstructions?.trim();
64
+ if (trimmed === COMPACTOR_INSTRUCTION) {
65
+ return { isCompactor: true, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null };
66
+ }
67
+
68
+ const keepPrefix = `${COMPACTOR_INSTRUCTION} `;
69
+ if (trimmed?.startsWith(keepPrefix)) {
70
+ const parsed = parseKeepAndPrompt(trimmed.slice(keepPrefix.length));
71
+ return {
72
+ isCompactor: true,
73
+ keepUserTurns: parsed.keepUserTurns ?? 1,
74
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
75
+ followUpPrompt: null,
76
+ };
77
+ }
78
+
79
+ const parsed = parseKeepAndPrompt(customInstructions);
80
+ return {
81
+ isCompactor: false,
82
+ keepUserTurns: parsed.keepUserTurns ?? 1,
83
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
84
+ followUpPrompt: parsed.followUpPrompt || null,
85
+ };
86
+ };