ultimate-pi 0.3.1 → 0.4.1

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 (184) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +37 -0
  2. package/.agents/skills/harness-governor/SKILL.md +1 -1
  3. package/.agents/skills/harness-orchestration/SKILL.md +54 -0
  4. package/.agents/skills/harness-plan/SKILL.md +4 -3
  5. package/.agents/skills/harness-sentrux-setup/SKILL.md +57 -0
  6. package/.agents/skills/scrapling-web/SKILL.md +93 -0
  7. package/.pi/PACKAGING.md +1 -0
  8. package/.pi/SYSTEM.md +13 -15
  9. package/.pi/agents/harness/adversary.md +3 -0
  10. package/.pi/agents/harness/evaluator.md +3 -0
  11. package/.pi/agents/harness/executor.md +4 -1
  12. package/.pi/agents/harness/meta-optimizer.md +2 -1
  13. package/.pi/agents/harness/planner.md +22 -1
  14. package/.pi/agents/harness/sentrux-bootstrap.md +42 -0
  15. package/.pi/agents/harness/tie-breaker.md +2 -0
  16. package/.pi/extensions/harness-ask-user.ts +74 -0
  17. package/.pi/extensions/harness-subagents.ts +9 -0
  18. package/.pi/extensions/lib/ask-user/dialog.ts +260 -0
  19. package/.pi/extensions/lib/ask-user/fallback.ts +78 -0
  20. package/.pi/extensions/lib/ask-user/render.ts +66 -0
  21. package/.pi/extensions/lib/ask-user/schema.ts +69 -0
  22. package/.pi/extensions/lib/ask-user/types.ts +41 -0
  23. package/.pi/extensions/lib/ask-user/validate-core.mjs +79 -0
  24. package/.pi/extensions/lib/ask-user/validate.ts +92 -0
  25. package/.pi/extensions/lib/harness-subagents/agent-loader.ts +126 -0
  26. package/.pi/extensions/lib/harness-subagents/agent-manifest.ts +119 -0
  27. package/.pi/extensions/lib/harness-subagents/agent-parser.ts +87 -0
  28. package/.pi/extensions/lib/harness-subagents/blackboard-tool.ts +118 -0
  29. package/.pi/extensions/lib/harness-subagents/blackboard.ts +175 -0
  30. package/.pi/extensions/lib/harness-subagents/spawn-policy.ts +27 -0
  31. package/.pi/extensions/lib/harness-subagents/types-blackboard.ts +27 -0
  32. package/.pi/extensions/lib/harness-subagents/vendored/agent-manager.ts +553 -0
  33. package/.pi/extensions/lib/harness-subagents/vendored/agent-runner.ts +637 -0
  34. package/.pi/extensions/lib/harness-subagents/vendored/agent-types.ts +175 -0
  35. package/.pi/extensions/lib/harness-subagents/vendored/context.ts +59 -0
  36. package/.pi/extensions/lib/harness-subagents/vendored/cross-extension-rpc.ts +134 -0
  37. package/.pi/extensions/lib/harness-subagents/vendored/custom-agents.ts +5 -0
  38. package/.pi/extensions/lib/harness-subagents/vendored/default-agents.ts +123 -0
  39. package/.pi/extensions/lib/harness-subagents/vendored/env.ts +43 -0
  40. package/.pi/extensions/lib/harness-subagents/vendored/group-join.ts +144 -0
  41. package/.pi/extensions/lib/harness-subagents/vendored/index.ts +2447 -0
  42. package/.pi/extensions/lib/harness-subagents/vendored/invocation-config.ts +52 -0
  43. package/.pi/extensions/lib/harness-subagents/vendored/memory.ts +182 -0
  44. package/.pi/extensions/lib/harness-subagents/vendored/model-resolver.ts +92 -0
  45. package/.pi/extensions/lib/harness-subagents/vendored/output-file.ts +115 -0
  46. package/.pi/extensions/lib/harness-subagents/vendored/prompts.ts +103 -0
  47. package/.pi/extensions/lib/harness-subagents/vendored/schedule-store.ts +177 -0
  48. package/.pi/extensions/lib/harness-subagents/vendored/schedule.ts +416 -0
  49. package/.pi/extensions/lib/harness-subagents/vendored/settings.ts +210 -0
  50. package/.pi/extensions/lib/harness-subagents/vendored/skill-loader.ts +108 -0
  51. package/.pi/extensions/lib/harness-subagents/vendored/types.ts +187 -0
  52. package/.pi/extensions/lib/harness-subagents/vendored/ui/agent-widget.ts +637 -0
  53. package/.pi/extensions/lib/harness-subagents/vendored/ui/conversation-viewer.ts +324 -0
  54. package/.pi/extensions/lib/harness-subagents/vendored/ui/schedule-menu.ts +110 -0
  55. package/.pi/extensions/lib/harness-subagents/vendored/usage.ts +71 -0
  56. package/.pi/extensions/lib/harness-subagents/vendored/worktree.ts +195 -0
  57. package/.pi/extensions/lib/harness-vcc-settings.ts +50 -0
  58. package/.pi/extensions/ultimate-pi-vcc.ts +17 -0
  59. package/.pi/harness/README.md +2 -1
  60. package/.pi/harness/agents.manifest.json +80 -0
  61. package/.pi/harness/docs/adrs/0009-sentrux-rules-lifecycle.md +9 -5
  62. package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +40 -0
  63. package/.pi/harness/docs/adrs/README.md +1 -0
  64. package/.pi/harness/env.harness.template +28 -0
  65. package/.pi/harness/sentrux/architecture.manifest.json +6 -1
  66. package/.pi/prompts/harness-auto.md +2 -2
  67. package/.pi/prompts/harness-plan.md +2 -2
  68. package/.pi/prompts/harness-router-tune.md +2 -2
  69. package/.pi/prompts/harness-run.md +1 -0
  70. package/.pi/prompts/harness-setup.md +179 -340
  71. package/.pi/scripts/README.md +6 -1
  72. package/.pi/scripts/harness-agents-manifest.mjs +123 -0
  73. package/.pi/scripts/harness-cli-verify.sh +60 -11
  74. package/.pi/scripts/harness-generate-model-router.mjs +242 -0
  75. package/.pi/scripts/harness-graphify-bootstrap.sh +1 -6
  76. package/.pi/scripts/harness-resolve-up-pkg.mjs +71 -0
  77. package/.pi/scripts/harness-seed-project-contracts.mjs +33 -1
  78. package/.pi/scripts/harness-sentrux-bootstrap.mjs +146 -0
  79. package/.pi/scripts/harness-sync-env.mjs +148 -0
  80. package/.pi/scripts/harness-verify.mjs +19 -0
  81. package/.pi/scripts/harness-web-search.md +33 -0
  82. package/.pi/scripts/harness-web.py +177 -0
  83. package/.pi/scripts/harness_web/__init__.py +1 -0
  84. package/.pi/scripts/harness_web/config.py +80 -0
  85. package/.pi/scripts/harness_web/output.py +55 -0
  86. package/.pi/scripts/harness_web/scrape.py +120 -0
  87. package/.pi/scripts/harness_web/search_ddg.py +106 -0
  88. package/.pi/scripts/release.sh +338 -0
  89. package/.pi/scripts/sentrux-rules-sync.mjs +29 -7
  90. package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +8 -0
  91. package/.pi/scripts/vendor-sync-pi-vcc.sh +40 -0
  92. package/.pi/settings.example.json +1 -7
  93. package/.sentrux/rules.toml +1 -1
  94. package/AGENTS.md +1 -1
  95. package/CHANGELOG.md +14 -0
  96. package/THIRD_PARTY_NOTICES.md +8 -0
  97. package/package.json +16 -12
  98. package/vendor/pi-vcc/README.md +215 -0
  99. package/vendor/pi-vcc/UPSTREAM_PIN.md +12 -0
  100. package/vendor/pi-vcc/demo.gif +0 -0
  101. package/vendor/pi-vcc/index.ts +12 -0
  102. package/vendor/pi-vcc/package.json +26 -0
  103. package/vendor/pi-vcc/scripts/audit-sessions.ts +88 -0
  104. package/vendor/pi-vcc/scripts/benchmark-real-sessions.ts +25 -0
  105. package/vendor/pi-vcc/scripts/compare-before-after.ts +36 -0
  106. package/vendor/pi-vcc/scripts/dump-branch-output.ts +20 -0
  107. package/vendor/pi-vcc/src/commands/pi-vcc.ts +36 -0
  108. package/vendor/pi-vcc/src/commands/vcc-recall.ts +65 -0
  109. package/vendor/pi-vcc/src/core/brief.ts +381 -0
  110. package/vendor/pi-vcc/src/core/build-sections.ts +79 -0
  111. package/vendor/pi-vcc/src/core/content.ts +60 -0
  112. package/vendor/pi-vcc/src/core/filter-noise.ts +42 -0
  113. package/vendor/pi-vcc/src/core/format-recall.ts +27 -0
  114. package/vendor/pi-vcc/src/core/format.ts +49 -0
  115. package/vendor/pi-vcc/src/core/lineage.ts +26 -0
  116. package/vendor/pi-vcc/src/core/load-messages.ts +41 -0
  117. package/vendor/pi-vcc/src/core/normalize.ts +66 -0
  118. package/vendor/pi-vcc/src/core/recall-scope.ts +14 -0
  119. package/vendor/pi-vcc/src/core/render-entries.ts +55 -0
  120. package/vendor/pi-vcc/src/core/report.ts +237 -0
  121. package/vendor/pi-vcc/src/core/sanitize.ts +5 -0
  122. package/vendor/pi-vcc/src/core/search-entries.ts +221 -0
  123. package/vendor/pi-vcc/src/core/settings.ts +8 -0
  124. package/vendor/pi-vcc/src/core/skill-collapse.ts +35 -0
  125. package/vendor/pi-vcc/src/core/summarize.ts +157 -0
  126. package/vendor/pi-vcc/src/core/tool-args.ts +14 -0
  127. package/vendor/pi-vcc/src/details.ts +7 -0
  128. package/vendor/pi-vcc/src/extract/commits.ts +69 -0
  129. package/vendor/pi-vcc/src/extract/files.ts +80 -0
  130. package/vendor/pi-vcc/src/extract/goals.ts +79 -0
  131. package/vendor/pi-vcc/src/extract/preferences.ts +55 -0
  132. package/vendor/pi-vcc/src/hooks/before-compact.ts +314 -0
  133. package/vendor/pi-vcc/src/sections.ts +12 -0
  134. package/vendor/pi-vcc/src/tools/recall.ts +109 -0
  135. package/vendor/pi-vcc/src/types.ts +14 -0
  136. package/vendor/pi-vcc/tests/before-compact-hook.test.ts +204 -0
  137. package/vendor/pi-vcc/tests/before-compact.test.ts +145 -0
  138. package/vendor/pi-vcc/tests/brief.test.ts +206 -0
  139. package/vendor/pi-vcc/tests/build-sections.test.ts +59 -0
  140. package/vendor/pi-vcc/tests/compile.test.ts +80 -0
  141. package/vendor/pi-vcc/tests/content.test.ts +31 -0
  142. package/vendor/pi-vcc/tests/extract-goals.test.ts +86 -0
  143. package/vendor/pi-vcc/tests/extract-preferences.test.ts +30 -0
  144. package/vendor/pi-vcc/tests/filter-noise.test.ts +61 -0
  145. package/vendor/pi-vcc/tests/fixtures.ts +61 -0
  146. package/vendor/pi-vcc/tests/format-recall.test.ts +30 -0
  147. package/vendor/pi-vcc/tests/format.test.ts +62 -0
  148. package/vendor/pi-vcc/tests/lineage.test.ts +33 -0
  149. package/vendor/pi-vcc/tests/load-messages.test.ts +51 -0
  150. package/vendor/pi-vcc/tests/normalize.test.ts +97 -0
  151. package/vendor/pi-vcc/tests/real-sessions.test.ts +38 -0
  152. package/vendor/pi-vcc/tests/recall-expand.test.ts +15 -0
  153. package/vendor/pi-vcc/tests/recall-scope.test.ts +32 -0
  154. package/vendor/pi-vcc/tests/recall-tool-scope.test.ts +67 -0
  155. package/vendor/pi-vcc/tests/render-entries.test.ts +62 -0
  156. package/vendor/pi-vcc/tests/report.test.ts +44 -0
  157. package/vendor/pi-vcc/tests/sanitize.test.ts +24 -0
  158. package/vendor/pi-vcc/tests/search-entries.test.ts +144 -0
  159. package/vendor/pi-vcc/tests/support/load-session.ts +23 -0
  160. package/vendor/pi-vcc/tests/support/real-sessions.ts +51 -0
  161. package/.agents/skills/firecrawl/SKILL.md +0 -150
  162. package/.agents/skills/firecrawl/rules/install.md +0 -82
  163. package/.agents/skills/firecrawl/rules/security.md +0 -26
  164. package/.agents/skills/firecrawl-agent/SKILL.md +0 -57
  165. package/.agents/skills/firecrawl-build-interact/SKILL.md +0 -67
  166. package/.agents/skills/firecrawl-build-onboarding/SKILL.md +0 -102
  167. package/.agents/skills/firecrawl-build-onboarding/references/auth-flow.md +0 -39
  168. package/.agents/skills/firecrawl-build-onboarding/references/project-setup.md +0 -20
  169. package/.agents/skills/firecrawl-build-onboarding/references/sdk-installation.md +0 -17
  170. package/.agents/skills/firecrawl-build-scrape/SKILL.md +0 -68
  171. package/.agents/skills/firecrawl-build-search/SKILL.md +0 -68
  172. package/.agents/skills/firecrawl-crawl/SKILL.md +0 -58
  173. package/.agents/skills/firecrawl-download/SKILL.md +0 -69
  174. package/.agents/skills/firecrawl-interact/SKILL.md +0 -83
  175. package/.agents/skills/firecrawl-map/SKILL.md +0 -50
  176. package/.agents/skills/firecrawl-parse/SKILL.md +0 -61
  177. package/.agents/skills/firecrawl-scrape/SKILL.md +0 -68
  178. package/.agents/skills/firecrawl-search/SKILL.md +0 -59
  179. package/.pi/pi-vcc-config.json +0 -4
  180. package/firecrawl/.env.template +0 -62
  181. package/firecrawl/README.md +0 -49
  182. package/firecrawl/docker-compose.yaml +0 -201
  183. package/firecrawl/searxng/searxng.env +0 -3
  184. package/firecrawl/searxng/settings.yml +0 -85
