@sassywares/pi-extensions 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Auto-title
3
+ *
4
+ * Generates a short session title after the first meaningful turn, and adds
5
+ * `/title` to regenerate or set one manually. Titles use pi's native session
6
+ * name, so they appear in the session picker, footer, and terminal title.
7
+ *
8
+ * Disable auto-generation with PI_AUTO_TITLE=0.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+
13
+ const MAX_TITLE_LENGTH = 60;
14
+ const AUTO_TITLE_DISABLED = process.env.PI_AUTO_TITLE === "0";
15
+
16
+ let generationToken = 0;
17
+ let generating = false;
18
+ let autoAttemptedSessionId: string | undefined;
19
+
20
+ function textOf(content: unknown): string {
21
+ if (typeof content === "string") return content;
22
+ if (!Array.isArray(content)) return "";
23
+ return content
24
+ .filter(
25
+ (part): part is { type: "text"; text: string } =>
26
+ !!part &&
27
+ typeof part === "object" &&
28
+ (part as { type?: unknown }).type === "text" &&
29
+ typeof (part as { text?: unknown }).text === "string",
30
+ )
31
+ .map((part) => part.text)
32
+ .join("\n");
33
+ }
34
+
35
+ function normalizeTitle(raw: string): string {
36
+ let title = (raw.split(/\r?\n/, 1)[0] ?? "").trim();
37
+ title = title.replace(/^#+\s*/, "");
38
+ title = title.replace(/^(?:session\s+)?title\s*:\s*/i, "");
39
+ title = title.replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, "").trim();
40
+ title = title.replace(/[.!?;:,,。!?;:]+$/, "").trim();
41
+ if (title.length > MAX_TITLE_LENGTH) {
42
+ title = title.slice(0, MAX_TITLE_LENGTH).replace(/\s+\S*$/, "").trim();
43
+ }
44
+ return title;
45
+ }
46
+
47
+ function hasAssistantText(ctx: ExtensionContext): boolean {
48
+ return ctx.sessionManager.getBranch().some(
49
+ (entry) =>
50
+ entry.type === "message" &&
51
+ entry.message.role === "assistant" &&
52
+ textOf(entry.message.content).trim().length > 0,
53
+ );
54
+ }
55
+
56
+ function buildTranscript(ctx: ExtensionContext): string {
57
+ const parts: string[] = [];
58
+ for (const entry of ctx.sessionManager.getBranch()) {
59
+ if (entry.type !== "message") continue;
60
+ const message = entry.message;
61
+ if (message.role !== "user" && message.role !== "assistant") continue;
62
+
63
+ const text = textOf(message.content).trim();
64
+ if (!text) continue;
65
+
66
+ // Titles only need the gist of the opening turns.
67
+ parts.push(`${message.role.toUpperCase()}: ${text.slice(0, 3000)}`);
68
+ if (parts.length >= 4) break;
69
+ }
70
+ return parts.join("\n\n").slice(0, 8000);
71
+ }
72
+
73
+ async function generateTitle(ctx: ExtensionContext): Promise<string | undefined> {
74
+ if (!ctx.model) return undefined;
75
+ const transcript = buildTranscript(ctx);
76
+ if (!transcript) return undefined;
77
+
78
+ const abort = new AbortController();
79
+ const timeout = setTimeout(() => abort.abort(), 15_000);
80
+ try {
81
+ const result = await ctx.modelRegistry.complete(
82
+ ctx.model,
83
+ {
84
+ systemPrompt:
85
+ "You create concise session titles for a coding agent. Reply with only the title: " +
86
+ "2-6 words, no quotes, no markdown, no trailing punctuation. " +
87
+ "Describe the concrete task, not the conversation. Use the user's language.",
88
+ messages: [
89
+ {
90
+ role: "user",
91
+ content: `Create a title for this session:\n\n${transcript}`,
92
+ timestamp: Date.now(),
93
+ },
94
+ ],
95
+ },
96
+ { maxTokens: 32, temperature: 0.2, signal: abort.signal },
97
+ );
98
+
99
+ if (result.stopReason === "error" || result.stopReason === "aborted") return undefined;
100
+ return normalizeTitle(textOf(result.content));
101
+ } finally {
102
+ clearTimeout(timeout);
103
+ }
104
+ }
105
+
106
+ async function titleCurrentSession(
107
+ pi: ExtensionAPI,
108
+ ctx: ExtensionContext,
109
+ force: boolean,
110
+ ): Promise<string | undefined> {
111
+ if (generating) return undefined;
112
+
113
+ const sessionId = ctx.sessionManager.getSessionId();
114
+ const token = generationToken;
115
+ const existing = pi.getSessionName();
116
+ if (!force && existing?.trim()) return existing;
117
+
118
+ generating = true;
119
+ try {
120
+ const title = await generateTitle(ctx);
121
+ if (!title) return undefined;
122
+
123
+ // The user may have switched sessions or named this one while we waited.
124
+ if (token !== generationToken || ctx.sessionManager.getSessionId() !== sessionId) return undefined;
125
+ if (!force && pi.getSessionName()?.trim()) return pi.getSessionName();
126
+
127
+ pi.setSessionName(title);
128
+ return title;
129
+ } finally {
130
+ generating = false;
131
+ }
132
+ }
133
+
134
+ export default function (pi: ExtensionAPI) {
135
+ pi.on("session_start", () => {
136
+ generationToken++;
137
+ generating = false;
138
+ autoAttemptedSessionId = undefined;
139
+ });
140
+
141
+ pi.on("agent_end", async (_event, ctx) => {
142
+ if (AUTO_TITLE_DISABLED) return;
143
+ if (pi.getSessionName()?.trim()) return;
144
+
145
+ const sessionId = ctx.sessionManager.getSessionId();
146
+ if (autoAttemptedSessionId === sessionId) return;
147
+ if (!hasAssistantText(ctx)) return;
148
+
149
+ // Try once per session. `/title` can retry after a provider failure.
150
+ autoAttemptedSessionId = sessionId;
151
+
152
+ // Do not delay the turn; the title can arrive a moment later.
153
+ void titleCurrentSession(pi, ctx, false).catch(() => {
154
+ // A title is a convenience; never surface provider errors into the session.
155
+ });
156
+ });
157
+
158
+ pi.registerCommand("title", {
159
+ description: "Generate a title from the conversation, or set one: /title <text>",
160
+ handler: async (args, ctx) => {
161
+ const requested = args.trim();
162
+ if (requested) {
163
+ const title = normalizeTitle(requested) || requested;
164
+ pi.setSessionName(title);
165
+ ctx.ui.notify(`Session titled: ${title}`, "info");
166
+ return;
167
+ }
168
+
169
+ ctx.ui.notify("Generating session title…", "info");
170
+ const title = await titleCurrentSession(pi, ctx, true);
171
+ if (title) {
172
+ ctx.ui.notify(`Session titled: ${title}`, "info");
173
+ } else {
174
+ ctx.ui.notify("Could not generate a title from this conversation.", "warning");
175
+ }
176
+ },
177
+ });
178
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Sassy Handoff - summarize the current session and continue in a fresh one.
3
+ *
4
+ * Usage:
5
+ * /handoff <goal for the next session>
6
+ * /handoff mode:auto <goal for the next session>
7
+ *
8
+ * Default: generate a focused continuation prompt, let the user edit it, then
9
+ * open a new session with the prompt seeded in the editor (submit to continue).
10
+ *
11
+ * mode:auto: generate the prompt and start the new session immediately, sending
12
+ * the prompt as its first user message (skill commands inside it are expanded).
13
+ *
14
+ * Session replacement is only available from command handlers, which is why
15
+ * this is a command and not a tool.
16
+ */
17
+
18
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+ import { type Message, uuidv7 } from "@earendil-works/pi-ai";
20
+ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
21
+ import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
22
+
23
+ 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:
24
+
25
+ 1. Summarizes the relevant context from the conversation (decisions made, approaches taken, key findings)
26
+ 2. Lists the relevant files that were discussed or modified, with the specific part that matters
27
+ 3. States the next task clearly, based on the user's goal
28
+ 4. Is self-contained - the new thread must be able to proceed without the old conversation
29
+
30
+ If the user's goal names a plan file and a phase, make the plan path and phase explicit, and end with the exact follow-up command to run them (for example: run /skill:sassy-work <plan> phase:<n>).
31
+
32
+ Format your response as the prompt itself. No preamble. Example shape:
33
+
34
+ ## Context
35
+ We were building X. Key decisions:
36
+ - Decision 1
37
+ - Decision 2
38
+
39
+ Files involved:
40
+ - path/to/file.ts - what matters there
41
+
42
+ ## Task
43
+ [Clear description of what to do next, including the exact command to run]`;
44
+
45
+ function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
46
+ if (entry.type === "message") {
47
+ return entry.message;
48
+ }
49
+ if (entry.type === "compaction") {
50
+ return {
51
+ role: "compactionSummary",
52
+ summary: entry.summary,
53
+ tokensBefore: entry.tokensBefore,
54
+ timestamp: new Date(entry.timestamp).getTime(),
55
+ };
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function getHandoffMessages(branch: SessionEntry[]): AgentMessage[] {
61
+ let compactionIndex = -1;
62
+ for (let i = branch.length - 1; i >= 0; i--) {
63
+ if (branch[i].type === "compaction") {
64
+ compactionIndex = i;
65
+ break;
66
+ }
67
+ }
68
+ if (compactionIndex < 0) {
69
+ return branch.map(entryToMessage).filter((message) => message !== undefined);
70
+ }
71
+
72
+ const compaction = branch[compactionIndex];
73
+ const firstKeptIndex =
74
+ compaction.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
75
+ const compactedBranch = [
76
+ compaction,
77
+ ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []),
78
+ ...branch.slice(compactionIndex + 1),
79
+ ];
80
+ return compactedBranch.map(entryToMessage).filter((message) => message !== undefined);
81
+ }
82
+
83
+ export default function (pi: ExtensionAPI) {
84
+ pi.registerCommand("handoff", {
85
+ description: "Summarize this session and continue in a fresh one",
86
+ handler: async (args, ctx) => {
87
+ if (ctx.mode !== "tui") {
88
+ ctx.ui.notify("handoff requires interactive mode", "error");
89
+ return;
90
+ }
91
+ if (!ctx.model) {
92
+ ctx.ui.notify("No model selected", "error");
93
+ return;
94
+ }
95
+
96
+ let rest = args.trim();
97
+ let auto = false;
98
+ if (rest.startsWith("mode:auto")) {
99
+ auto = true;
100
+ rest = rest.slice("mode:auto".length).trim();
101
+ }
102
+ if (!rest) {
103
+ ctx.ui.notify("Usage: /handoff [mode:auto] <goal for next session>", "error");
104
+ return;
105
+ }
106
+ const goal = rest;
107
+
108
+ const messages = getHandoffMessages(ctx.sessionManager.getBranch());
109
+ if (messages.length === 0) {
110
+ ctx.ui.notify("No conversation to hand off", "error");
111
+ return;
112
+ }
113
+
114
+ const conversationText = serializeConversation(convertToLlm(messages));
115
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
116
+
117
+ const generated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
118
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
119
+ loader.onAbort = () => done(null);
120
+
121
+ const doGenerate = async () => {
122
+ const userMessage: Message = {
123
+ role: "user",
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
128
+ },
129
+ ],
130
+ timestamp: Date.now(),
131
+ };
132
+
133
+ const response = await ctx.modelRegistry.complete(
134
+ ctx.model!,
135
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
136
+ { signal: loader.signal, cacheRetention: "none", sessionId: uuidv7() },
137
+ );
138
+
139
+ if (response.stopReason === "aborted") return null;
140
+
141
+ return response.content
142
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
143
+ .map((c) => c.text)
144
+ .join("\n");
145
+ };
146
+
147
+ doGenerate()
148
+ .then(done)
149
+ .catch((err) => {
150
+ console.error("Handoff generation failed:", err);
151
+ done(null);
152
+ });
153
+
154
+ return loader;
155
+ });
156
+
157
+ if (generated === null) {
158
+ ctx.ui.notify("Handoff cancelled", "info");
159
+ return;
160
+ }
161
+
162
+ let prompt = generated;
163
+ if (!auto) {
164
+ const edited = await ctx.ui.editor("Edit handoff prompt", generated);
165
+ if (edited === undefined) {
166
+ ctx.ui.notify("Handoff cancelled", "info");
167
+ return;
168
+ }
169
+ prompt = edited;
170
+ }
171
+
172
+ const result = await ctx.newSession({
173
+ parentSession: currentSessionFile,
174
+ withSession: async (replacementCtx) => {
175
+ if (auto) {
176
+ await replacementCtx.sendUserMessage(prompt, { expandPromptTemplates: true });
177
+ } else {
178
+ replacementCtx.ui.setEditorText(prompt);
179
+ replacementCtx.ui.notify("Handoff ready. Submit when ready.", "info");
180
+ }
181
+ },
182
+ });
183
+
184
+ if (result.cancelled) {
185
+ ctx.ui.notify("New session cancelled", "info");
186
+ }
187
+ },
188
+ });
189
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Agent discovery and configuration
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
+
9
+ export type AgentScope = "user" | "project" | "both";
10
+
11
+ export interface AgentConfig {
12
+ name: string;
13
+ description: string;
14
+ tools?: string[];
15
+ model?: string;
16
+ thinking?: string;
17
+ systemPrompt: string;
18
+ source: "user" | "project";
19
+ filePath: string;
20
+ }
21
+
22
+ export interface AgentDiscoveryResult {
23
+ agents: AgentConfig[];
24
+ projectAgentsDir: string | null;
25
+ }
26
+
27
+ /**
28
+ * Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a
29
+ * real YAML parser, so any scalar or collection can appear here.
30
+ *
31
+ * A type alias rather than an interface: `parseFrontmatter` constrains its
32
+ * parameter to `Record<string, unknown>`, and only an alias picks up the
33
+ * implicit index signature that satisfies it.
34
+ */
35
+ type AgentFrontmatter = {
36
+ name?: unknown;
37
+ description?: unknown;
38
+ tools?: unknown;
39
+ model?: unknown;
40
+ thinking?: unknown;
41
+ };
42
+
43
+ /**
44
+ * Normalize a frontmatter `tools` value to a list of tool names.
45
+ *
46
+ * Both spellings are valid YAML and both are in use:
47
+ *
48
+ * tools: read, bash # string
49
+ * tools: [read, bash] # array
50
+ *
51
+ * so accept either. Anything else (a number, a map, a nested list) yields no
52
+ * tools rather than throwing: this runs inside agent discovery, where a single
53
+ * bad file must not take down every other agent in the same directory.
54
+ */
55
+ function parseToolList(value: unknown): string[] | undefined {
56
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
57
+ const tools = raw
58
+ .filter((t): t is string => typeof t === "string")
59
+ .map((t) => t.trim())
60
+ .filter(Boolean);
61
+ return tools.length > 0 ? tools : undefined;
62
+ }
63
+
64
+ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
65
+ const agents: AgentConfig[] = [];
66
+
67
+ if (!fs.existsSync(dir)) {
68
+ return agents;
69
+ }
70
+
71
+ let entries: fs.Dirent[];
72
+ try {
73
+ entries = fs.readdirSync(dir, { withFileTypes: true });
74
+ } catch {
75
+ return agents;
76
+ }
77
+
78
+ for (const entry of entries) {
79
+ if (!entry.name.endsWith(".md")) continue;
80
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
81
+
82
+ const filePath = path.join(dir, entry.name);
83
+ let content: string;
84
+ try {
85
+ content = fs.readFileSync(filePath, "utf-8");
86
+ } catch {
87
+ continue;
88
+ }
89
+
90
+ const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
91
+
92
+ if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
93
+ continue;
94
+ }
95
+
96
+ agents.push({
97
+ name: frontmatter.name,
98
+ description: frontmatter.description,
99
+ tools: parseToolList(frontmatter.tools),
100
+ model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
101
+ thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
102
+ systemPrompt: body,
103
+ source,
104
+ filePath,
105
+ });
106
+ }
107
+
108
+ return agents;
109
+ }
110
+
111
+ function isDirectory(p: string): boolean {
112
+ try {
113
+ return fs.statSync(p).isDirectory();
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function findNearestProjectAgentsDir(cwd: string): string | null {
120
+ let currentDir = cwd;
121
+ while (true) {
122
+ const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
123
+ if (isDirectory(candidate)) return candidate;
124
+
125
+ const parentDir = path.dirname(currentDir);
126
+ if (parentDir === currentDir) return null;
127
+ currentDir = parentDir;
128
+ }
129
+ }
130
+
131
+ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
132
+ const userDir = path.join(getAgentDir(), "agents");
133
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
134
+
135
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
136
+ const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
137
+
138
+ const agentMap = new Map<string, AgentConfig>();
139
+
140
+ if (scope === "both") {
141
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
142
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
143
+ } else if (scope === "user") {
144
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
145
+ } else {
146
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
147
+ }
148
+
149
+ return { agents: Array.from(agentMap.values()), projectAgentsDir };
150
+ }
151
+
152
+ export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
153
+ if (agents.length === 0) return { text: "none", remaining: 0 };
154
+ const listed = agents.slice(0, maxItems);
155
+ const remaining = agents.length - listed.length;
156
+ return {
157
+ text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
158
+ remaining,
159
+ };
160
+ }