@ghyper9023/pi-dev-workflow 0.6.3 → 0.8.0

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 (40) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/.pre-commit-config.yaml +75 -0
  3. package/.version/RELEASE-v0.7.0.md +85 -0
  4. package/.version/RELEASE-v0.8.0.md +93 -0
  5. package/README.md +178 -294
  6. package/extensions/dev-prompts.ts +121 -1033
  7. package/extensions/git-commands.ts +62 -171
  8. package/extensions/grill-me-agent.ts +160 -164
  9. package/extensions/pre-check.ts +190 -0
  10. package/extensions/review-detect.ts +123 -0
  11. package/extensions/session-utils.ts +168 -0
  12. package/extensions/ui-helpers.ts +22 -780
  13. package/package.json +2 -2
  14. package/prompts/APPEND_SYSTEM.md +73 -59
  15. package/skills/review-html/SKILL.md +1 -1
  16. package/tests/test-no-subagents.mjs +264 -0
  17. package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
  18. package/agents/git-agent.md +0 -44
  19. package/agents/grill/dev-doc-grill-agent.md +0 -42
  20. package/agents/grill/dev-fix-grill-agent.md +0 -44
  21. package/agents/grill/dev-grill-agent.md +0 -40
  22. package/agents/grill/dev-perf-grill-agent.md +0 -45
  23. package/agents/grill/dev-prd-agent.md +0 -60
  24. package/agents/grill/dev-refactor-grill-agent.md +0 -46
  25. package/agents/grill/dev-test-grill-agent.md +0 -45
  26. package/agents/review-agent.md +0 -53
  27. package/agents/workflow/docWriter-agent.md +0 -53
  28. package/agents/workflow/planner-agent.md +0 -131
  29. package/agents/workflow/reviewer-agent.md +0 -128
  30. package/agents/workflow/trimmer-agent.md +0 -78
  31. package/agents/workflow/worker-agent.md +0 -70
  32. package/extensions/sub-agents.ts +0 -954
  33. package/extensions/workflow-engine.ts +0 -2005
  34. package/tests/test-grill-json-fix.mjs +0 -243
  35. package/tests/test-loopcount-timeout-fix.mjs +0 -336
  36. package/tests/test-output-directory-structure.mjs +0 -177
  37. package/tests/test-save-answer-file-workflow.mjs +0 -187
  38. package/tests/test-workflow-config.mjs +0 -244
  39. package/tests/test-workflow-engine-bugs.mjs +0 -908
  40. package/tests/test-workflow-engine.mjs +0 -518
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pre-Check Extension
3
+ *
4
+ * 提供 `/dev-pre-check` 命令:在真正执行任务前插入一道「意图校验」闸门。
5
+ *
6
+ * 流程:
7
+ * 1. 取用户原始 prompt(命令参数,缺省则弹输入框)
8
+ * 2. 组合提示词 =「用户原始 prompt」+ 固定的「用自己的话重述目标与问题」指令,
9
+ * 并把本轮任务意图改成「只做意图理解,不产生任何实质改动」
10
+ * 3. 等待代理给出复述(代理空闲 + 本轮新增的 assistant 文本)
11
+ * 4. 用户确认意图:正确 → 开始真正的工作;不正确 → 补充说明后重新复述;取消 → 结束
12
+ * 5. 确认正确后,把「原始 prompt + 已确认的意图复述」投递给代理开始执行
13
+ *
14
+ * confirmIntent() 是上述 2-4 步的通用实现,/dev-feat 等 dev 命令同样复用:
15
+ * 它们把已确认的意图接进自己组装的提示词,而不是另造一套意图识别。
16
+ */
17
+
18
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
19
+ import { uiInput, uiSelect } from "./ui-helpers";
20
+ import { pollFor, getLastAssistantTextAfter } from "./session-utils";
21
+
22
+ // ── 提示词组装 ────────────────────────────────────────────────
23
+
24
+ /** 固定的「意图复述」指令,与用户原始 prompt 组合成意图校验提示词。 */
25
+ const RESTATE_INSTRUCTION = [
26
+ "## 你的任务",
27
+ "用自己的话重述你认为用户的目标是什么,以及用户试图解决的问题是什么。",
28
+ "",
29
+ "## 硬性约束(最高优先级)",
30
+ "- 本轮只做意图理解与复述,不执行任何实质工作。",
31
+ "- 禁止修改、创建、删除任何文件;禁止运行任何有副作用的命令。",
32
+ "- 禁止调用写入类工具(如 write、edit,以及 bash 中的写操作);只允许只读探查(如 read、grep)。",
33
+ "- 禁止输出代码、补丁、diff、实施计划、解决方案或改进建议。",
34
+ "- 不要反问用户、不要请求确认、不要给出下一步行动。",
35
+ "- 若原始 prompt 存在歧义或信息缺口,在「不确定之处」中明确指出,但不要替用户做决定。",
36
+ "",
37
+ "## 输出格式(严格遵循)",
38
+ "### 1. 用户的目标",
39
+ "(用自己的话,2-4 句)",
40
+ "",
41
+ "### 2. 用户试图解决的问题",
42
+ "(用自己的话,2-4 句)",
43
+ "",
44
+ "### 3. 不确定之处",
45
+ "(逐条列出,无则写「无」)",
46
+ "",
47
+ "### 4. 一句话概括",
48
+ "(一句话说清「用户想让我做什么」)",
49
+ "",
50
+ "复述完成后立即停止,等待用户确认意图。",
51
+ ].join("\n");
52
+
53
+ /** 组装意图校验提示词:原始 prompt + 固定复述指令(+ 历史修正说明)。 */
54
+ export function buildPreCheckPrompt(originalPrompt: string, corrections: string[]): string {
55
+ const lines: string[] = [
56
+ "[pre-check] 任务意图校验:只复述,不执行",
57
+ "",
58
+ "## 用户原始 prompt(原文)",
59
+ originalPrompt,
60
+ "",
61
+ RESTATE_INSTRUCTION,
62
+ ];
63
+
64
+ if (corrections.length > 0) {
65
+ lines.push(
66
+ "",
67
+ "## 用户对上一次复述的修正(必须吸收)",
68
+ ...corrections.map((c, i) => `${i + 1}. ${c}`),
69
+ );
70
+ }
71
+
72
+ return lines.join("\n");
73
+ }
74
+
75
+ /** 组装确认后的执行提示词:原始 prompt + 已确认的意图复述。 */
76
+ export function buildExecutionPrompt(originalPrompt: string, intent: string): string {
77
+ return [
78
+ "[execute] 意图已确认,开始执行",
79
+ "",
80
+ "## 用户原始 prompt(原文,权威需求)",
81
+ originalPrompt,
82
+ "",
83
+ "## 已由用户确认的任务意图",
84
+ intent,
85
+ "",
86
+ "## 要求",
87
+ "- 现在开始执行用户原始 prompt 描述的工作。",
88
+ "- 以上已确认意图是本任务的权威前提;与你的理解冲突时,以该意图为准。",
89
+ "- 执行前若仍需澄清,先向我提问,不要臆测。",
90
+ ].join("\n");
91
+ }
92
+
93
+ // ── 意图确认循环 ─────────────────────────────────────────────
94
+
95
+ const PRE_CHECK_TIMEOUT_MS = 5 * 60_000;
96
+
97
+ /**
98
+ * 复述 → 确认 →(必要时)修正 的意图确认循环。
99
+ * 返回已确认的意图复述;用户取消或始终未取到复述时返回 undefined。
100
+ */
101
+ export async function confirmIntent(
102
+ task: string,
103
+ ctx: ExtensionCommandContext,
104
+ pi: ExtensionAPI,
105
+ ): Promise<string | undefined> {
106
+ const corrections: string[] = [];
107
+
108
+ for (;;) {
109
+ ctx.ui.notify("正在让代理复述任务意图(只分析,不执行)...", "info");
110
+ const sentAt = Date.now();
111
+ pi.sendUserMessage(buildPreCheckPrompt(task, corrections), { deliverAs: "followUp" });
112
+
113
+ // 完成标志:代理空闲且本轮产生了新的 assistant 文本。
114
+ // 不依赖 waitForIdle —— 它可能在 followUp 触发的新 turn 开始前就返回。
115
+ const intent = await pollFor(
116
+ () => (ctx.isIdle() ? getLastAssistantTextAfter(ctx, sentAt) || undefined : undefined),
117
+ PRE_CHECK_TIMEOUT_MS,
118
+ );
119
+
120
+ if (!intent) {
121
+ const retry = await uiSelect(ctx, "未获取到代理的意图复述", ["重试", "取消"]);
122
+ if (retry === "重试") continue;
123
+ return undefined;
124
+ }
125
+
126
+ const decision = await uiSelect(ctx, "意图确认:以上复述是否准确反映你的目标?", [
127
+ "是 — 意图正确,开始执行",
128
+ "否 — 意图不正确,我要补充说明后重新分析",
129
+ "取消",
130
+ ]);
131
+
132
+ if (!decision || decision === "取消") return undefined;
133
+ if (decision.startsWith("是")) return intent;
134
+
135
+ const fix = await uiInput(
136
+ ctx,
137
+ "补充/修正说明",
138
+ "说明哪里理解错了、遗漏了什么",
139
+ false,
140
+ );
141
+ if (fix === undefined) return undefined;
142
+ corrections.push(fix.trim() || "(用户未提供具体说明,请重新检查复述)");
143
+ }
144
+ }
145
+
146
+ // ── 流程 ─────────────────────────────────────────────────────
147
+
148
+ async function runPreCheck(
149
+ pi: ExtensionAPI,
150
+ ctx: ExtensionCommandContext,
151
+ args: string,
152
+ ): Promise<void> {
153
+ if (!ctx.hasUI) {
154
+ ctx.ui.notify("/dev-pre-check 需要交互式界面才能确认意图", "error");
155
+ return;
156
+ }
157
+
158
+ let originalPrompt = args.trim();
159
+ if (!originalPrompt) {
160
+ const input = await uiInput(
161
+ ctx,
162
+ "待校验的需求(原始 prompt)",
163
+ "输入你希望 AI 先复述确认的需求原文",
164
+ true,
165
+ );
166
+ if (input === undefined) return;
167
+ originalPrompt = input.trim();
168
+ if (!originalPrompt) return;
169
+ }
170
+
171
+ const intent = await confirmIntent(originalPrompt, ctx, pi);
172
+ if (!intent) {
173
+ ctx.ui.notify("已取消,未执行任何改动", "info");
174
+ return;
175
+ }
176
+
177
+ pi.sendUserMessage(buildExecutionPrompt(originalPrompt, intent), { deliverAs: "followUp" });
178
+ ctx.ui.notify("意图已确认,开始执行", "info");
179
+ }
180
+
181
+ // ── Extension ────────────────────────────────────────────────
182
+
183
+ export default function (pi: ExtensionAPI) {
184
+ pi.registerCommand("dev-pre-check", {
185
+ description: "(pre-check) 先让 AI 复述任务意图并确认,确认正确后再开始真正的工作",
186
+ handler: async (args, ctx) => {
187
+ await runPreCheck(pi, ctx, args);
188
+ },
189
+ });
190
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Review Detection Extension
3
+ *
4
+ * 拦截带审查意图的输入,交给 review-html skill 在当前代理中执行:
5
+ * - 直接输入 `/skill:review-html` → 立即审查
6
+ * - 输入同时命中「审查意图」与「代码对象」关键词 → 弹选单让用户确认
7
+ *
8
+ * 审查报告由 review-html skill 写入 .pi-dev-output/pi-review/html/,
9
+ * 这里以报告文件出现作为完成标志(不使用 waitForIdle:它可能在 followUp
10
+ * 触发的新 turn 开始前就返回,导致误报完成)。
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { uiSelect } from "./ui-helpers";
17
+
18
+ /** Find the newest HTML review file generated after `afterMs`(忽略先前遗留的旧报告)。 */
19
+ function findNewestReviewHtml(cwd: string, afterMs: number): string {
20
+ const candidates = [
21
+ path.join(cwd, ".pi-dev-output", "pi-review", "html"),
22
+ path.join(cwd, "pi-review"),
23
+ path.join(cwd, ".pi-dev-output", "pi-review"),
24
+ ];
25
+
26
+ for (const reviewDir of candidates) {
27
+ try {
28
+ if (fs.existsSync(reviewDir)) {
29
+ const files = fs.readdirSync(reviewDir)
30
+ .filter(f => f.endsWith(".html"))
31
+ .map(f => ({
32
+ name: f,
33
+ mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs,
34
+ }))
35
+ .filter(f => f.mtime > afterMs)
36
+ .sort((a, b) => b.mtime - a.mtime);
37
+ if (files.length > 0) {
38
+ const rel = path.relative(cwd, reviewDir);
39
+ return rel + "/" + files[0].name;
40
+ }
41
+ }
42
+ } catch {
43
+ // ignore fs errors
44
+ }
45
+ }
46
+
47
+ return "";
48
+ }
49
+
50
+ /** Run a code review in the current agent and report the result. */
51
+ async function runReview(task: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise<string | undefined> {
52
+ const startTime = Date.now();
53
+ ctx.ui.notify("🤖 正在运行代码审查,请稍候...", "info");
54
+
55
+ pi.sendUserMessage(`/skill:review-html\n\n${task}`, {
56
+ deliverAs: "followUp",
57
+ expandPromptTemplates: true,
58
+ });
59
+
60
+ // 以“报告文件生成为完成标志”进行轮询;不使用 waitForIdle,
61
+ // 避免其在 sendUserMessage 触发的新 turn 开始前立即返回,导致误报“已完成”。
62
+ const deadline = Date.now() + 10 * 60_000;
63
+ let filePath = "";
64
+ while (Date.now() < deadline) {
65
+ filePath = findNewestReviewHtml(ctx.cwd, startTime);
66
+ if (filePath) break;
67
+ await new Promise((r) => setTimeout(r, 2_000));
68
+ }
69
+
70
+ const dur = ((Date.now() - startTime) / 1000).toFixed(1);
71
+ if (filePath) {
72
+ ctx.ui.notify(`📄 审查报告已生成: ${filePath} (${dur}s)`, "info");
73
+ } else {
74
+ ctx.ui.notify(`⚠️ 审查未生成报告 (${dur}s),请查看当前代理的回复或稍后重试`, "warning");
75
+ }
76
+ return filePath;
77
+ }
78
+
79
+ export default function (pi: ExtensionAPI) {
80
+ pi.on("input", async (event, ctx) => {
81
+ if (!ctx.hasUI) return { action: "continue" };
82
+ // 扩展注入的消息(sendUserMessage,source 为 "extension")不重入本处理器,避免无限递归
83
+ if (event.source === "extension") return { action: "continue" };
84
+
85
+ const text = event.text.trim().toLowerCase();
86
+
87
+ // Detect review-html skill invocation or explicit review request.
88
+ const isReviewSkill = text.startsWith("/skill:review-html");
89
+ const hasReviewIntent = text.includes("review") ||
90
+ text.includes("审查") || text.includes("审阅") || text.includes("review-html");
91
+ const hasCodeTarget = text.includes("code") || text.includes("代码") ||
92
+ text.includes("diff") || text.includes("commit") ||
93
+ text.includes("html") || text.includes("report") || text.includes("报告") ||
94
+ text.includes("本次改动") || text.includes("这次改动");
95
+ const isReviewRequest = !isReviewSkill && hasReviewIntent && hasCodeTarget;
96
+
97
+ if (!isReviewSkill && !isReviewRequest) return { action: "continue" };
98
+
99
+ // ── Skill invocation: run review directly ─────────────────
100
+ if (isReviewSkill) {
101
+ await runReview(event.text, ctx, pi);
102
+ return { action: "handled" };
103
+ }
104
+
105
+ // ── Review intent: let the user choose ────────────────────
106
+ const mode = await uiSelect(
107
+ ctx,
108
+ "🔍 检测到审查意图",
109
+ [
110
+ "1. 开始审查(阻塞,等待结果)",
111
+ "2. 不是审查(放行给主代理)",
112
+ ],
113
+ );
114
+
115
+ if (!mode || mode.startsWith("2")) {
116
+ return { action: "continue" };
117
+ }
118
+
119
+ await runReview(event.text, ctx, pi);
120
+
121
+ return { action: "handled" };
122
+ });
123
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * session-utils.ts — 跨扩展共享的会话与等待工具
3
+ *
4
+ * 职责:
5
+ * 1. pollFor() — 轮询等待当前代理的产物(文件/文本)出现,带超时
6
+ * 2. getLastAssistantTextAfter() — 提取指定时间点之后最后一条 assistant 文本
7
+ *
8
+ * 供 git-commands.ts / grill-me-agent.ts / dev-prompts.ts 等扩展共享使用。
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ /** 项目基础信息,用于生成默认验收标准与默认字段。 */
16
+ export interface ProjectDefaults {
17
+ language: string;
18
+ testCmd: string;
19
+ lintCmd: string;
20
+ packageManager: string;
21
+ hasPreCommit: boolean;
22
+ hasCI: boolean;
23
+ }
24
+
25
+ function safeReaddir(dir: string): string[] {
26
+ try {
27
+ return fs.readdirSync(dir);
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "coverage", ".pi-dev-output"]);
34
+
35
+ /** 浅层递归收集文件扩展名(深度 2),用于语言推断。 */
36
+ function scanExtensions(cwd: string, depth: number): string[] {
37
+ const exts = new Set<string>();
38
+ const walk = (dir: string, d: number): void => {
39
+ if (d > depth) return;
40
+ for (const name of safeReaddir(dir)) {
41
+ const full = path.join(dir, name);
42
+ let stat: fs.Stats | undefined;
43
+ try {
44
+ stat = fs.statSync(full);
45
+ } catch {
46
+ continue;
47
+ }
48
+ if (stat.isDirectory()) {
49
+ if (!SKIP_DIRS.has(name)) walk(full, d + 1);
50
+ } else {
51
+ const m = name.match(/\.([a-z0-9]+)$/i);
52
+ if (m) exts.add(m[1]!.toLowerCase());
53
+ }
54
+ }
55
+ };
56
+ walk(cwd, depth);
57
+ return [...exts];
58
+ }
59
+
60
+ /** 探测当前项目的语言/测试/lint 命令与工程规范(pre-commit、CI)。 */
61
+ export function detectProjectDefaults(cwd: string): ProjectDefaults {
62
+ const d: ProjectDefaults = {
63
+ language: "",
64
+ testCmd: "",
65
+ lintCmd: "",
66
+ packageManager: "npm",
67
+ hasPreCommit: false,
68
+ hasCI: false,
69
+ };
70
+ try {
71
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
72
+ const scripts = pkg.scripts ?? {};
73
+ const rawTest = scripts.test ?? "";
74
+ d.testCmd = /no test specified/.test(rawTest) ? "" : rawTest;
75
+ d.lintCmd = scripts.lint ?? scripts["lint:fix"] ?? scripts.format ?? "";
76
+ const pm = pkg.packageManager ?? "";
77
+ d.packageManager = /pnpm/.test(pm) ? "pnpm" : /yarn/.test(pm) ? "yarn" : "npm";
78
+ const meta = [pkg.name ?? "", Object.keys(pkg.dependencies ?? {}), Object.keys(pkg.devDependencies ?? {})].flat().join(" ").toLowerCase();
79
+ if (fs.existsSync(path.join(cwd, "tsconfig.json")) || /typescript|tsx/.test(meta)) d.language = "TypeScript";
80
+ else if (/javascript|next|react|vue|node|nest/.test(meta)) d.language = "JavaScript";
81
+ } catch { /* 非 Node 项目 */ }
82
+ if (!d.language) {
83
+ const files = safeReaddir(cwd);
84
+ const exts = scanExtensions(cwd, 2);
85
+ if (fs.existsSync(path.join(cwd, "Cargo.toml"))) d.language = "Rust";
86
+ else if (fs.existsSync(path.join(cwd, "go.mod"))) d.language = "Go";
87
+ else if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "requirements.txt"))) d.language = "Python";
88
+ else if (fs.existsSync(path.join(cwd, "pom.xml")) || fs.existsSync(path.join(cwd, "build.gradle"))) d.language = "Java";
89
+ else if (files.some((f) => f.endsWith(".csproj"))) d.language = "C#";
90
+ else if (exts.includes("rs")) d.language = "Rust";
91
+ else if (exts.includes("go")) d.language = "Go";
92
+ else if (exts.includes("py")) d.language = "Python";
93
+ else if (exts.includes("ts") || exts.includes("tsx")) d.language = "TypeScript";
94
+ else if (exts.some((e) => ["js", "mjs", "cjs", "jsx"].includes(e))) d.language = "JavaScript";
95
+ }
96
+ d.hasPreCommit = fs.existsSync(path.join(cwd, ".pre-commit-config.yaml")) || fs.existsSync(path.join(cwd, ".pre-commit-config.yml"));
97
+ d.hasCI = safeReaddir(path.join(cwd, ".github", "workflows")).some((f) => /\.ya?ml$/.test(f));
98
+ return d;
99
+ }
100
+
101
+ /** 根据项目探测结果生成常规验收标准条目(测试 + lint + pre-commit + CI)。 */
102
+ export function defaultAcceptanceItems(d: ProjectDefaults): string[] {
103
+ const items: string[] = [];
104
+ if (d.testCmd) items.push(`运行 ${d.testCmd} 确认全部测试通过、无回归`);
105
+ else items.push(`按 ${d.language || "项目"} 的常规测试方式编写并运行测试,确认核心逻辑正确、无回归`);
106
+ if (d.lintCmd) items.push(`运行 ${d.lintCmd} 符合代码规范`);
107
+ if (d.hasPreCommit) items.push("通过本地 pre-commit 钩子检查");
108
+ if (d.hasCI) items.push("通过 CI 检查");
109
+ return items;
110
+ }
111
+
112
+ /**
113
+ * 轮询直到回调返回非空/非 undefined,或超时。
114
+ * 用于替代 "sendUserMessage 后再 waitForIdle" 的不可靠时序:
115
+ * waitForIdle 可能在消息触发的新 turn 开始前立即返回,导致误判完成。
116
+ */
117
+ export async function pollFor<T>(poll: () => T | undefined | null, timeoutMs: number, intervalMs = 2_000): Promise<T | undefined> {
118
+ const deadline = Date.now() + timeoutMs;
119
+ while (Date.now() < deadline) {
120
+ const value = poll();
121
+ if (value !== undefined && value !== null && value !== "") return value;
122
+ await new Promise((r) => setTimeout(r, intervalMs));
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /**
128
+ * Extract the most recent assistant message text that arrived after a given moment.
129
+ * Used as fallback when the agent did not write the expected file.
130
+ */
131
+ export function getLastAssistantTextAfter(ctx: ExtensionCommandContext, afterMs: number): string {
132
+ try {
133
+ const entries = ctx.sessionManager.getEntries();
134
+ let text = "";
135
+ for (const entry of entries) {
136
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
137
+ const ts = new Date(entry.timestamp).getTime();
138
+ if (ts > afterMs) {
139
+ text = extractMessageText(entry.message.content) || text;
140
+ }
141
+ }
142
+ return text;
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+
148
+ function extractMessageText(content: unknown): string {
149
+ if (typeof content === "string") return content;
150
+ if (Array.isArray(content)) {
151
+ return content
152
+ .map((part) => {
153
+ if (typeof part === "string") return part;
154
+ if (part && typeof part === "object" && "type" in part && part.type === "text") {
155
+ return (part as { text: string }).text;
156
+ }
157
+ return "";
158
+ })
159
+ .join("\n");
160
+ }
161
+ return "";
162
+ }
163
+
164
+ // ── Extension factory (required by pi extension loader) ─────
165
+
166
+ export default function (_pi: ExtensionAPI) {
167
+ // session-utils is a helper module, imported by other extensions.
168
+ }