@tuan_son.dinh/gsd 2.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Remote Questions — Slack adapter
3
+ */
4
+
5
+ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
6
+ import { formatForSlack, parseSlackReply } from "./format.js";
7
+
8
+ const SLACK_API = "https://slack.com/api";
9
+ const PER_REQUEST_TIMEOUT_MS = 15_000;
10
+
11
+ export class SlackAdapter implements ChannelAdapter {
12
+ readonly name = "slack" as const;
13
+ private botUserId: string | null = null;
14
+ private readonly token: string;
15
+ private readonly channelId: string;
16
+
17
+ constructor(token: string, channelId: string) {
18
+ this.token = token;
19
+ this.channelId = channelId;
20
+ }
21
+
22
+ async validate(): Promise<void> {
23
+ const res = await this.slackApi("auth.test", {});
24
+ if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`);
25
+ this.botUserId = String(res.user_id ?? "");
26
+ }
27
+
28
+ async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
29
+ const res = await this.slackApi("chat.postMessage", {
30
+ channel: this.channelId,
31
+ text: "GSD needs your input",
32
+ blocks: formatForSlack(prompt),
33
+ });
34
+
35
+ if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`);
36
+
37
+ const ts = String(res.ts);
38
+ const channel = String(res.channel);
39
+ return {
40
+ ref: {
41
+ id: prompt.id,
42
+ channel: "slack",
43
+ messageId: ts,
44
+ threadTs: ts,
45
+ channelId: channel,
46
+ threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`,
47
+ },
48
+ };
49
+ }
50
+
51
+ async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
52
+ if (!this.botUserId) await this.validate();
53
+
54
+ const res = await this.slackApi("conversations.replies", {
55
+ channel: ref.channelId,
56
+ ts: ref.threadTs!,
57
+ limit: "20",
58
+ });
59
+
60
+ if (!res.ok) return null;
61
+
62
+ const messages = (res.messages ?? []) as Array<{ user?: string; text?: string; ts: string }>;
63
+ const userReplies = messages.filter((m) => m.ts !== ref.threadTs && m.user && m.user !== this.botUserId && m.text);
64
+ if (userReplies.length === 0) return null;
65
+
66
+ return parseSlackReply(String(userReplies[0].text), prompt.questions);
67
+ }
68
+
69
+ private async slackApi(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
70
+ const url = `${SLACK_API}/${method}`;
71
+ const isGet = method === "conversations.replies" || method === "auth.test";
72
+
73
+ let response: Response;
74
+ if (isGet) {
75
+ const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString();
76
+ response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) });
77
+ } else {
78
+ response = await fetch(url, {
79
+ method: "POST",
80
+ headers: {
81
+ Authorization: `Bearer ${this.token}`,
82
+ "Content-Type": "application/json; charset=utf-8",
83
+ },
84
+ body: JSON.stringify(params),
85
+ signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS),
86
+ });
87
+ }
88
+
89
+ if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`);
90
+ return (await response.json()) as Record<string, unknown>;
91
+ }
92
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Remote Questions — status helpers
3
+ */
4
+
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { readPromptRecord } from "./store.js";
9
+
10
+ export interface LatestPromptSummary {
11
+ id: string;
12
+ status: string;
13
+ updatedAt: number;
14
+ }
15
+
16
+ export function getLatestPromptSummary(): LatestPromptSummary | null {
17
+ const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions");
18
+ if (!existsSync(runtimeDir)) return null;
19
+ const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json"));
20
+ if (files.length === 0) return null;
21
+
22
+ let latest: LatestPromptSummary | null = null;
23
+ for (const file of files) {
24
+ const record = readPromptRecord(file.replace(/\.json$/, ""));
25
+ if (!record) continue;
26
+ if (!latest || record.updatedAt > latest.updatedAt) {
27
+ latest = { id: record.id, status: record.status, updatedAt: record.updatedAt };
28
+ }
29
+ }
30
+ return latest;
31
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Remote Questions — durable prompt store
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js";
9
+
10
+ function runtimeDir(): string {
11
+ return join(homedir(), ".gsd", "runtime", "remote-questions");
12
+ }
13
+
14
+ function recordPath(id: string): string {
15
+ return join(runtimeDir(), `${id}.json`);
16
+ }
17
+
18
+ export function createPromptRecord(prompt: RemotePrompt): RemotePromptRecord {
19
+ return {
20
+ version: 1,
21
+ id: prompt.id,
22
+ createdAt: prompt.createdAt,
23
+ updatedAt: Date.now(),
24
+ status: "pending",
25
+ channel: prompt.channel,
26
+ timeoutAt: prompt.timeoutAt,
27
+ pollIntervalMs: prompt.pollIntervalMs,
28
+ questions: prompt.questions,
29
+ context: prompt.context,
30
+ };
31
+ }
32
+
33
+ export function writePromptRecord(record: RemotePromptRecord): void {
34
+ mkdirSync(runtimeDir(), { recursive: true });
35
+ writeFileSync(recordPath(record.id), JSON.stringify(record, null, 2) + "\n", "utf-8");
36
+ }
37
+
38
+ export function readPromptRecord(id: string): RemotePromptRecord | null {
39
+ const path = recordPath(id);
40
+ if (!existsSync(path)) return null;
41
+ try {
42
+ return JSON.parse(readFileSync(path, "utf-8")) as RemotePromptRecord;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function updatePromptRecord(
49
+ id: string,
50
+ updates: Partial<RemotePromptRecord>,
51
+ ): RemotePromptRecord | null {
52
+ const current = readPromptRecord(id);
53
+ if (!current) return null;
54
+ const next: RemotePromptRecord = {
55
+ ...current,
56
+ ...updates,
57
+ updatedAt: Date.now(),
58
+ };
59
+ writePromptRecord(next);
60
+ return next;
61
+ }
62
+
63
+ export function markPromptDispatched(id: string, ref: RemotePromptRef): RemotePromptRecord | null {
64
+ return updatePromptRecord(id, { ref, status: "pending" });
65
+ }
66
+
67
+ export function markPromptAnswered(id: string, response: RemoteAnswer): RemotePromptRecord | null {
68
+ return updatePromptRecord(id, { response, status: "answered", lastPollAt: Date.now() });
69
+ }
70
+
71
+ export function markPromptStatus(id: string, status: RemotePromptStatus, lastError?: string): RemotePromptRecord | null {
72
+ return updatePromptRecord(id, {
73
+ status,
74
+ lastPollAt: Date.now(),
75
+ ...(lastError ? { lastError } : {}),
76
+ });
77
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Remote Questions — shared types
3
+ */
4
+
5
+ export type RemoteChannel = "slack" | "discord";
6
+
7
+ export interface RemoteQuestionOption {
8
+ label: string;
9
+ description: string;
10
+ }
11
+
12
+ export interface RemoteQuestion {
13
+ id: string;
14
+ header: string;
15
+ question: string;
16
+ options: RemoteQuestionOption[];
17
+ allowMultiple: boolean;
18
+ }
19
+
20
+ export interface RemotePrompt {
21
+ id: string;
22
+ channel: RemoteChannel;
23
+ createdAt: number;
24
+ timeoutAt: number;
25
+ pollIntervalMs: number;
26
+ questions: RemoteQuestion[];
27
+ context?: {
28
+ source: string;
29
+ };
30
+ }
31
+
32
+ export interface RemotePromptRef {
33
+ id: string;
34
+ channel: RemoteChannel;
35
+ messageId: string;
36
+ channelId: string;
37
+ threadTs?: string;
38
+ threadUrl?: string;
39
+ }
40
+
41
+ export interface RemoteAnswer {
42
+ answers: Record<string, { answers: string[]; user_note?: string }>;
43
+ }
44
+
45
+ export type RemotePromptStatus = "pending" | "answered" | "timed_out" | "failed" | "cancelled";
46
+
47
+ export interface RemotePromptRecord {
48
+ version: 1;
49
+ id: string;
50
+ createdAt: number;
51
+ updatedAt: number;
52
+ status: RemotePromptStatus;
53
+ channel: RemoteChannel;
54
+ timeoutAt: number;
55
+ pollIntervalMs: number;
56
+ questions: RemoteQuestion[];
57
+ ref?: RemotePromptRef;
58
+ response?: RemoteAnswer;
59
+ lastPollAt?: number;
60
+ lastError?: string;
61
+ context?: {
62
+ source: string;
63
+ };
64
+ }
65
+
66
+ export interface RemoteDispatchResult {
67
+ ref: RemotePromptRef;
68
+ }
69
+
70
+ export interface ChannelAdapter {
71
+ readonly name: RemoteChannel;
72
+ validate(): Promise<void>;
73
+ sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
74
+ pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
75
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * LRU cache with TTL — zero external dependencies.
3
+ *
4
+ * - max: maximum entries before oldest is evicted
5
+ * - ttlMs: time-to-live per entry
6
+ *
7
+ * Uses a Map (insertion-ordered) for O(1) LRU eviction:
8
+ * on every access the entry is deleted and re-inserted at the tail.
9
+ */
10
+ export class LRUTTLCache<V> {
11
+ private readonly max: number;
12
+ private readonly ttlMs: number;
13
+ private readonly store = new Map<string, { value: V; expiresAt: number }>();
14
+ private purgeTimer: ReturnType<typeof setInterval> | null = null;
15
+
16
+ constructor(options: { max: number; ttlMs: number }) {
17
+ this.max = options.max;
18
+ this.ttlMs = options.ttlMs;
19
+ }
20
+
21
+ get(key: string): V | undefined {
22
+ const entry = this.store.get(key);
23
+ if (!entry) return undefined;
24
+ if (Date.now() > entry.expiresAt) {
25
+ this.store.delete(key);
26
+ return undefined;
27
+ }
28
+ // Refresh to tail (most-recently-used)
29
+ this.store.delete(key);
30
+ this.store.set(key, entry);
31
+ return entry.value;
32
+ }
33
+
34
+ set(key: string, value: V): void {
35
+ if (this.store.has(key)) {
36
+ this.store.delete(key);
37
+ } else if (this.store.size >= this.max) {
38
+ const oldest = this.store.keys().next().value;
39
+ if (oldest !== undefined) this.store.delete(oldest);
40
+ }
41
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
42
+ }
43
+
44
+ has(key: string): boolean {
45
+ return this.get(key) !== undefined;
46
+ }
47
+
48
+ purgeStale(): void {
49
+ const now = Date.now();
50
+ for (const [key, entry] of this.store) {
51
+ if (now > entry.expiresAt) this.store.delete(key);
52
+ }
53
+ }
54
+
55
+ startPurgeInterval(intervalMs: number): void {
56
+ if (this.purgeTimer !== null) return;
57
+ this.purgeTimer = setInterval(() => this.purgeStale(), intervalMs);
58
+ // Don't keep the process alive just for cache cleanup
59
+ if (this.purgeTimer && typeof this.purgeTimer === "object" && "unref" in this.purgeTimer) {
60
+ (this.purgeTimer as NodeJS.Timeout).unref();
61
+ }
62
+ }
63
+
64
+ stopPurgeInterval(): void {
65
+ if (this.purgeTimer !== null) {
66
+ clearInterval(this.purgeTimer);
67
+ this.purgeTimer = null;
68
+ }
69
+ }
70
+
71
+ clear(): void {
72
+ this.store.clear();
73
+ }
74
+
75
+ get size(): number {
76
+ return this.store.size;
77
+ }
78
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * /search-provider slash command.
3
+ *
4
+ * Lets users switch between tavily, brave, and auto search backends.
5
+ * Supports direct arg (`/search-provider tavily`) or interactive select UI.
6
+ * Tab completion provides the three valid options with key status.
7
+ *
8
+ * All provider logic lives in provider.ts (S01) — this is pure UI wiring.
9
+ */
10
+
11
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'
12
+ import type { AutocompleteItem } from '@mariozechner/pi-tui'
13
+ import {
14
+ getTavilyApiKey,
15
+ getBraveApiKey,
16
+ getSearchProviderPreference,
17
+ setSearchProviderPreference,
18
+ resolveSearchProvider,
19
+ type SearchProviderPreference,
20
+ } from './provider.ts'
21
+
22
+ const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'auto']
23
+
24
+ function keyStatus(provider: 'tavily' | 'brave'): string {
25
+ if (provider === 'tavily') return getTavilyApiKey() ? '✓' : '✗'
26
+ return getBraveApiKey() ? '✓' : '✗'
27
+ }
28
+
29
+ function buildSelectOptions(): string[] {
30
+ return [
31
+ `tavily (key: ${keyStatus('tavily')})`,
32
+ `brave (key: ${keyStatus('brave')})`,
33
+ `auto`,
34
+ ]
35
+ }
36
+
37
+ function parseSelectChoice(choice: string): SearchProviderPreference {
38
+ if (choice.startsWith('tavily')) return 'tavily'
39
+ if (choice.startsWith('brave')) return 'brave'
40
+ return 'auto'
41
+ }
42
+
43
+ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
44
+ pi.registerCommand('search-provider', {
45
+ description: 'Switch search provider (tavily, brave, auto)',
46
+
47
+ getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
48
+ const trimmed = prefix.trim().toLowerCase()
49
+ return VALID_PREFERENCES
50
+ .filter((p) => p.startsWith(trimmed))
51
+ .map((p) => {
52
+ let description: string
53
+ if (p === 'auto') {
54
+ description = `Auto-select (tavily: ${keyStatus('tavily')}, brave: ${keyStatus('brave')})`
55
+ } else {
56
+ description = `key: ${keyStatus(p)}`
57
+ }
58
+ return { value: p, label: p, description }
59
+ })
60
+ },
61
+
62
+ async handler(args, ctx) {
63
+ const trimmed = args.trim().toLowerCase()
64
+
65
+ let chosen: SearchProviderPreference
66
+
67
+ if (trimmed && (VALID_PREFERENCES as string[]).includes(trimmed)) {
68
+ // Direct arg — apply immediately, no select UI
69
+ chosen = trimmed as SearchProviderPreference
70
+ } else {
71
+ // No arg or invalid arg — show interactive select
72
+ const current = getSearchProviderPreference()
73
+ const options = buildSelectOptions()
74
+ const result = await ctx.ui.select(
75
+ `Search provider (current: ${current})`,
76
+ options,
77
+ )
78
+
79
+ if (result === undefined) {
80
+ // User cancelled — bail silently
81
+ return
82
+ }
83
+
84
+ chosen = parseSelectChoice(result)
85
+ }
86
+
87
+ setSearchProviderPreference(chosen)
88
+ const effective = resolveSearchProvider()
89
+ ctx.ui.notify(
90
+ `Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}`,
91
+ 'info',
92
+ )
93
+ },
94
+ })
95
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Token-efficient output formatting for search results, page content,
3
+ * and LLM context responses.
4
+ */
5
+
6
+ import { extractDomain } from "./url-utils";
7
+
8
+ export interface SearchResultFormatted {
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ age?: string;
13
+ extra_snippets?: string[];
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ // =============================================================================
18
+ // Adaptive Snippet Budget
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Compute how many extra_snippets to show per result based on total count.
23
+ * Fewer results → more snippets each. More results → fewer snippets each.
24
+ *
25
+ * This keeps total output roughly constant regardless of result count.
26
+ */
27
+ function snippetsPerResult(resultCount: number): number {
28
+ if (resultCount <= 2) return 5; // show all available
29
+ if (resultCount <= 4) return 3;
30
+ if (resultCount <= 6) return 2;
31
+ if (resultCount <= 8) return 1;
32
+ return 0; // 9-10 results: descriptions only
33
+ }
34
+
35
+ // =============================================================================
36
+ // Search Results Formatting
37
+ // =============================================================================
38
+
39
+ export interface FormatSearchOptions {
40
+ cached?: boolean;
41
+ summary?: string;
42
+ queryCorrected?: boolean;
43
+ originalQuery?: string;
44
+ correctedQuery?: string;
45
+ moreResultsAvailable?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Format search results in a compact, token-efficient format.
50
+ *
51
+ * Produces:
52
+ * [1] Python Web Frameworks — example.com (2024-11)
53
+ * Main snippet text...
54
+ * + "additional excerpt 1"
55
+ * + "additional excerpt 2"
56
+ *
57
+ * Snippet count per result adapts to total result count.
58
+ */
59
+ export function formatSearchResults(
60
+ query: string,
61
+ results: SearchResultFormatted[],
62
+ options: FormatSearchOptions = {}
63
+ ): string {
64
+ const parts: string[] = [];
65
+
66
+ // Header
67
+ const cacheTag = options.cached ? " (cached)" : "";
68
+ parts.push(`Search: "${query}"${cacheTag}`);
69
+
70
+ // Spellcheck/query correction notice
71
+ if (options.queryCorrected && options.correctedQuery) {
72
+ parts.push(`Note: Query was corrected to "${options.correctedQuery}" (original: "${options.originalQuery ?? query}")`);
73
+ }
74
+
75
+ parts.push(""); // blank line after header
76
+
77
+ // AI summary block if available (from Brave Summarizer)
78
+ if (options.summary) {
79
+ parts.push(`Summary: ${options.summary}\n`);
80
+ }
81
+
82
+ if (results.length === 0) {
83
+ parts.push("No results found.");
84
+ return parts.join("\n");
85
+ }
86
+
87
+ const maxSnippets = snippetsPerResult(results.length);
88
+
89
+ // Results
90
+ for (let i = 0; i < results.length; i++) {
91
+ const r = results[i];
92
+ const domain = extractDomain(r.url);
93
+ const age = r.age ? ` (${r.age})` : "";
94
+
95
+ // Compact header line: [N] Title — domain (age)
96
+ parts.push(`[${i + 1}] ${r.title} — ${domain}${age}`);
97
+ parts.push(r.url);
98
+
99
+ // Primary description
100
+ if (r.description) {
101
+ parts.push(r.description);
102
+ }
103
+
104
+ // Extra snippets — adaptive count based on total results
105
+ if (maxSnippets > 0 && r.extra_snippets && r.extra_snippets.length > 0) {
106
+ for (const snippet of r.extra_snippets.slice(0, maxSnippets)) {
107
+ const clean = snippet.replace(/\n/g, " ").trim();
108
+ if (clean) parts.push(`+ ${clean}`);
109
+ }
110
+ }
111
+
112
+ parts.push(""); // blank line between results
113
+ }
114
+
115
+ // Pagination hint
116
+ if (options.moreResultsAvailable) {
117
+ parts.push("[More results available — increase count or refine query]");
118
+ }
119
+
120
+ return parts.join("\n");
121
+ }
122
+
123
+ // =============================================================================
124
+ // Page Content Formatting
125
+ // =============================================================================
126
+
127
+ export interface FormatPageOptions {
128
+ title?: string;
129
+ charCount: number;
130
+ truncated: boolean;
131
+ originalChars?: number;
132
+ hasMore?: boolean;
133
+ nextOffset?: number;
134
+ }
135
+
136
+ /**
137
+ * Format extracted page content with metadata header.
138
+ */
139
+ export function formatPageContent(
140
+ url: string,
141
+ content: string,
142
+ options: FormatPageOptions
143
+ ): string {
144
+ const domain = extractDomain(url);
145
+ const title = options.title ? ` — ${options.title}` : "";
146
+ const truncNote = options.truncated && options.originalChars
147
+ ? ` [truncated from ${options.originalChars.toLocaleString()} chars]`
148
+ : "";
149
+ const moreNote = options.hasMore && options.nextOffset
150
+ ? ` [use offset:${options.nextOffset} to continue reading]`
151
+ : "";
152
+
153
+ const header = `Page: ${domain}${title} (${options.charCount.toLocaleString()} chars)${truncNote}${moreNote}\n${url}\n---`;
154
+
155
+ return `${header}\n${content}`;
156
+ }
157
+
158
+ // =============================================================================
159
+ // LLM Context Formatting
160
+ // =============================================================================
161
+
162
+ export interface LLMContextSnippet {
163
+ url: string;
164
+ title: string;
165
+ snippets: string[];
166
+ }
167
+
168
+ export interface LLMContextSource {
169
+ title: string;
170
+ hostname: string;
171
+ age: string[] | null;
172
+ }
173
+
174
+ /**
175
+ * Format LLM Context API response in a compact, agent-optimized format.
176
+ *
177
+ * Output:
178
+ * Context: "query" (N sources, ~Mk tokens)
179
+ *
180
+ * [1] Page Title — domain.com (age)
181
+ * url
182
+ * Snippet text...
183
+ * ---
184
+ * Another snippet...
185
+ */
186
+ export function formatLLMContext(
187
+ query: string,
188
+ grounding: LLMContextSnippet[],
189
+ sources: Record<string, LLMContextSource>,
190
+ options: { cached?: boolean; tokenCount?: number } = {}
191
+ ): string {
192
+ const parts: string[] = [];
193
+
194
+ const cacheTag = options.cached ? " (cached)" : "";
195
+ const tokenTag = options.tokenCount ? ` (~${Math.round(options.tokenCount / 1000)}k tokens)` : "";
196
+ parts.push(`Context: "${query}" (${grounding.length} sources${tokenTag})${cacheTag}`);
197
+ parts.push("");
198
+
199
+ if (grounding.length === 0) {
200
+ parts.push("No relevant content found.");
201
+ return parts.join("\n");
202
+ }
203
+
204
+ for (let i = 0; i < grounding.length; i++) {
205
+ const g = grounding[i];
206
+ const source = sources[g.url];
207
+ const domain = source?.hostname || extractDomain(g.url);
208
+ const age = source?.age?.[2] ? ` (${source.age[2]})` : ""; // [2] is "N days ago" format
209
+
210
+ parts.push(`[${i + 1}] ${g.title || source?.title || "(untitled)"} — ${domain}${age}`);
211
+ parts.push(g.url);
212
+
213
+ // Join snippets with separator
214
+ for (const snippet of g.snippets) {
215
+ const clean = snippet.trim();
216
+ if (clean) parts.push(clean);
217
+ }
218
+
219
+ parts.push(""); // blank line between sources
220
+ }
221
+
222
+ return parts.join("\n");
223
+ }
224
+
225
+ // =============================================================================
226
+ // Multi-Page Formatting
227
+ // =============================================================================
228
+
229
+ /**
230
+ * Format multiple page extractions compactly.
231
+ */
232
+ export function formatMultiplePages(
233
+ pages: Array<{
234
+ url: string;
235
+ title?: string;
236
+ content: string;
237
+ charCount: number;
238
+ error?: string;
239
+ }>
240
+ ): string {
241
+ const parts: string[] = [];
242
+
243
+ for (const page of pages) {
244
+ const domain = extractDomain(page.url);
245
+ if (page.error) {
246
+ parts.push(`[✗] ${domain}: ${page.error}`);
247
+ } else {
248
+ const title = page.title ? ` — ${page.title}` : "";
249
+ parts.push(`[✓] ${domain}${title} (${page.charCount.toLocaleString()} chars)`);
250
+ parts.push(page.url);
251
+ parts.push("---");
252
+ parts.push(page.content);
253
+ }
254
+ parts.push(""); // separator
255
+ }
256
+
257
+ return parts.join("\n");
258
+ }