@@ -0,0 +1,221 @@
1
+ import type { Message } from "@mariozechner/pi-ai";
2
+ import type { RenderedEntry } from "./render-entries";
3
+ import { textOf } from "./content";
4
+
5
+ export interface SearchHit extends RenderedEntry {
6
+ /** Context snippet around the first matched term (only when query provided) */
7
+ snippet?: string;
8
+ /** Number of query terms matched (for ranking) */
9
+ matchCount?: number;
10
+ }
11
+
12
+ const escapeRegex = (s: string): string =>
13
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+
15
+ /** Try to compile as regex; fall back to escaped literal. */
16
+ const safeRegex = (pattern: string): RegExp => {
17
+ try {
18
+ return new RegExp(pattern, "i");
19
+ } catch {
20
+ return new RegExp(escapeRegex(pattern), "i");
21
+ }
22
+ };
23
+
24
+ /** Detect if the query looks like a single regex pattern (contains regex metacharacters). */
25
+ const looksLikeRegex = (query: string): boolean =>
26
+ /[|*+?{}()[\]\\^$.]/.test(query);
27
+
28
+ /** Build a regex for snippet highlighting — matches first available term. */
29
+ const snippetRegex = (terms: string[]): RegExp => {
30
+ const alts = terms.map((t) => {
31
+ try {
32
+ // Validate that it's a valid regex
33
+ new RegExp(t, "i");
34
+ return t;
35
+ } catch {
36
+ return escapeRegex(t);
37
+ }
38
+ });
39
+ return new RegExp(alts.join("|"), "i");
40
+ };
41
+
42
+ // ── Stopwords for natural language queries ──
43
+ const STOPWORDS = new Set([
44
+ // English
45
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
46
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
47
+ "should", "may", "might", "can", "shall", "of", "in", "to", "for",
48
+ "with", "on", "at", "from", "by", "as", "into", "through", "during",
49
+ "before", "after", "above", "below", "between", "out", "off", "over",
50
+ "under", "again", "further", "then", "once", "here", "there", "when",
51
+ "where", "why", "how", "all", "both", "each", "few", "more", "most",
52
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
53
+ "so", "than", "too", "very", "just", "about", "it", "its", "that",
54
+ "this", "what", "which", "who", "whom", "these", "those",
55
+ ]);
56
+
57
+ /** Remove stopwords, keep meaningful terms. */
58
+ const filterStopwords = (terms: string[]): string[] => {
59
+ const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
60
+ // If all terms were stopwords, return original (don't lose everything)
61
+ return meaningful.length > 0 ? meaningful : terms;
62
+ };
63
+
64
+ /** Count how many distinct terms match the haystack. */
65
+ const countMatches = (hay: string, terms: string[]): number => {
66
+ let count = 0;
67
+ for (const t of terms) {
68
+ if (safeRegex(t).test(hay)) count++;
69
+ }
70
+ return count;
71
+ };
72
+
73
+ // ── BM25-lite scoring ──
74
+ const BM25_K = 1.2;
75
+ const BM25_B = 0.75;
76
+
77
+ /** Count occurrences of a regex pattern in text. */
78
+ const termFreq = (text: string, pattern: RegExp): number => {
79
+ const matches = text.match(new RegExp(pattern.source, "gi"));
80
+ return matches ? matches.length : 0;
81
+ };
82
+
83
+ interface BM25Context {
84
+ n: number; // total docs
85
+ avgDl: number; // average doc length (words)
86
+ df: Map<string, number>; // term -> number of docs containing it
87
+ }
88
+
89
+ /** Precompute IDF and avgDl across all docs. */
90
+ const buildBM25Context = (docs: string[], terms: string[]): BM25Context => {
91
+ const n = docs.length;
92
+ const df = new Map<string, number>();
93
+ let totalLen = 0;
94
+
95
+ for (const doc of docs) {
96
+ totalLen += doc.split(/\s+/).length;
97
+ for (const t of terms) {
98
+ if (safeRegex(t).test(doc)) {
99
+ df.set(t, (df.get(t) ?? 0) + 1);
100
+ }
101
+ }
102
+ }
103
+
104
+ return { n, avgDl: totalLen / Math.max(n, 1), df };
105
+ };
106
+
107
+ /** BM25 score for a single doc against query terms. */
108
+ const bm25Score = (doc: string, terms: string[], ctx: BM25Context): number => {
109
+ const dl = doc.split(/\s+/).length;
110
+ let score = 0;
111
+
112
+ for (const t of terms) {
113
+ const tf = termFreq(doc, safeRegex(t));
114
+ if (tf === 0) continue;
115
+
116
+ const docFreq = ctx.df.get(t) ?? 0;
117
+ // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
118
+ const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
119
+ // TF saturation with length normalization
120
+ const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
121
+ score += idf * tfNorm;
122
+ }
123
+
124
+ return score;
125
+ };
126
+
127
+ /** Line-based snippet: ±contextLines around first regex match. */
128
+ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
129
+ const lines = text.split("\n");
130
+ let matchIdx = -1;
131
+ for (let i = 0; i < lines.length; i++) {
132
+ if (regex.test(lines[i])) {
133
+ matchIdx = i;
134
+ break;
135
+ }
136
+ }
137
+ if (matchIdx === -1) return undefined;
138
+
139
+ const start = Math.max(0, matchIdx - contextLines);
140
+ const end = Math.min(lines.length, matchIdx + contextLines + 1);
141
+ const slice = lines.slice(start, end);
142
+
143
+ const parts: string[] = [];
144
+ if (start > 0) parts.push(`...(${start} lines above)`);
145
+ parts.push(...slice);
146
+ if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
147
+ return parts.join("\n");
148
+ };
149
+
150
+ /** Build full searchable text for a message. */
151
+ const fullText = (msg: Message): string => {
152
+ if ((msg as any).role === "bashExecution") {
153
+ return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
154
+ }
155
+ return textOf(msg.content);
156
+ };
157
+
158
+ export const searchEntries = (
159
+ entries: RenderedEntry[],
160
+ messages: Message[],
161
+ query?: string,
162
+ ): SearchHit[] => {
163
+ if (!query?.trim()) return entries;
164
+
165
+ const rawQuery = query.trim();
166
+
167
+ // If query looks like a single regex pattern (contains metacharacters),
168
+ // treat the whole thing as one pattern — don't split into terms
169
+ if (looksLikeRegex(rawQuery)) {
170
+ const regex = safeRegex(rawQuery);
171
+ const hits: SearchHit[] = [];
172
+ for (let i = 0; i < entries.length; i++) {
173
+ const e = entries[i];
174
+ const msg = messages[i];
175
+ const text = msg ? fullText(msg) : e.summary;
176
+ const filePart = e.files?.join(" ") ?? "";
177
+ const hay = `${e.role} ${text} ${filePart}`;
178
+ if (regex.test(hay)) {
179
+ const snip = lineSnippet(text, regex);
180
+ hits.push({ ...e, snippet: snip, matchCount: 1 });
181
+ }
182
+ }
183
+ return hits;
184
+ }
185
+
186
+ // Natural language / multi-word query: BM25 scoring
187
+ const rawTerms = rawQuery.split(/\s+/);
188
+ const terms = filterStopwords(rawTerms);
189
+ const snipRe = snippetRegex(terms);
190
+
191
+ // Build all docs for BM25 context
192
+ const docs: string[] = [];
193
+ for (let i = 0; i < entries.length; i++) {
194
+ const e = entries[i];
195
+ const msg = messages[i];
196
+ const text = msg ? fullText(msg) : e.summary;
197
+ const filePart = e.files?.join(" ") ?? "";
198
+ docs.push(`${e.role} ${text} ${filePart}`);
199
+ }
200
+
201
+ const ctx = buildBM25Context(docs, terms);
202
+
203
+ const scored: Array<{ hit: SearchHit; score: number }> = [];
204
+ for (let i = 0; i < entries.length; i++) {
205
+ const e = entries[i];
206
+ const hay = docs[i];
207
+ const mc = countMatches(hay, terms);
208
+ if (mc === 0) continue;
209
+ const score = bm25Score(hay, terms, ctx);
210
+ const text = messages[i] ? fullText(messages[i]) : e.summary;
211
+ const snip = lineSnippet(text, snipRe);
212
+ scored.push({
213
+ hit: { ...e, snippet: snip, matchCount: mc },
214
+ score,
215
+ });
216
+ }
217
+
218
+ // Sort by BM25 score desc
219
+ scored.sort((a, b) => b.score - a.score);
220
+ return scored.map((s) => s.hit);
221
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * ultimate-pi harness settings (env-only). Re-exported for vendored pi-vcc layout.
3
+ */
4
+ export type { PiVccSettings } from "../../../../.pi/extensions/lib/harness-vcc-settings.js";
5
+ export {
6
+ loadSettings,
7
+ scaffoldSettings,
8
+ } from "../../../../.pi/extensions/lib/harness-vcc-settings.js";
@@ -0,0 +1,35 @@
1
+ /** Shared skill-tag collapse utilities */
2
+
3
+ const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
4
+ const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
5
+
6
+ /** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
7
+ export const collapseSkillLines = (lines: string[]): string[] => {
8
+ const result: string[] = [];
9
+ const seenSkills = new Set<string>();
10
+ let insideSkill = false;
11
+
12
+ for (const line of lines) {
13
+ const skillMatch = line.match(SKILL_TAG_RE);
14
+ if (skillMatch) {
15
+ insideSkill = true;
16
+ const name = skillMatch[1];
17
+ if (!seenSkills.has(name)) {
18
+ seenSkills.add(name);
19
+ result.push(`[skill: ${name}]`);
20
+ }
21
+ continue;
22
+ }
23
+ if (insideSkill) {
24
+ if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
25
+ continue;
26
+ }
27
+ result.push(line);
28
+ }
29
+ return result;
30
+ };
31
+
32
+ /** Collapse <skill name="X" ...>...</skill> blocks in raw text */
33
+ const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
34
+ export const collapseSkillText = (text: string): string =>
35
+ text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);
@@ -0,0 +1,157 @@
1
+ import type { Message } from "@mariozechner/pi-ai";
2
+ import type { FileOps } from "../types";
3
+ import { normalize } from "./normalize";
4
+ import { filterNoise } from "./filter-noise";
5
+ import { buildSections } from "./build-sections";
6
+ import { formatSummary, capBrief, RECALL_NOTE } from "./format";
7
+
8
+ export interface CompileInput {
9
+ messages: Message[];
10
+ previousSummary?: string;
11
+ fileOps?: FileOps;
12
+ }
13
+
14
+ const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
15
+
16
+ const SEPARATOR = "\n\n---\n\n";
17
+
18
+ /** Extract a named section from summary text */
19
+ const sectionOf = (text: string, header: string): string => {
20
+ const tag = `[${header}]`;
21
+ const start = text.indexOf(tag);
22
+ if (start < 0) return "";
23
+ const after = text.slice(start);
24
+ // Find next section header or separator
25
+ const nextSection = HEADER_NAMES
26
+ .filter((h) => h !== header)
27
+ .map((h) => after.indexOf(`[${h}]`))
28
+ .filter((n) => n > 0);
29
+ const nextSep = after.indexOf("\n\n---\n\n");
30
+ const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
31
+ const end = candidates[0];
32
+ return (end ? after.slice(0, end) : after).trim();
33
+ };
34
+
35
+ /** Extract the brief transcript part (everything after ---) */
36
+ const briefOf = (text: string): string => {
37
+ const idx = text.indexOf(SEPARATOR);
38
+ if (idx < 0) return "";
39
+ return text.slice(idx + SEPARATOR.length).trim();
40
+ };
41
+
42
+ /** Merge a header section */
43
+ const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
44
+ // Outstanding Context is volatile -- always use fresh only
45
+ if (header === "Outstanding Context") return fresh;
46
+ if (!prev) return fresh;
47
+ if (!fresh) return prev;
48
+
49
+ // Files And Changes: merge by category (Modified/Created/Read), dedup paths
50
+ if (header === "Files And Changes") {
51
+ return mergeFileLines(prev, fresh);
52
+ }
53
+
54
+ // Session Goal, User Preferences: line-level dedup, cap
55
+ const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
56
+ const prevLines = prev.split("\n").filter(isClean);
57
+ const freshLines = fresh.split("\n").filter(isClean);
58
+ const combined = [...new Set([...prevLines, ...freshLines])];
59
+ const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
60
+ const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
61
+ if (capped.length === 0) return "";
62
+ return `[${header}]\n${capped.join("\n")}`;
63
+ };
64
+
65
+ /** Merge Files And Changes by category, dedup paths across compactions */
66
+ const mergeFileLines = (prev: string, fresh: string): string => {
67
+ const categories = ["Modified", "Created", "Read"] as const;
68
+ const merged: Record<string, Set<string>> = {};
69
+ for (const cat of categories) merged[cat] = new Set();
70
+
71
+ // Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
72
+ for (const text of [prev, fresh]) {
73
+ for (const line of text.split("\n")) {
74
+ for (const cat of categories) {
75
+ const prefix = `- ${cat}: `;
76
+ if (!line.startsWith(prefix)) continue;
77
+ let rest = line.slice(prefix.length);
78
+ // Strip "(+N more)" suffix
79
+ rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
80
+ for (const p of rest.split(",")) {
81
+ const trimmed = p.trim();
82
+ if (trimmed) merged[cat].add(trimmed);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // Dedup: if already in Modified, drop from Created (file existed before)
89
+ for (const p of merged.Modified) merged.Created.delete(p);
90
+
91
+ const cap = (set: Set<string>, limit: number) => {
92
+ const arr = [...set];
93
+ if (arr.length <= limit) return arr.join(", ");
94
+ return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
95
+ };
96
+
97
+ const lines: string[] = [];
98
+ if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
99
+ if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
100
+ if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
101
+ if (lines.length === 0) return "";
102
+ return `[Files And Changes]\n${lines.join("\n")}`;
103
+ };
104
+
105
+ const mergeBriefTranscript = (prev: string, fresh: string): string => {
106
+ if (!prev) return fresh;
107
+ if (!fresh) return prev;
108
+ return prev + "\n\n" + fresh;
109
+ };
110
+
111
+ const mergePrevious = (prev: string, fresh: string): string => {
112
+ // Merge header sections
113
+ const headers = HEADER_NAMES
114
+ .map((header) => {
115
+ const freshSec = sectionOf(fresh, header);
116
+ const prevSec = sectionOf(prev, header);
117
+ return mergeHeaderSection(header, prevSec, freshSec);
118
+ })
119
+ .filter(Boolean);
120
+
121
+ // Merge brief transcript
122
+ const prevBrief = briefOf(prev);
123
+ const freshBrief = briefOf(fresh);
124
+ const mergedBrief = mergeBriefTranscript(prevBrief, freshBrief);
125
+
126
+ const parts: string[] = [];
127
+ if (headers.length > 0) {
128
+ parts.push(headers.join("\n\n"));
129
+ }
130
+ if (mergedBrief) {
131
+ parts.push(capBrief(mergedBrief));
132
+ }
133
+
134
+ return parts.join(SEPARATOR);
135
+ };
136
+
137
+ export const compile = (input: CompileInput): string => {
138
+ const blocks = filterNoise(normalize(input.messages));
139
+ const data = buildSections({ blocks });
140
+ const fresh = formatSummary(data);
141
+ // Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
142
+ // so merge doesn't re-stack it inside the brief.
143
+ const prev = input.previousSummary
144
+ ? stripRecallNote(input.previousSummary)
145
+ : undefined;
146
+ const merged = prev ? mergePrevious(prev, fresh) : fresh;
147
+ if (!merged) return "";
148
+ return merged + SEPARATOR + RECALL_NOTE;
149
+ };
150
+
151
+ const stripRecallNote = (text: string): string => {
152
+ // Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
153
+ // Handles both current format (---\n\nNOTE) and bare trailing NOTE.
154
+ const idx = text.lastIndexOf(RECALL_NOTE);
155
+ if (idx < 0) return text;
156
+ return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
157
+ };
@@ -0,0 +1,14 @@
1
+ export const extractPath = (args: Record<string, unknown>): string | null => {
2
+ for (const key of ["path", "file_path", "filePath", "file"]) {
3
+ if (typeof args[key] === "string") return args[key] as string;
4
+ }
5
+ return null;
6
+ };
7
+
8
+ export const summarizeToolArgs = (args: Record<string, unknown>): string => {
9
+ const path = extractPath(args);
10
+ if (path) return `path=${path}`;
11
+ if (typeof args.command === "string") return `command=${args.command}`;
12
+ if (typeof args.query === "string") return `query=${args.query}`;
13
+ return Object.keys(args).join(", ");
14
+ };
@@ -0,0 +1,7 @@
1
+ export interface PiVccCompactionDetails {
2
+ compactor: "pi-vcc" | "ultimate-pi-vcc";
3
+ version: number;
4
+ sections: string[];
5
+ sourceMessageCount: number;
6
+ previousSummaryUsed: boolean;
7
+ }
@@ -0,0 +1,69 @@
1
+ import type { NormalizedBlock } from "../types";
2
+
3
+ interface CommitInfo {
4
+ hash?: string;
5
+ message: string;
6
+ }
7
+
8
+ const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
9
+ // Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
10
+ const HASH_RE = /\b([0-9a-f]{7,12})\b/;
11
+
12
+ const firstLineOf = (text: string): string => {
13
+ const line = text.split(/\\n|\n/)[0] ?? "";
14
+ return line.trim();
15
+ };
16
+
17
+ const cleanMessage = (msg: string): string =>
18
+ msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
19
+
20
+ /**
21
+ * Extract git commits from bash tool calls (`git commit -m "..."`) and pair
22
+ * with hash from the immediately following tool_result.
23
+ */
24
+ export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
25
+ const commits: CommitInfo[] = [];
26
+
27
+ for (let i = 0; i < blocks.length; i++) {
28
+ const b = blocks[i];
29
+ if (b.kind !== "tool_call" || b.name !== "bash") continue;
30
+ const cmd = typeof b.args.command === "string" ? b.args.command : "";
31
+ if (!/\bgit\s+commit\b/.test(cmd)) continue;
32
+ const m = cmd.match(COMMIT_MSG_RE);
33
+ if (!m) continue;
34
+ const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
35
+ if (!message) continue;
36
+
37
+ let hash: string | undefined;
38
+ // Look at next tool_result for hash
39
+ for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
40
+ const r = blocks[j];
41
+ if (r.kind !== "tool_result") continue;
42
+ // Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
43
+ const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
44
+ if (bracket) { hash = bracket[1]; break; }
45
+ const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
46
+ if (range) { hash = range[2]; break; }
47
+ const plain = r.text.match(HASH_RE);
48
+ if (plain) { hash = plain[1]; break; }
49
+ }
50
+
51
+ // Dedup by message+hash
52
+ const key = `${hash ?? ""}::${message}`;
53
+ if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
54
+ commits.push({ hash, message });
55
+ }
56
+ }
57
+
58
+ return commits;
59
+ };
60
+
61
+ export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
62
+ const lines: string[] = [];
63
+ const items = commits.slice(-limit); // keep most recent
64
+ for (const c of items) {
65
+ const prefix = c.hash ? `${c.hash}: ` : "";
66
+ lines.push(`${prefix}${c.message}`);
67
+ }
68
+ return lines;
69
+ };
@@ -0,0 +1,80 @@
1
+ import type { FileOps, NormalizedBlock } from "../types";
2
+ import { extractPath } from "../core/tool-args";
3
+
4
+ interface FileActivity {
5
+ read: Set<string>;
6
+ modified: Set<string>;
7
+ created: Set<string>;
8
+ }
9
+
10
+ const FILE_READ_TOOLS = new Set([
11
+ "Read", "read_file", "View",
12
+ ]);
13
+
14
+ const FILE_WRITE_TOOLS = new Set([
15
+ "Edit", "Write", "edit", "write", "edit_file", "write_file",
16
+ "MultiEdit",
17
+ ]);
18
+
19
+ const FILE_CREATE_TOOLS = new Set([
20
+ "Write", "write", "write_file",
21
+ ]);
22
+
23
+ /**
24
+ * Find the longest common directory prefix among absolute paths.
25
+ * Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
26
+ */
27
+ const longestCommonDirPrefix = (paths: string[]): string => {
28
+ const abs = paths.filter((p) => p.startsWith("/"));
29
+ if (abs.length < 2) return "";
30
+ const split = abs.map((p) => p.split("/"));
31
+ const min = Math.min(...split.map((s) => s.length));
32
+ let i = 0;
33
+ while (i < min - 1) {
34
+ const seg = split[0][i];
35
+ if (!split.every((s) => s[i] === seg)) break;
36
+ i++;
37
+ }
38
+ if (i < 2) return ""; // require at least /a/b common
39
+ return split[0].slice(0, i).join("/") + "/";
40
+ };
41
+
42
+ const trimPaths = (set: Set<string>, prefix: string): Set<string> => {
43
+ if (!prefix) return set;
44
+ const out = new Set<string>();
45
+ for (const p of set) {
46
+ out.add(p.startsWith(prefix) ? p.slice(prefix.length) : p);
47
+ }
48
+ return out;
49
+ };
50
+
51
+ export const extractFiles = (
52
+ blocks: NormalizedBlock[],
53
+ fileOps?: FileOps,
54
+ ): FileActivity => {
55
+ const act: FileActivity = {
56
+ read: new Set(fileOps?.readFiles ?? []),
57
+ modified: new Set(fileOps?.modifiedFiles ?? []),
58
+ created: new Set(fileOps?.createdFiles ?? []),
59
+ };
60
+
61
+ for (const b of blocks) {
62
+ if (b.kind !== "tool_call") continue;
63
+ const p = extractPath(b.args);
64
+ if (!p) continue;
65
+
66
+ if (FILE_READ_TOOLS.has(b.name)) act.read.add(p);
67
+ if (FILE_WRITE_TOOLS.has(b.name)) act.modified.add(p);
68
+ if (FILE_CREATE_TOOLS.has(b.name)) act.created.add(p);
69
+ }
70
+
71
+ const all = [...act.read, ...act.modified, ...act.created];
72
+ const prefix = longestCommonDirPrefix(all);
73
+ if (prefix) {
74
+ act.read = trimPaths(act.read, prefix);
75
+ act.modified = trimPaths(act.modified, prefix);
76
+ act.created = trimPaths(act.created, prefix);
77
+ }
78
+
79
+ return act;
80
+ };
@@ -0,0 +1,79 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { nonEmptyLines, clip } from "../core/content";
3
+ import { collapseSkillLines } from "../core/skill-collapse";
4
+
5
+ const SCOPE_CHANGE_RE =
6
+ /\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
7
+
8
+ const TASK_RE =
9
+ /\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up)\b/i;
10
+
11
+ const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|ok\b.*|y|n|k)\s*[.!?]*$/i;
12
+
13
+ // Reject lines that are clearly not user goals (pasted output, code, paths, tool dumps)
14
+ // or meta-prompt boilerplate (command templates like `/issues` that start with "For each issue:"
15
+ // followed by numbered "Read the issue in full..." steps).
16
+ const NON_GOAL_RE =
17
+ /^\s*[\[│├└─╭╰]|```|^\s*(=[A-Z]+\(|function |const |let |var |import |export |class )|^(https?:|file:|\/[A-Za-z])|\\n|^\s*For each\b|\bin full\b[^\n]*\b(comments|issue|issues|PRs?|linked)\b/;
18
+
19
+ // Signals that the rest of the user message is a command template (e.g. /issues),
20
+ // in which case we should stop collecting goals at the signal line.
21
+ const TEMPLATE_SIGNAL_RE =
22
+ /^\s*(For each\b|Do NOT implement\b|Analyze and propose\b|If Task\/context\b|Output:\s*$)/i;
23
+
24
+ const truncateAtTemplate = (lines: string[]): string[] => {
25
+ const idx = lines.findIndex((l) => TEMPLATE_SIGNAL_RE.test(l));
26
+ return idx >= 0 ? lines.slice(0, idx) : lines;
27
+ };
28
+
29
+ const stripLeadingBullet = (line: string): string =>
30
+ line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim();
31
+
32
+ const MAX_GOAL_CHARS = 200;
33
+
34
+ const isSubstantiveGoal = (text: string): boolean => {
35
+ const t = text.trim();
36
+ if (t.length <= 5) return false;
37
+ if (t.length > MAX_GOAL_CHARS) return false;
38
+ if (NOISE_SHORT_RE.test(t)) return false;
39
+ if (NON_GOAL_RE.test(t)) return false;
40
+ return true;
41
+ };
42
+
43
+ // Test scope-change / task intent only on the leading portion of a user block
44
+ // so that pasted outputs below the actual instruction do not trigger matches.
45
+ const LEADING_CHARS = 200;
46
+
47
+ export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
48
+ const goals: string[] = [];
49
+ let latestScopeChange: string[] | null = null;
50
+
51
+ for (const b of blocks) {
52
+ if (b.kind !== "user") continue;
53
+ const rawLines = nonEmptyLines(b.text);
54
+ const truncated = truncateAtTemplate(rawLines);
55
+ const lines = collapseSkillLines(truncated.filter(isSubstantiveGoal))
56
+ .map(stripLeadingBullet)
57
+ .filter((l) => l.length > 5);
58
+ if (lines.length === 0) continue;
59
+
60
+ if (goals.length === 0) {
61
+ goals.push(...lines.slice(0, 6));
62
+ continue;
63
+ }
64
+
65
+ const leading = b.text.slice(0, LEADING_CHARS);
66
+ if (SCOPE_CHANGE_RE.test(leading)) {
67
+ latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
68
+ } else if (TASK_RE.test(leading) && lines[0].length > 15) {
69
+ latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
70
+ }
71
+ }
72
+
73
+ // Only emit the [Scope change] marker when we actually captured bullets.
74
+ if (latestScopeChange && latestScopeChange.length > 0) {
75
+ goals.push("[Scope change]", ...latestScopeChange);
76
+ }
77
+
78
+ return goals.slice(0, 8);
79
+ };