@oh-my-pi/pi-coding-agent 1.337.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.
- package/CHANGELOG.md +1228 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +637 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +81 -0
- package/src/cli/args.ts +246 -0
- package/src/cli/file-processor.ts +72 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +650 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli.ts +10 -0
- package/src/commands/init.md +20 -0
- package/src/config.ts +159 -0
- package/src/core/agent-session.ts +1900 -0
- package/src/core/auth-storage.ts +236 -0
- package/src/core/bash-executor.ts +196 -0
- package/src/core/compaction/branch-summarization.ts +343 -0
- package/src/core/compaction/compaction.ts +742 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +154 -0
- package/src/core/custom-tools/index.ts +21 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +169 -0
- package/src/core/custom-tools/wrapper.ts +28 -0
- package/src/core/exec.ts +129 -0
- package/src/core/export-html/index.ts +211 -0
- package/src/core/export-html/template.css +781 -0
- package/src/core/export-html/template.html +54 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +312 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +99 -0
- package/src/core/hooks/types.ts +773 -0
- package/src/core/index.ts +52 -0
- package/src/core/mcp/client.ts +158 -0
- package/src/core/mcp/config.ts +154 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +68 -0
- package/src/core/mcp/manager.ts +181 -0
- package/src/core/mcp/tool-bridge.ts +148 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +220 -0
- package/src/core/messages.ts +189 -0
- package/src/core/model-registry.ts +317 -0
- package/src/core/model-resolver.ts +393 -0
- package/src/core/plugins/doctor.ts +59 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +338 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +32 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +760 -0
- package/src/core/session-manager.ts +1128 -0
- package/src/core/settings-manager.ts +443 -0
- package/src/core/skills.ts +437 -0
- package/src/core/slash-commands.ts +248 -0
- package/src/core/system-prompt.ts +439 -0
- package/src/core/timings.ts +25 -0
- package/src/core/tools/ask.ts +211 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +250 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +475 -0
- package/src/core/tools/edit.ts +208 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +64 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/logger.ts +56 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +196 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +337 -0
- package/src/core/tools/exa/types.ts +168 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +261 -0
- package/src/core/tools/grep.ts +555 -0
- package/src/core/tools/index.ts +202 -0
- package/src/core/tools/ls.ts +140 -0
- package/src/core/tools/lsp/client.ts +605 -0
- package/src/core/tools/lsp/config.ts +147 -0
- package/src/core/tools/lsp/edits.ts +101 -0
- package/src/core/tools/lsp/index.ts +804 -0
- package/src/core/tools/lsp/render.ts +447 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +463 -0
- package/src/core/tools/lsp/utils.ts +486 -0
- package/src/core/tools/notebook.ts +229 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +240 -0
- package/src/core/tools/renderers.ts +540 -0
- package/src/core/tools/task/agents.ts +153 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/bundled-agents/browser.md +71 -0
- package/src/core/tools/task/bundled-agents/explore.md +82 -0
- package/src/core/tools/task/bundled-agents/plan.md +54 -0
- package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
- package/src/core/tools/task/bundled-agents/task.md +53 -0
- package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
- package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
- package/src/core/tools/task/bundled-commands/implement.md +11 -0
- package/src/core/tools/task/commands.ts +213 -0
- package/src/core/tools/task/discovery.ts +208 -0
- package/src/core/tools/task/executor.ts +367 -0
- package/src/core/tools/task/index.ts +388 -0
- package/src/core/tools/task/model-resolver.ts +115 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +232 -0
- package/src/core/tools/task/types.ts +99 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2370 -0
- package/src/core/tools/web-search/auth.ts +193 -0
- package/src/core/tools/web-search/index.ts +537 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +302 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +182 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +99 -0
- package/src/index.ts +176 -0
- package/src/main.ts +464 -0
- package/src/migrations.ts +135 -0
- package/src/modes/index.ts +43 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +196 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/footer.ts +381 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +247 -0
- package/src/modes/interactive/components/oauth-selector.ts +120 -0
- package/src/modes/interactive/components/plugin-settings.ts +479 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +204 -0
- package/src/modes/interactive/components/settings-selector.ts +453 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +675 -0
- package/src/modes/interactive/components/tree-selector.ts +866 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +183 -0
- package/src/modes/interactive/interactive-mode.ts +2516 -0
- package/src/modes/interactive/theme/dark.json +101 -0
- package/src/modes/interactive/theme/light.json +98 -0
- package/src/modes/interactive/theme/theme-schema.json +308 -0
- package/src/modes/interactive/theme/theme.ts +998 -0
- package/src/modes/print-mode.ts +128 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +483 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell.ts +276 -0
- package/src/utils/tools-manager.ts +274 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff hook - transfer context to a new focused session
|
|
3
|
+
*
|
|
4
|
+
* Instead of compacting (which is lossy), handoff extracts what matters
|
|
5
|
+
* for your next task and creates a new session with a generated prompt.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* /handoff now implement this for teams as well
|
|
9
|
+
* /handoff execute phase one of the plan
|
|
10
|
+
* /handoff check other places that need this fix
|
|
11
|
+
*
|
|
12
|
+
* The generated prompt appears as a draft in the editor for review/editing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { complete, type Message } from "@oh-my-pi/pi-ai";
|
|
16
|
+
import type { HookAPI, SessionEntry } from "@oh-my-pi/pi-coding-agent";
|
|
17
|
+
import { BorderedLoader, convertToLlm, serializeConversation } from "@oh-my-pi/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
|
|
20
|
+
|
|
21
|
+
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
|
|
22
|
+
2. Lists any relevant files that were discussed or modified
|
|
23
|
+
3. Clearly states the next task based on the user's goal
|
|
24
|
+
4. Is self-contained - the new thread should be able to proceed without the old conversation
|
|
25
|
+
|
|
26
|
+
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
|
|
27
|
+
|
|
28
|
+
Example output format:
|
|
29
|
+
## Context
|
|
30
|
+
We've been working on X. Key decisions:
|
|
31
|
+
- Decision 1
|
|
32
|
+
- Decision 2
|
|
33
|
+
|
|
34
|
+
Files involved:
|
|
35
|
+
- path/to/file1.ts
|
|
36
|
+
- path/to/file2.ts
|
|
37
|
+
|
|
38
|
+
## Task
|
|
39
|
+
[Clear description of what to do next based on user's goal]`;
|
|
40
|
+
|
|
41
|
+
export default function (pi: HookAPI) {
|
|
42
|
+
pi.registerCommand("handoff", {
|
|
43
|
+
description: "Transfer context to a new focused session",
|
|
44
|
+
handler: async (args, ctx) => {
|
|
45
|
+
if (!ctx.hasUI) {
|
|
46
|
+
ctx.ui.notify("handoff requires interactive mode", "error");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!ctx.model) {
|
|
51
|
+
ctx.ui.notify("No model selected", "error");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const goal = args.trim();
|
|
56
|
+
if (!goal) {
|
|
57
|
+
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Gather conversation context from current branch
|
|
62
|
+
const branch = ctx.sessionManager.getBranch();
|
|
63
|
+
const messages = branch
|
|
64
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
65
|
+
.map((entry) => entry.message);
|
|
66
|
+
|
|
67
|
+
if (messages.length === 0) {
|
|
68
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Convert to LLM format and serialize
|
|
73
|
+
const llmMessages = convertToLlm(messages);
|
|
74
|
+
const conversationText = serializeConversation(llmMessages);
|
|
75
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
76
|
+
|
|
77
|
+
// Generate the handoff prompt with loader UI
|
|
78
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
|
79
|
+
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
|
80
|
+
loader.onAbort = () => done(null);
|
|
81
|
+
|
|
82
|
+
const doGenerate = async () => {
|
|
83
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
|
84
|
+
|
|
85
|
+
const userMessage: Message = {
|
|
86
|
+
role: "user",
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const response = await complete(
|
|
97
|
+
ctx.model!,
|
|
98
|
+
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
99
|
+
{ apiKey, signal: loader.signal },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (response.stopReason === "aborted") {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return response.content
|
|
107
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
108
|
+
.map((c) => c.text)
|
|
109
|
+
.join("\n");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
doGenerate()
|
|
113
|
+
.then(done)
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
console.error("Handoff generation failed:", err);
|
|
116
|
+
done(null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return loader;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result === null) {
|
|
123
|
+
ctx.ui.notify("Cancelled", "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Let user edit the generated prompt
|
|
128
|
+
const editedPrompt = await ctx.ui.editor("Edit handoff prompt (ctrl+enter to submit, esc to cancel)", result);
|
|
129
|
+
|
|
130
|
+
if (editedPrompt === undefined) {
|
|
131
|
+
ctx.ui.notify("Cancelled", "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Create new session with parent tracking
|
|
136
|
+
const newSessionResult = await ctx.newSession({
|
|
137
|
+
parentSession: currentSessionFile,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (newSessionResult.cancelled) {
|
|
141
|
+
ctx.ui.notify("New session cancelled", "info");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Set the edited prompt in the main editor for submission
|
|
146
|
+
ctx.ui.setEditorText(editedPrompt);
|
|
147
|
+
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Gate Hook
|
|
3
|
+
*
|
|
4
|
+
* Prompts for confirmation before running potentially dangerous bash commands.
|
|
5
|
+
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export default function (pi: HookAPI) {
|
|
11
|
+
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
|
12
|
+
|
|
13
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
14
|
+
if (event.toolName !== "bash") return undefined;
|
|
15
|
+
|
|
16
|
+
const command = event.input.command as string;
|
|
17
|
+
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
|
18
|
+
|
|
19
|
+
if (isDangerous) {
|
|
20
|
+
if (!ctx.hasUI) {
|
|
21
|
+
// In non-interactive mode, block by default
|
|
22
|
+
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
|
26
|
+
|
|
27
|
+
if (choice !== "Yes") {
|
|
28
|
+
return { block: true, reason: "Blocked by user" };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return undefined;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protected Paths Hook
|
|
3
|
+
*
|
|
4
|
+
* Blocks write and edit operations to protected paths.
|
|
5
|
+
* Useful for preventing accidental modifications to sensitive files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export default function (pi: HookAPI) {
|
|
11
|
+
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
12
|
+
|
|
13
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
14
|
+
if (event.toolName !== "write" && event.toolName !== "edit") {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const path = event.input.path as string;
|
|
19
|
+
const isProtected = protectedPaths.some((p) => path.includes(p));
|
|
20
|
+
|
|
21
|
+
if (isProtected) {
|
|
22
|
+
if (ctx.hasUI) {
|
|
23
|
+
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
|
24
|
+
}
|
|
25
|
+
return { block: true, reason: `Path "${path}" is protected` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return undefined;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Q&A extraction hook - extracts questions from assistant responses
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the "prompt generator" pattern:
|
|
5
|
+
* 1. /qna command gets the last assistant message
|
|
6
|
+
* 2. Shows a spinner while extracting (hides editor)
|
|
7
|
+
* 3. Loads the result into the editor for user to fill in answers
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { complete, type UserMessage } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
|
|
12
|
+
import { BorderedLoader } from "@oh-my-pi/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
|
15
|
+
|
|
16
|
+
Output format:
|
|
17
|
+
- List each question on its own line, prefixed with "Q: "
|
|
18
|
+
- After each question, add a blank line for the answer prefixed with "A: "
|
|
19
|
+
- If no questions are found, output "No questions found in the last message."
|
|
20
|
+
|
|
21
|
+
Example output:
|
|
22
|
+
Q: What is your preferred database?
|
|
23
|
+
A:
|
|
24
|
+
|
|
25
|
+
Q: Should we use TypeScript or JavaScript?
|
|
26
|
+
A:
|
|
27
|
+
|
|
28
|
+
Keep questions in the order they appeared. Be concise.`;
|
|
29
|
+
|
|
30
|
+
export default function (pi: HookAPI) {
|
|
31
|
+
pi.registerCommand("qna", {
|
|
32
|
+
description: "Extract questions from last assistant message into editor",
|
|
33
|
+
handler: async (_args, ctx) => {
|
|
34
|
+
if (!ctx.hasUI) {
|
|
35
|
+
ctx.ui.notify("qna requires interactive mode", "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!ctx.model) {
|
|
40
|
+
ctx.ui.notify("No model selected", "error");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find the last assistant message on the current branch
|
|
45
|
+
const branch = ctx.sessionManager.getBranch();
|
|
46
|
+
let lastAssistantText: string | undefined;
|
|
47
|
+
|
|
48
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
49
|
+
const entry = branch[i];
|
|
50
|
+
if (entry.type === "message") {
|
|
51
|
+
const msg = entry.message;
|
|
52
|
+
if ("role" in msg && msg.role === "assistant") {
|
|
53
|
+
if (msg.stopReason !== "stop") {
|
|
54
|
+
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const textParts = msg.content
|
|
58
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
59
|
+
.map((c) => c.text);
|
|
60
|
+
if (textParts.length > 0) {
|
|
61
|
+
lastAssistantText = textParts.join("\n");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!lastAssistantText) {
|
|
69
|
+
ctx.ui.notify("No assistant messages found", "error");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Run extraction with loader UI
|
|
74
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
|
75
|
+
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
|
76
|
+
loader.onAbort = () => done(null);
|
|
77
|
+
|
|
78
|
+
// Do the work
|
|
79
|
+
const doExtract = async () => {
|
|
80
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
|
81
|
+
const userMessage: UserMessage = {
|
|
82
|
+
role: "user",
|
|
83
|
+
content: [{ type: "text", text: lastAssistantText! }],
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const response = await complete(
|
|
88
|
+
ctx.model!,
|
|
89
|
+
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
90
|
+
{ apiKey, signal: loader.signal },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (response.stopReason === "aborted") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return response.content
|
|
98
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
99
|
+
.map((c) => c.text)
|
|
100
|
+
.join("\n");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
doExtract()
|
|
104
|
+
.then(done)
|
|
105
|
+
.catch(() => done(null));
|
|
106
|
+
|
|
107
|
+
return loader;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (result === null) {
|
|
111
|
+
ctx.ui.notify("Cancelled", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ctx.ui.setEditorText(result);
|
|
116
|
+
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snake game hook - play snake with /snake command
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
|
|
6
|
+
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
7
|
+
|
|
8
|
+
const GAME_WIDTH = 40;
|
|
9
|
+
const GAME_HEIGHT = 15;
|
|
10
|
+
const TICK_MS = 100;
|
|
11
|
+
|
|
12
|
+
type Direction = "up" | "down" | "left" | "right";
|
|
13
|
+
type Point = { x: number; y: number };
|
|
14
|
+
|
|
15
|
+
interface GameState {
|
|
16
|
+
snake: Point[];
|
|
17
|
+
food: Point;
|
|
18
|
+
direction: Direction;
|
|
19
|
+
nextDirection: Direction;
|
|
20
|
+
score: number;
|
|
21
|
+
gameOver: boolean;
|
|
22
|
+
highScore: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createInitialState(): GameState {
|
|
26
|
+
const startX = Math.floor(GAME_WIDTH / 2);
|
|
27
|
+
const startY = Math.floor(GAME_HEIGHT / 2);
|
|
28
|
+
return {
|
|
29
|
+
snake: [
|
|
30
|
+
{ x: startX, y: startY },
|
|
31
|
+
{ x: startX - 1, y: startY },
|
|
32
|
+
{ x: startX - 2, y: startY },
|
|
33
|
+
],
|
|
34
|
+
food: spawnFood([{ x: startX, y: startY }]),
|
|
35
|
+
direction: "right",
|
|
36
|
+
nextDirection: "right",
|
|
37
|
+
score: 0,
|
|
38
|
+
gameOver: false,
|
|
39
|
+
highScore: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function spawnFood(snake: Point[]): Point {
|
|
44
|
+
let food: Point;
|
|
45
|
+
do {
|
|
46
|
+
food = {
|
|
47
|
+
x: Math.floor(Math.random() * GAME_WIDTH),
|
|
48
|
+
y: Math.floor(Math.random() * GAME_HEIGHT),
|
|
49
|
+
};
|
|
50
|
+
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
|
51
|
+
return food;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class SnakeComponent {
|
|
55
|
+
private state: GameState;
|
|
56
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
57
|
+
private onClose: () => void;
|
|
58
|
+
private onSave: (state: GameState | null) => void;
|
|
59
|
+
private tui: { requestRender: () => void };
|
|
60
|
+
private cachedLines: string[] = [];
|
|
61
|
+
private cachedWidth = 0;
|
|
62
|
+
private version = 0;
|
|
63
|
+
private cachedVersion = -1;
|
|
64
|
+
private paused: boolean;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
tui: { requestRender: () => void },
|
|
68
|
+
onClose: () => void,
|
|
69
|
+
onSave: (state: GameState | null) => void,
|
|
70
|
+
savedState?: GameState,
|
|
71
|
+
) {
|
|
72
|
+
this.tui = tui;
|
|
73
|
+
if (savedState && !savedState.gameOver) {
|
|
74
|
+
// Resume from saved state, start paused
|
|
75
|
+
this.state = savedState;
|
|
76
|
+
this.paused = true;
|
|
77
|
+
} else {
|
|
78
|
+
// New game or saved game was over
|
|
79
|
+
this.state = createInitialState();
|
|
80
|
+
if (savedState) {
|
|
81
|
+
this.state.highScore = savedState.highScore;
|
|
82
|
+
}
|
|
83
|
+
this.paused = false;
|
|
84
|
+
this.startGame();
|
|
85
|
+
}
|
|
86
|
+
this.onClose = onClose;
|
|
87
|
+
this.onSave = onSave;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private startGame(): void {
|
|
91
|
+
this.interval = setInterval(() => {
|
|
92
|
+
if (!this.state.gameOver) {
|
|
93
|
+
this.tick();
|
|
94
|
+
this.version++;
|
|
95
|
+
this.tui.requestRender();
|
|
96
|
+
}
|
|
97
|
+
}, TICK_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private tick(): void {
|
|
101
|
+
// Apply queued direction change
|
|
102
|
+
this.state.direction = this.state.nextDirection;
|
|
103
|
+
|
|
104
|
+
// Calculate new head position
|
|
105
|
+
const head = this.state.snake[0];
|
|
106
|
+
let newHead: Point;
|
|
107
|
+
|
|
108
|
+
switch (this.state.direction) {
|
|
109
|
+
case "up":
|
|
110
|
+
newHead = { x: head.x, y: head.y - 1 };
|
|
111
|
+
break;
|
|
112
|
+
case "down":
|
|
113
|
+
newHead = { x: head.x, y: head.y + 1 };
|
|
114
|
+
break;
|
|
115
|
+
case "left":
|
|
116
|
+
newHead = { x: head.x - 1, y: head.y };
|
|
117
|
+
break;
|
|
118
|
+
case "right":
|
|
119
|
+
newHead = { x: head.x + 1, y: head.y };
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check wall collision
|
|
124
|
+
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
|
125
|
+
this.state.gameOver = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check self collision
|
|
130
|
+
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
|
131
|
+
this.state.gameOver = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Move snake
|
|
136
|
+
this.state.snake.unshift(newHead);
|
|
137
|
+
|
|
138
|
+
// Check food collision
|
|
139
|
+
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
|
140
|
+
this.state.score += 10;
|
|
141
|
+
if (this.state.score > this.state.highScore) {
|
|
142
|
+
this.state.highScore = this.state.score;
|
|
143
|
+
}
|
|
144
|
+
this.state.food = spawnFood(this.state.snake);
|
|
145
|
+
} else {
|
|
146
|
+
this.state.snake.pop();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
handleInput(data: string): void {
|
|
151
|
+
// If paused (resuming), wait for any key
|
|
152
|
+
if (this.paused) {
|
|
153
|
+
if (isEscape(data) || data === "q" || data === "Q") {
|
|
154
|
+
// Quit without clearing save
|
|
155
|
+
this.dispose();
|
|
156
|
+
this.onClose();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Any other key resumes
|
|
160
|
+
this.paused = false;
|
|
161
|
+
this.startGame();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ESC to pause and save
|
|
166
|
+
if (isEscape(data)) {
|
|
167
|
+
this.dispose();
|
|
168
|
+
this.onSave(this.state);
|
|
169
|
+
this.onClose();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Q to quit without saving (clears saved state)
|
|
174
|
+
if (data === "q" || data === "Q") {
|
|
175
|
+
this.dispose();
|
|
176
|
+
this.onSave(null); // Clear saved state
|
|
177
|
+
this.onClose();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Arrow keys or WASD
|
|
182
|
+
if (isArrowUp(data) || data === "w" || data === "W") {
|
|
183
|
+
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
|
184
|
+
} else if (isArrowDown(data) || data === "s" || data === "S") {
|
|
185
|
+
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
|
186
|
+
} else if (isArrowRight(data) || data === "d" || data === "D") {
|
|
187
|
+
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
|
188
|
+
} else if (isArrowLeft(data) || data === "a" || data === "A") {
|
|
189
|
+
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Restart on game over
|
|
193
|
+
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
|
194
|
+
const highScore = this.state.highScore;
|
|
195
|
+
this.state = createInitialState();
|
|
196
|
+
this.state.highScore = highScore;
|
|
197
|
+
this.onSave(null); // Clear saved state on restart
|
|
198
|
+
this.version++;
|
|
199
|
+
this.tui.requestRender();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {
|
|
204
|
+
this.cachedWidth = 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
render(width: number): string[] {
|
|
208
|
+
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
|
209
|
+
return this.cachedLines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const lines: string[] = [];
|
|
213
|
+
|
|
214
|
+
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
|
215
|
+
const cellWidth = 2;
|
|
216
|
+
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
|
217
|
+
const effectiveHeight = GAME_HEIGHT;
|
|
218
|
+
|
|
219
|
+
// Colors
|
|
220
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
221
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
222
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
223
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
224
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
225
|
+
|
|
226
|
+
const boxWidth = effectiveWidth * cellWidth;
|
|
227
|
+
|
|
228
|
+
// Helper to pad content inside box
|
|
229
|
+
const boxLine = (content: string) => {
|
|
230
|
+
const contentLen = visibleWidth(content);
|
|
231
|
+
const padding = Math.max(0, boxWidth - contentLen);
|
|
232
|
+
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Top border
|
|
236
|
+
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
|
237
|
+
|
|
238
|
+
// Header with score
|
|
239
|
+
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
|
240
|
+
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
|
241
|
+
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
|
242
|
+
lines.push(this.padLine(boxLine(title), width));
|
|
243
|
+
|
|
244
|
+
// Separator
|
|
245
|
+
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
246
|
+
|
|
247
|
+
// Game grid
|
|
248
|
+
for (let y = 0; y < effectiveHeight; y++) {
|
|
249
|
+
let row = "";
|
|
250
|
+
for (let x = 0; x < effectiveWidth; x++) {
|
|
251
|
+
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
|
252
|
+
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
|
253
|
+
const isFood = this.state.food.x === x && this.state.food.y === y;
|
|
254
|
+
|
|
255
|
+
if (isHead) {
|
|
256
|
+
row += green("██"); // Snake head (2 chars)
|
|
257
|
+
} else if (isBody) {
|
|
258
|
+
row += green("▓▓"); // Snake body (2 chars)
|
|
259
|
+
} else if (isFood) {
|
|
260
|
+
row += red("◆ "); // Food (2 chars)
|
|
261
|
+
} else {
|
|
262
|
+
row += " "; // Empty cell (2 spaces)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Separator
|
|
269
|
+
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
270
|
+
|
|
271
|
+
// Footer
|
|
272
|
+
let footer: string;
|
|
273
|
+
if (this.paused) {
|
|
274
|
+
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
|
275
|
+
} else if (this.state.gameOver) {
|
|
276
|
+
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
|
277
|
+
} else {
|
|
278
|
+
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
|
279
|
+
}
|
|
280
|
+
lines.push(this.padLine(boxLine(footer), width));
|
|
281
|
+
|
|
282
|
+
// Bottom border
|
|
283
|
+
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
|
284
|
+
|
|
285
|
+
this.cachedLines = lines;
|
|
286
|
+
this.cachedWidth = width;
|
|
287
|
+
this.cachedVersion = this.version;
|
|
288
|
+
|
|
289
|
+
return lines;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private padLine(line: string, width: number): string {
|
|
293
|
+
// Calculate visible length (strip ANSI codes)
|
|
294
|
+
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
295
|
+
const padding = Math.max(0, width - visibleLen);
|
|
296
|
+
return line + " ".repeat(padding);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
dispose(): void {
|
|
300
|
+
if (this.interval) {
|
|
301
|
+
clearInterval(this.interval);
|
|
302
|
+
this.interval = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const SNAKE_SAVE_TYPE = "snake-save";
|
|
308
|
+
|
|
309
|
+
export default function (pi: HookAPI) {
|
|
310
|
+
pi.registerCommand("snake", {
|
|
311
|
+
description: "Play Snake!",
|
|
312
|
+
|
|
313
|
+
handler: async (_args, ctx) => {
|
|
314
|
+
if (!ctx.hasUI) {
|
|
315
|
+
ctx.ui.notify("Snake requires interactive mode", "error");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Load saved state from session
|
|
320
|
+
const entries = ctx.sessionManager.getEntries();
|
|
321
|
+
let savedState: GameState | undefined;
|
|
322
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
323
|
+
const entry = entries[i];
|
|
324
|
+
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
|
325
|
+
savedState = entry.data as GameState;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await ctx.ui.custom((tui, _theme, done) => {
|
|
331
|
+
return new SnakeComponent(
|
|
332
|
+
tui,
|
|
333
|
+
() => done(undefined),
|
|
334
|
+
(state) => {
|
|
335
|
+
// Save or clear state
|
|
336
|
+
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
|
337
|
+
},
|
|
338
|
+
savedState,
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|