pi-crew 0.9.12 → 0.9.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -15,7 +15,9 @@ export const DEFAULT_CHILD_PI: Readonly<{
15
15
  // Child workers can spend more than a few seconds in provider calls or long-running tools without emitting stdout.
16
16
  // Keep this as a coarse stuck-worker guard rather than a short per-message latency budget.
17
17
  responseTimeoutMs: 5 * 60_000,
18
- maxCaptureBytes: 256 * 1024,
18
+ // #3 unresponsive worker hardening: increased from 256KB to 512KB so critical
19
+ // diagnostic stderr is less likely to be silently truncated during hang analysis.
20
+ maxCaptureBytes: 512 * 1024,
19
21
  // L4 output-handling: thresholds sized from real worker-output data
20
22
  // (27 result artifacts measured: max 9226 bytes, median 8272, 100% < 16KB).
21
23
  // Previous values (8192/1024/4096) truncated 62% of real results.
@@ -9,6 +9,16 @@
9
9
  * Philosophy (Round 6 stress-test): radically downsized. Just a Markdown
10
10
  * file the user can edit, surfaced into every run. No vector DB, no
11
11
  * embeddings, no graph. Simple = trustworthy.
12
+ *
13
+ * B2 section-aware injection (2026-06-28): knowledge.md splits cleanly into
14
+ * CONVENTIONS (always-relevant: Code Style, Environment, Architecture,
15
+ * Testing, Release Process — ~2.5KB) and SESSION-LOG (per-version post-
16
+ * mortems, incidents, fix detail — ~29KB, rarely relevant). Conventions
17
+ * are ALWAYS injected; session-log sections are injected on-demand via
18
+ * header-token IDF scoring against the task/goal, capped at
19
+ * MAX_SESSION_LOG_BYTES. Non-matched session-log is summarized as a section
20
+ * index with a `read` path-hint so the worker can recover any omitted topic.
21
+ * Design doc: research-findings/b2-section-aware-design.md.
12
22
  */
13
23
  import * as fs from "node:fs";
14
24
  import * as path from "node:path";
@@ -17,20 +27,179 @@ import { projectCrewRoot } from "../utils/paths.ts";
17
27
 
18
28
  /** The knowledge file, relative to the project crew root. */
19
29
  export const KNOWLEDGE_FILENAME = "knowledge.md";
20
- /** Cap injected knowledge to avoid unbounded system-prompt growth. */
21
- const MAX_KNOWLEDGE_BYTES = 16_000;
30
+
31
+ /**
32
+ * Session-log injection cap (B2). Conventions are always injected in full;
33
+ * matched session-log sections are capped here to bound total prompt size.
34
+ * Sized so 2-3 typical sections (~1-2.5KB each) fit, while the largest single
35
+ * outlier (v0.9.10 IN PROGRESS, ~7KB) is excluded from a full-match scenario.
36
+ */
37
+ const MAX_SESSION_LOG_BYTES = 5_000;
38
+
39
+ /**
40
+ * Relevance context for section-aware injection. When omitted (or when goal/
41
+ * taskText are both absent), injection falls back to conventions-only — no
42
+ * IDF computation, no session-log body. This keeps callers without query
43
+ * context (main-session hook, legacy tests) stable.
44
+ */
45
+ export interface KnowledgeQuery {
46
+ /** The team run goal (broad topic signal). */
47
+ goal?: string;
48
+ /** The step/task instruction text (narrow topic signal). */
49
+ taskText?: string;
50
+ /** The worker role (reserved for future role-floor/boost; not scored yet). */
51
+ role?: string;
52
+ }
53
+
54
+ /**
55
+ * Headers of sections always treated as CONVENTIONS (universal project rules).
56
+ * Anything NOT matching these (after the convention run) is SESSION-LOG.
57
+ * Header-matching is prefix + case-insensitive against the H2 title.
58
+ */
59
+ const CONVENTION_HEADERS = ["Code Style", "Environment", "Architecture", "Testing", "Release Process"];
22
60
 
23
61
  /** Resolve the knowledge file path for a cwd (may not exist yet). */
24
62
  export function knowledgePath(cwd: string): string {
25
63
  return path.join(projectCrewRoot(cwd), KNOWLEDGE_FILENAME);
26
64
  }
27
65
 
28
- /** Read knowledge content, truncated to a safe size. "" if absent/empty. */
29
- export function readKnowledge(cwd: string): string {
66
+ interface KnowledgeSection {
67
+ /** H2 header text (without leading `## `). */
68
+ header: string;
69
+ /** Full section body including the `## ` header line. */
70
+ body: string;
71
+ /** Header tokens (lowercased, stopword-filtered) for IDF scoring. */
72
+ headerTokens: Set<string>;
73
+ }
74
+
75
+ const STOPWORDS = new Set([
76
+ "the", "a", "an", "is", "are", "of", "to", "in", "for", "and", "or", "not",
77
+ "with", "this", "that", "it", "be", "on", "at", "by", "do", "does", "how",
78
+ "what", "when", "from", "fix", "fixes", "fixed", "v0", "v1", "released",
79
+ "progress", "uncommitted", "not", "pushed", "published", "session", "post",
80
+ ]);
81
+
82
+ /** Tokenize header text into a Set of lowercased non-stopword tokens. */
83
+ function tokenizeHeader(header: string): Set<string> {
84
+ const tokens = new Set<string>();
85
+ for (const raw of header.toLowerCase().split(/[^a-z0-9.]+/)) {
86
+ // keep dot-paths (e.g. "run.lock", "config.ts") and alphanumerics >= 2 chars
87
+ const t = raw.replace(/^\.+|\.+$/g, "");
88
+ if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
89
+ }
90
+ return tokens;
91
+ }
92
+
93
+ /** Parse knowledge.md into (conventions, sessionLog) sections by H2 header. */
94
+ function parseKnowledgeSections(content: string): { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] } {
95
+ const lines = content.split(/\r?\n/);
96
+ const sections: KnowledgeSection[] = [];
97
+ let current: { header: string; lines: string[] } | null = null;
98
+ for (const line of lines) {
99
+ const m = /^##\s+(.+?)\s*$/.exec(line);
100
+ if (m) {
101
+ if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
102
+ current = { header: m[1]!, lines: [line] };
103
+ } else if (current) {
104
+ current.lines.push(line);
105
+ }
106
+ }
107
+ if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
108
+
109
+ // Classify: a section is a CONVENTION if its header starts-with one of the
110
+ // known convention titles. Once we leave the convention run (first non-
111
+ // matching H2 after conventions), everything else is SESSION-LOG. This is
112
+ // self-healing: if knowledge.md is restructured, the classifier adapts.
113
+ const conventions: KnowledgeSection[] = [];
114
+ const sessionLog: KnowledgeSection[] = [];
115
+ let leftConventionRun = false;
116
+ for (const sec of sections) {
117
+ const isConvention = !leftConventionRun && CONVENTION_HEADERS.some((c) => sec.header.toLowerCase().startsWith(c.toLowerCase()));
118
+ if (isConvention) conventions.push(sec);
119
+ else {
120
+ leftConventionRun = true;
121
+ sessionLog.push(sec);
122
+ }
123
+ }
124
+ return { conventions, sessionLog };
125
+ }
126
+
127
+ /** Compute IDF (inverse document frequency) over session-log header tokens. */
128
+ function computeIdf(sections: KnowledgeSection[]): Map<string, number> {
129
+ const N = sections.length;
130
+ const df = new Map<string, number>();
131
+ for (const s of sections) for (const token of s.headerTokens) df.set(token, (df.get(token) ?? 0) + 1);
132
+ const idf = new Map<string, number>();
133
+ for (const [token, freq] of df) idf.set(token, Math.log(N / freq)); // standard IDF; freq >= 1
134
+ return idf;
135
+ }
136
+
137
+ /** Tokenize a free-form query (goal/taskText) the same way as headers. */
138
+ function tokenizeQuery(query: string): Set<string> {
139
+ const tokens = new Set<string>();
140
+ for (const raw of query.toLowerCase().split(/[^a-z0-9.]+/)) {
141
+ const t = raw.replace(/^\.+|\.+$/g, "");
142
+ if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
143
+ }
144
+ return tokens;
145
+ }
146
+
147
+ /** Score a section by summed IDF of query tokens present in its header. */
148
+ function scoreSection(queryTokens: Set<string>, headerTokens: Set<string>, idf: Map<string, number>): number {
149
+ let score = 0;
150
+ for (const token of queryTokens) if (headerTokens.has(token)) score += idf.get(token) ?? 0;
151
+ return score;
152
+ }
153
+
154
+ /**
155
+ * Select session-log sections relevant to the query, capped at budgetBytes.
156
+ * Greedy by descending score (then original order), drop-whole policy with a
157
+ * head-slice fallback for the single best match if nothing else fits (so a
158
+ * matched query never returns zero bytes).
159
+ */
160
+ function selectSessionLog(query: string, sessionLog: KnowledgeSection[], budgetBytes: number): KnowledgeSection[] {
161
+ if (sessionLog.length === 0 || !query.trim()) return [];
162
+ const idf = computeIdf(sessionLog);
163
+ const queryTokens = tokenizeQuery(query);
164
+ // Match = ANY header-token overlap with the query. IDF still drives RANKING
165
+ // (rarer overlap ranks first), but a token that happens to appear in every
166
+ // section (IDF=0) must still count as a match — otherwise a query for a
167
+ // common-but-relevant keyword (e.g. "redaction") would return nothing.
168
+ const scored = sessionLog
169
+ .map((sec, idx) => {
170
+ const overlap = [...queryTokens].filter((t) => sec.headerTokens.has(t));
171
+ return { sec, score: overlap.reduce((sum, t) => sum + (idf.get(t) ?? 0), 0), idx, hasOverlap: overlap.length > 0 };
172
+ })
173
+ .filter((x) => x.hasOverlap);
174
+ if (scored.length === 0) return [];
175
+ scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
176
+
177
+ const selected: KnowledgeSection[] = [];
178
+ let used = 0;
179
+ let bestMatch: { sec: KnowledgeSection; score: number } | undefined;
180
+ for (const { sec, score } of scored) {
181
+ if (score > (bestMatch?.score ?? -1)) bestMatch = { sec, score };
182
+ if (used + sec.body.length <= budgetBytes) {
183
+ selected.push(sec);
184
+ used += sec.body.length;
185
+ }
186
+ }
187
+ // Head-slice fallback: if the best match didn't fit (or nothing fit), inject
188
+ // a head-sliced copy of the single best match so the query isn't empty.
189
+ if (selected.length === 0 && bestMatch) {
190
+ const sliced = bestMatch.sec.body.slice(0, Math.max(0, budgetBytes));
191
+ selected.push({ ...bestMatch.sec, body: `${sliced}\n\n<!-- section truncated (session-log budget ${budgetBytes} bytes). Full file: use \`read\`. -->` });
192
+ }
193
+ return selected;
194
+ }
195
+
196
+ /** Read knowledge content. "" if absent/empty. */
197
+ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
30
198
  try {
31
199
  const p = knowledgePath(cwd);
32
200
  const stat = tryStat(p);
33
201
  if (!stat) {
202
+ sectionCache.delete(p);
34
203
  knowledgeCache.delete(p);
35
204
  return "";
36
205
  }
@@ -39,14 +208,46 @@ export function readKnowledge(cwd: string): string {
39
208
  // For a run with N workers this is N redundant readFileSync of the same
40
209
  // file. Cache by (mtimeMs, size) and only re-read when the file changes.
41
210
  const cacheKey = `${stat.mtimeMs}:${stat.size}`;
42
- const cached = knowledgeCache.get(p);
43
- if (cached && cached.key === cacheKey) return cached.content;
44
- let content = fs.readFileSync(p, "utf8").trim();
45
- if (content.length > MAX_KNOWLEDGE_BYTES) {
46
- content = `${content.slice(0, MAX_KNOWLEDGE_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_BYTES} bytes -->`;
211
+
212
+ // B2: when no query context is provided (main-session hook, legacy
213
+ // callers, tests), keep the simple head-only path. This preserves the
214
+ // 4 existing unit tests exactly (they call readKnowledge(cwd)).
215
+ if (!query || (!query.goal && !query.taskText)) {
216
+ const cached = knowledgeCache.get(p);
217
+ if (cached && cached.key === cacheKey) return cached.content;
218
+ let content = fs.readFileSync(p, "utf8").trim();
219
+ if (content.length > MAX_KNOWLEDGE_HEAD_BYTES) {
220
+ content = `${content.slice(0, MAX_KNOWLEDGE_HEAD_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_HEAD_BYTES} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
221
+ }
222
+ knowledgeCache.set(p, { key: cacheKey, content });
223
+ return content;
224
+ }
225
+
226
+ // Section-aware path: cache parsed sections, select per-query.
227
+ const cachedSections = sectionCache.get(p);
228
+ let parsed: { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] };
229
+ if (cachedSections && cachedSections.key === cacheKey) {
230
+ parsed = { conventions: cachedSections.conventions, sessionLog: cachedSections.sessionLog };
231
+ } else {
232
+ const content = fs.readFileSync(p, "utf8").trim();
233
+ parsed = parseKnowledgeSections(content);
234
+ sectionCache.set(p, { key: cacheKey, conventions: parsed.conventions, sessionLog: parsed.sessionLog });
235
+ }
236
+
237
+ const queryText = [query.goal, query.taskText].filter(Boolean).join(" \n ");
238
+ const matchedSessionLog = selectSessionLog(queryText, parsed.sessionLog, MAX_SESSION_LOG_BYTES);
239
+
240
+ const parts: string[] = [];
241
+ // Conventions: always full.
242
+ for (const sec of parsed.conventions) parts.push(sec.body);
243
+ // Matched session-log (drop-whole, budget-capped).
244
+ for (const sec of matchedSessionLog) parts.push(sec.body);
245
+ // Always: section-index of ALL session-log headers (recovery safety net).
246
+ if (parsed.sessionLog.length > 0) {
247
+ const indexLines = parsed.sessionLog.map((s) => ` - ${s.header}`);
248
+ parts.push(`<!-- Session-log sections in knowledge.md (not injected unless matched above — use \`read\` for detail):\n${indexLines.join("\n")}\nFull file: ${p} -->`);
47
249
  }
48
- knowledgeCache.set(p, { key: cacheKey, content });
49
- return content;
250
+ return parts.join("\n").trim();
50
251
  } catch {
51
252
  return "";
52
253
  }
@@ -68,9 +269,19 @@ interface CachedKnowledge {
68
269
  }
69
270
  const knowledgeCache = new Map<string, CachedKnowledge>();
70
271
 
272
+ interface CachedSections {
273
+ key: string;
274
+ conventions: KnowledgeSection[];
275
+ sessionLog: KnowledgeSection[];
276
+ }
277
+ const sectionCache = new Map<string, CachedSections>();
278
+
279
+ /** Head cap for the no-query (legacy / main-session) path. */
280
+ const MAX_KNOWLEDGE_HEAD_BYTES = 2_000;
281
+
71
282
  /** Build the injected prompt fragment (empty if no knowledge). */
72
- export function buildKnowledgeFragment(cwd: string): string {
73
- const content = readKnowledge(cwd);
283
+ export function buildKnowledgeFragment(cwd: string, query?: KnowledgeQuery): string {
284
+ const content = readKnowledge(cwd, query);
74
285
  if (!content) return "";
75
286
  return [
76
287
  "",
@@ -85,8 +296,18 @@ export function buildKnowledgeFragment(cwd: string): string {
85
296
 
86
297
  /**
87
298
  * Register the knowledge-injection hook. Appends project knowledge to the
88
- * system prompt on every agent start (main session + each crew worker,
89
- * since workers are child Pi processes that also fire before_agent_start).
299
+ * MAIN session's system prompt on `before_agent_start`. This hook does NOT
300
+ * fire for crew workers: they are spawned with `--no-extensions`, so the
301
+ * extension layer (and this hook) never loads in their process. Workers
302
+ * instead receive knowledge via `buildKnowledgeFragment(task.cwd)` injected
303
+ * into their prompt stablePrefix by `prompt-builder.ts`. Do NOT "fix" this
304
+ * perceived gap by making the hook reach workers — it would cause
305
+ * double-injection. (Verified by research workflow 2026-06-28.)
306
+ *
307
+ * The hook calls buildKnowledgeFragment(cwd) with NO query — so the main
308
+ * session gets conventions-only (no session-log noise), which is the right
309
+ * default for an interactive session that doesn't have a single task focus.
310
+ * Workers (which DO have a task) use the section-aware path via prompt-builder.
90
311
  */
91
312
  export function registerKnowledgeInjection(pi: ExtensionAPI): void {
92
313
  pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
@@ -44,4 +44,4 @@ export { defineTool, createBashTool } from "@earendil-works/pi-coding-agent";
44
44
  * and to surface version-drift if Pi upgrades introduce breaking changes.
45
45
  * Update this when bumping the pi-coding-agent dependency.
46
46
  */
47
- export const BUILT_AGAINST_PI_VERSION = "0.79.3";
47
+ export const BUILT_AGAINST_PI_VERSION = "0.77.0";
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Team-tool entry point for the `chain` feature.
3
+ *
4
+ * Dispatched from `handleRun` (run.ts) via an early-return guard:
5
+ * if (params.chain) → handleChainRun(params, ctx, handleRun)
6
+ *
7
+ * `handleRun` is passed by reference (NOT imported) to break the
8
+ * run.ts ↔ chain-dispatch.ts import cycle. This file imports only
9
+ * chain-runner, handoff-manager, chain-executor, and the shared
10
+ * tool-result helpers — none of which import run.ts.
11
+ *
12
+ * @see src/extension/team-tool/chain-executor.ts (ChainTeamRunExecutor)
13
+ * @see src/runtime/chain-runner.ts (ChainRunner.runChain, parseChainString)
14
+ */
15
+
16
+ import { ChainRunner, parseChainString } from "../../runtime/chain-runner.ts";
17
+ import { HandoffManager } from "../../runtime/handoff-manager.ts";
18
+ import { ChainTeamRunExecutor, type HandleRunFn } from "./chain-executor.ts";
19
+ import { result, type TeamContext } from "./context.ts";
20
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
21
+ import type { PiTeamsToolResult } from "../tool-result.ts";
22
+
23
+ /**
24
+ * Execute a chain expression (`"a -> b -> c"`). Each step runs a real team run
25
+ * via the injected `handleRun`, with handoff context passed forward through the
26
+ * goal text. Returns a multi-line summary plus structured `data` for the TUI.
27
+ *
28
+ * @param params - Must contain `chain`. `team`/`workflow`/`model` are forwarded
29
+ * as per-step overrides when a step does not declare its own.
30
+ * @param ctx - The team tool context (cwd is used to load run manifests).
31
+ * @param handleRun - The run.ts `handleRun` function reference (injected).
32
+ */
33
+ export async function handleChainRun(
34
+ params: TeamToolParamsValue,
35
+ ctx: TeamContext,
36
+ handleRun: HandleRunFn,
37
+ ): Promise<PiTeamsToolResult> {
38
+ const chainString = params.chain;
39
+ if (!chainString || chainString.trim().length === 0) {
40
+ return result("Chain expression is empty.", { action: "run", status: "error" }, true);
41
+ }
42
+
43
+ const spec = parseChainString(chainString);
44
+ if (spec.steps.length === 0) {
45
+ return result(
46
+ "Chain must contain at least one step. Use the form: \"step1 -> step2 -> step3\".",
47
+ { action: "run", status: "error" },
48
+ true,
49
+ );
50
+ }
51
+
52
+ // Construct the concrete executor with per-step overrides forwarded from the
53
+ // chain invocation (overridden by any step that parsed to a @team reference).
54
+ const executor = new ChainTeamRunExecutor({
55
+ handleRun,
56
+ ctx,
57
+ overrides: {
58
+ team: params.team,
59
+ workflow: params.workflow,
60
+ model: params.model,
61
+ },
62
+ });
63
+
64
+ // Surface the global continueOnError from the parsed spec (e.g. --continue-on-error=true).
65
+ const runner = new ChainRunner(executor, new HandoffManager());
66
+ const chainResult = await runner.runChain(spec, {}, undefined);
67
+
68
+ // Build a readable multi-line summary.
69
+ const lines: string[] = [
70
+ `Chain ${chainResult.success ? "completed" : "ended"}: ${spec.steps.length} step(s), ${chainResult.totalHandoffs.length} handoff(s).`,
71
+ "",
72
+ ];
73
+ for (const s of chainResult.steps) {
74
+ const runId = executor.stepRunIds[s.step - 1];
75
+ const tag =
76
+ s.outcome === "success" ? "✓"
77
+ : s.outcome === "failure" ? "✗"
78
+ : s.outcome === "partial" ? "≈"
79
+ : "⊘";
80
+ lines.push(
81
+ `${tag} Step ${s.step} [${s.name}]: ${s.outcome} (${s.duration}ms)${runId ? ` runId=${runId}` : ""}${s.error ? ` | ${s.error}` : ""}`,
82
+ );
83
+ }
84
+ lines.push("");
85
+ lines.push(
86
+ `Total: ${chainResult.totalDuration}ms${chainResult.totalTokens !== undefined ? `, ${chainResult.totalTokens} tokens` : ""}`,
87
+ );
88
+
89
+ return result(
90
+ lines.join("\n"),
91
+ {
92
+ action: "run",
93
+ status: chainResult.success ? "ok" : "error",
94
+ data: {
95
+ chain: true,
96
+ steps: chainResult.steps.length,
97
+ totalTokens: chainResult.totalTokens,
98
+ runIds: executor.stepRunIds,
99
+ },
100
+ },
101
+ !chainResult.success,
102
+ );
103
+ }