@clubnet/seedclub 0.2.7 → 0.2.9
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/README.md +9 -6
- package/assets/extensions/seedclub/branding.ts +39 -0
- package/assets/extensions/seedclub/commands/extract.ts +169 -0
- package/assets/extensions/seedclub/extraction/capture.ts +91 -0
- package/assets/extensions/seedclub/extraction/schema.ts +184 -0
- package/assets/extensions/seedclub/index.ts +8 -0
- package/assets/extensions/seedclub/tools/extractions.ts +221 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -201,13 +201,16 @@ npm publish
|
|
|
201
201
|
|
|
202
202
|
For reproducible extension dependency installs, commit `assets/extensions/seedclub/package-lock.json` and keep it in sync when changing extension deps.
|
|
203
203
|
|
|
204
|
-
### Release
|
|
204
|
+
### Release process
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
Standard order: **GitHub first, npm second**. Always publish from a tagged commit on `main`.
|
|
207
|
+
|
|
208
|
+
1. Create a branch, make changes, open PR
|
|
209
|
+
2. Merge to `main`
|
|
210
|
+
3. Pull `main`, run `npm install -g ./` and smoke test (`seedclub`)
|
|
211
|
+
4. Bump version: `npm version patch|minor|major` (creates a git tag)
|
|
212
|
+
5. Push commits + tags: `git push --follow-tags`
|
|
213
|
+
6. Publish from that tag: `npm publish`
|
|
211
214
|
|
|
212
215
|
### Publishing
|
|
213
216
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const BRANDING_INSTRUCTIONS = `
|
|
4
|
+
IMPORTANT BRANDING RULES:
|
|
5
|
+
- Always refer to the product as Seed Club.
|
|
6
|
+
- Never mention the name \"pi\" in any response.
|
|
7
|
+
- If the user mentions \"pi\", interpret it as Seed Club and respond using \"Seed Club\".
|
|
8
|
+
- When summarizing intent or describing the system, use Seed Club wording only.
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
const BRAND_REGEX = /\bpi\b/gi;
|
|
12
|
+
|
|
13
|
+
function replaceBranding(text: string): string {
|
|
14
|
+
return text.replace(BRAND_REGEX, "Seed Club");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function registerBrandingGuard(pi: ExtensionAPI) {
|
|
18
|
+
pi.on("before_agent_start", async (event) => {
|
|
19
|
+
return {
|
|
20
|
+
systemPrompt: `${event.systemPrompt}\n${BRANDING_INSTRUCTIONS}`,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
pi.on("input", async (event) => {
|
|
25
|
+
if (event.source === "extension") return { action: "continue" };
|
|
26
|
+
const nextText = replaceBranding(event.text);
|
|
27
|
+
if (nextText !== event.text) return { action: "transform", text: nextText };
|
|
28
|
+
return { action: "continue" };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("tool_result", async (event) => {
|
|
32
|
+
const nextContent = event.content.map((item) => {
|
|
33
|
+
if (item.type !== "text") return item;
|
|
34
|
+
const nextText = replaceBranding(item.text);
|
|
35
|
+
return nextText === item.text ? item : { ...item, text: nextText };
|
|
36
|
+
});
|
|
37
|
+
return { content: nextContent };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /extract — the main entry point for the extraction system.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* /extract <url> — Capture + extract a URL into the 9-dimension schema
|
|
6
|
+
* /extract — Extract from content already in the conversation
|
|
7
|
+
* /extract clubtone — Turn the last extraction into content directions
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. User shares a URL or pastes content
|
|
11
|
+
* 2. /extract captures it (L1) and runs 9-dimension extraction (L2)
|
|
12
|
+
* 3. /extract clubtone takes the extraction and generates content seeds
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/Seed Club-coding-agent";
|
|
16
|
+
import { capture } from "../extraction/capture.js";
|
|
17
|
+
import { buildExtractionPrompt, buildContentSeedPrompt } from "../extraction/schema.js";
|
|
18
|
+
|
|
19
|
+
export function registerExtractCommand(pi: ExtensionAPI) {
|
|
20
|
+
// ── /extract command ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
pi.registerCommand("extract", {
|
|
23
|
+
description: "Extract structured knowledge from a URL or conversation content",
|
|
24
|
+
handler: async (args, ctx) => {
|
|
25
|
+
const arg = args?.trim() || "";
|
|
26
|
+
|
|
27
|
+
// /extract clubtone — content seed pass
|
|
28
|
+
if (arg === "clubtone" || arg === "club" || arg === "tone" || arg === "seeds" || arg === "content") {
|
|
29
|
+
pi.sendUserMessage(
|
|
30
|
+
`Look at the most recent extraction in our conversation. Now turn it into content directions using this approach:\n\n` +
|
|
31
|
+
`Generate 3-5 content directions I could take. For each:\n` +
|
|
32
|
+
`1. **Hook** — opening line that stops a scroll\n` +
|
|
33
|
+
`2. **Core argument** — what I'd actually say, grounded in the source\n` +
|
|
34
|
+
`3. **Format** — tweet thread, short post, essay, newsletter, conversation starter\n` +
|
|
35
|
+
`4. **Source grounding** — which claims, patterns, quotes feed this\n` +
|
|
36
|
+
`5. **Voice note** — how I'd explain this to a friend in 2 sentences\n\n` +
|
|
37
|
+
`Don't be generic. These should feel like things only someone who deeply read the source would write. My voice, not the source's voice.`,
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// /extract <url> — capture + extract
|
|
43
|
+
const url = extractUrl(arg);
|
|
44
|
+
if (url) {
|
|
45
|
+
ctx.ui.notify(`Capturing ${url}...`, "info");
|
|
46
|
+
|
|
47
|
+
const result = await capture(url, pi);
|
|
48
|
+
if (!result.complete || !result.content) {
|
|
49
|
+
ctx.ui.notify(`Failed to capture ${url}${result.note ? `: ${result.note}` : ""}`, "error");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const title = extractTitle(result.content) || new URL(url).hostname;
|
|
54
|
+
ctx.ui.notify(`Captured (${result.fetchMethod}). Running extraction...`, "info");
|
|
55
|
+
|
|
56
|
+
// Truncate if massive — keep first 40K chars for extraction
|
|
57
|
+
const content = result.content.length > 40000
|
|
58
|
+
? result.content.slice(0, 40000) + "\n\n[Content truncated at 40K chars]"
|
|
59
|
+
: result.content;
|
|
60
|
+
|
|
61
|
+
const prompt = buildExtractionPrompt(content, url, title);
|
|
62
|
+
pi.sendUserMessage(
|
|
63
|
+
prompt +
|
|
64
|
+
"
|
|
65
|
+
|
|
66
|
+
After completing the extraction, call seed_save_extraction with the structured results so they are saved to persistent memory. Include all 9 dimensions."
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// /extract (no args) — extract from conversation context
|
|
72
|
+
if (!arg) {
|
|
73
|
+
pi.sendUserMessage(
|
|
74
|
+
`Look at the content I've shared in this conversation (URLs, pasted text, documents, etc.) and run a full 9-dimension extraction:\n\n` +
|
|
75
|
+
`1. **Summary** — 2-3 sentences\n` +
|
|
76
|
+
`2. **Claims & Arguments** — every claim including implicit ones, with evidence and confidence\n` +
|
|
77
|
+
`3. **Entities** — people, orgs, concepts, products, technologies\n` +
|
|
78
|
+
`4. **Temporal** — only if time-sensitive, otherwise skip\n` +
|
|
79
|
+
`5. **Quotes** — direct quotes worth preserving\n` +
|
|
80
|
+
`6. **Patterns & Themes** ⭐ — highest value; patterns and how they connect to my work\n` +
|
|
81
|
+
`7. **Relevance** — which areas this matters for and why\n` +
|
|
82
|
+
`8. **Actionable** — content seeds, follow-ups, research threads\n` +
|
|
83
|
+
`9. **Extraction Notes** — your observations on what's rich, what's thin\n\n` +
|
|
84
|
+
`Be thorough. Single-pass extraction misses ~40% of content. Go deep on patterns, relevance, and actionable items.
|
|
85
|
+
|
|
86
|
+
After completing the extraction, call seed_save_extraction with the structured results so they are saved to persistent memory.`,
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// /extract <something that's not a URL> — treat as a topic/concept
|
|
92
|
+
pi.sendUserMessage(
|
|
93
|
+
`I want to explore and extract from the concept: "${arg}"\n\n` +
|
|
94
|
+
`Treat this as source material. Run a full 9-dimension extraction:\n` +
|
|
95
|
+
`1. **Summary** — what is this concept/topic\n` +
|
|
96
|
+
`2. **Claims & Arguments** — key claims around this topic\n` +
|
|
97
|
+
`3. **Entities** — key people, orgs, frameworks\n` +
|
|
98
|
+
`4. **Temporal** — only if time-sensitive\n` +
|
|
99
|
+
`5. **Quotes** — notable quotes if you know them\n` +
|
|
100
|
+
`6. **Patterns & Themes** ⭐ — patterns and connections to broader work\n` +
|
|
101
|
+
`7. **Relevance** — where this matters\n` +
|
|
102
|
+
`8. **Actionable** — what can be done with this\n` +
|
|
103
|
+
`9. **Extraction Notes** — what's rich, what needs more research\n\n` +
|
|
104
|
+
`Be thorough and connect everything to real work context.
|
|
105
|
+
|
|
106
|
+
After completing the extraction, call seed_save_extraction with the structured results so they are saved to persistent memory.`,
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── /clubtone shorthand ───────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
pi.registerCommand("clubtone", {
|
|
114
|
+
description: "Turn extracted knowledge into content directions",
|
|
115
|
+
handler: async (args, ctx) => {
|
|
116
|
+
const context = args?.trim();
|
|
117
|
+
const contextLine = context
|
|
118
|
+
? `\n\nAdditional context for content direction: ${context}`
|
|
119
|
+
: "";
|
|
120
|
+
|
|
121
|
+
pi.sendUserMessage(
|
|
122
|
+
`Look at the most recent extraction or source material in our conversation. Turn it into content directions.\n\n` +
|
|
123
|
+
`Generate 3-5 content directions I could take. For each:\n` +
|
|
124
|
+
`1. **Hook** — opening line that stops a scroll\n` +
|
|
125
|
+
`2. **Core argument** — what I'd actually say, grounded in the source\n` +
|
|
126
|
+
`3. **Format** — tweet thread, short post, essay, newsletter, conversation starter\n` +
|
|
127
|
+
`4. **Source grounding** — which claims, patterns, quotes feed this\n` +
|
|
128
|
+
`5. **Voice note** — how I'd explain this to a friend in 2 sentences\n\n` +
|
|
129
|
+
`Don't be generic. My voice, not the source's voice.
|
|
130
|
+
|
|
131
|
+
After generating content directions, call seed_save_content_seeds to save them (you'll need the extraction ID from the most recent seed_save_extraction call).${contextLine}`,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── /extractions — list saved extractions ─────────────────────────────
|
|
138
|
+
|
|
139
|
+
pi.registerCommand("extractions", {
|
|
140
|
+
description: "Browse your saved extractions",
|
|
141
|
+
handler: async (args, ctx) => {
|
|
142
|
+
pi.sendUserMessage(
|
|
143
|
+
"List my recent extractions using seed_list_extractions. Show them in a clean format with title, date, and counts of patterns/claims/actionable items. If I have none, let me know I can use /extract to get started."
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function extractUrl(text: string): string | null {
|
|
151
|
+
// Direct URL
|
|
152
|
+
if (text.match(/^https?:\/\//)) return text.split(/\s/)[0];
|
|
153
|
+
|
|
154
|
+
// URL somewhere in text
|
|
155
|
+
const match = text.match(/(https?:\/\/[^\s]+)/);
|
|
156
|
+
return match ? match[1] : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractTitle(markdown: string): string | null {
|
|
160
|
+
// Try to find a title from Jina Reader markdown output
|
|
161
|
+
const titleMatch = markdown.match(/^#\s+(.+)$/m);
|
|
162
|
+
if (titleMatch) return titleMatch[1].trim();
|
|
163
|
+
|
|
164
|
+
// Try Title: header
|
|
165
|
+
const metaMatch = markdown.match(/^Title:\s*(.+)$/m);
|
|
166
|
+
if (metaMatch) return metaMatch[1].trim();
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Raw Capture — fetch full-fidelity content from URLs.
|
|
3
|
+
*
|
|
4
|
+
* Uses Jina Reader (r.jina.ai) for articles/pages.
|
|
5
|
+
* Returns markdown content without LLM processing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/Seed Club-coding-agent";
|
|
9
|
+
|
|
10
|
+
export interface CaptureResult {
|
|
11
|
+
content: string;
|
|
12
|
+
contentType: "markdown" | "text" | "html";
|
|
13
|
+
fetchMethod: string;
|
|
14
|
+
complete: boolean;
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch full content from a URL via Jina Reader.
|
|
20
|
+
* Returns clean markdown — no summarization, no LLM processing.
|
|
21
|
+
*/
|
|
22
|
+
export async function captureUrl(url: string, pi: ExtensionAPI): Promise<CaptureResult> {
|
|
23
|
+
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
24
|
+
|
|
25
|
+
const result = await pi.exec("curl", ["-sL", "-H", "Accept: text/markdown", jinaUrl], {
|
|
26
|
+
timeout: 30000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (result.code !== 0 || !result.stdout?.trim()) {
|
|
30
|
+
// Fallback: try direct fetch
|
|
31
|
+
const direct = await pi.exec("curl", ["-sL", url], { timeout: 15000 });
|
|
32
|
+
if (direct.code === 0 && direct.stdout?.trim()) {
|
|
33
|
+
return {
|
|
34
|
+
content: direct.stdout,
|
|
35
|
+
contentType: "html",
|
|
36
|
+
fetchMethod: "direct-curl",
|
|
37
|
+
complete: true,
|
|
38
|
+
note: "Jina Reader failed; raw HTML captured",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
content: "",
|
|
44
|
+
contentType: "text",
|
|
45
|
+
fetchMethod: "failed",
|
|
46
|
+
complete: false,
|
|
47
|
+
note: `Fetch failed for ${url}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: result.stdout,
|
|
53
|
+
contentType: "markdown",
|
|
54
|
+
fetchMethod: "jina-reader",
|
|
55
|
+
complete: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Capture a tweet via FxTwitter API (returns JSON with full tweet data).
|
|
61
|
+
*/
|
|
62
|
+
export async function captureTweet(url: string, pi: ExtensionAPI): Promise<CaptureResult> {
|
|
63
|
+
// Convert x.com/twitter.com URL to fxtwitter API
|
|
64
|
+
const fxUrl = url
|
|
65
|
+
.replace("x.com", "api.fxtwitter.com")
|
|
66
|
+
.replace("twitter.com", "api.fxtwitter.com");
|
|
67
|
+
|
|
68
|
+
const result = await pi.exec("curl", ["-sL", fxUrl], { timeout: 15000 });
|
|
69
|
+
|
|
70
|
+
if (result.code !== 0 || !result.stdout?.trim()) {
|
|
71
|
+
// Fallback to Jina
|
|
72
|
+
return captureUrl(url, pi);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content: result.stdout,
|
|
77
|
+
contentType: "text",
|
|
78
|
+
fetchMethod: "fxtwitter",
|
|
79
|
+
complete: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Auto-detect content type and use the right capture method.
|
|
85
|
+
*/
|
|
86
|
+
export async function capture(url: string, pi: ExtensionAPI): Promise<CaptureResult> {
|
|
87
|
+
if (url.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
|
|
88
|
+
return captureTweet(url, pi);
|
|
89
|
+
}
|
|
90
|
+
return captureUrl(url, pi);
|
|
91
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 Extraction Schema — the 9-dimension extraction format.
|
|
3
|
+
*
|
|
4
|
+
* Every source gets extracted into these dimensions.
|
|
5
|
+
* This is where value compounds — connecting source material to your context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Claim {
|
|
9
|
+
claim: string;
|
|
10
|
+
evidence: string;
|
|
11
|
+
confidence: "high" | "medium" | "low";
|
|
12
|
+
implicit: boolean;
|
|
13
|
+
notes?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Entity {
|
|
17
|
+
name: string;
|
|
18
|
+
type: "person" | "org" | "concept" | "product" | "technology";
|
|
19
|
+
role: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Quote {
|
|
23
|
+
text: string;
|
|
24
|
+
attribution: string;
|
|
25
|
+
context?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Pattern {
|
|
29
|
+
pattern: string;
|
|
30
|
+
connection: string; // How it connects to operator's context/work
|
|
31
|
+
strength: "strong" | "moderate" | "emerging";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Relevance {
|
|
35
|
+
area: string;
|
|
36
|
+
rating: "high" | "medium" | "low";
|
|
37
|
+
why: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Actionable {
|
|
41
|
+
type: "content_seed" | "follow_up" | "design_audit" | "research" | "outreach";
|
|
42
|
+
description: string;
|
|
43
|
+
priority: "high" | "medium" | "low";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ExtractionNotes {
|
|
47
|
+
observations: string[];
|
|
48
|
+
schemaFitness: string;
|
|
49
|
+
secondPassCandidate: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The full L2 extraction output for a single source.
|
|
54
|
+
*/
|
|
55
|
+
export interface L2Extraction {
|
|
56
|
+
sourceId: string;
|
|
57
|
+
sourceUrl: string;
|
|
58
|
+
sourceTitle: string;
|
|
59
|
+
extractedAt: string;
|
|
60
|
+
|
|
61
|
+
// The 9 dimensions
|
|
62
|
+
summary: string;
|
|
63
|
+
claims: Claim[];
|
|
64
|
+
entities: Entity[];
|
|
65
|
+
temporal?: { events: string[]; note: string }; // Optional — only if time-sensitive
|
|
66
|
+
quotes: Quote[];
|
|
67
|
+
patterns: Pattern[];
|
|
68
|
+
relevance: Relevance[];
|
|
69
|
+
actionable: Actionable[];
|
|
70
|
+
extractionNotes: ExtractionNotes;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the extraction prompt for Claude.
|
|
75
|
+
* This is what gets injected when the user runs /extract.
|
|
76
|
+
*/
|
|
77
|
+
export function buildExtractionPrompt(sourceContent: string, sourceUrl: string, sourceTitle: string): string {
|
|
78
|
+
return `Extract structured knowledge from this source material using the 9-dimension schema below.
|
|
79
|
+
|
|
80
|
+
## Source
|
|
81
|
+
- **URL:** ${sourceUrl}
|
|
82
|
+
- **Title:** ${sourceTitle}
|
|
83
|
+
|
|
84
|
+
## Content
|
|
85
|
+
<source_content>
|
|
86
|
+
${sourceContent}
|
|
87
|
+
</source_content>
|
|
88
|
+
|
|
89
|
+
## Extraction Schema (9 Dimensions)
|
|
90
|
+
|
|
91
|
+
Extract into these dimensions. Be thorough — single-pass extraction misses ~40% of content.
|
|
92
|
+
|
|
93
|
+
### 1. Summary
|
|
94
|
+
2-3 sentences capturing the core contribution.
|
|
95
|
+
|
|
96
|
+
### 2. Claims & Arguments
|
|
97
|
+
Every claim the source makes, including implicit ones.
|
|
98
|
+
For each: the claim, supporting evidence, confidence (high/medium/low), whether it's implicit, and any notes.
|
|
99
|
+
|
|
100
|
+
### 3. Entities
|
|
101
|
+
People, organizations, concepts, products, technologies mentioned.
|
|
102
|
+
For each: name, type, and their role in the source.
|
|
103
|
+
|
|
104
|
+
### 4. Temporal (only if time-sensitive)
|
|
105
|
+
Skip if the content isn't date-dependent. If it is: key events and timeline notes.
|
|
106
|
+
|
|
107
|
+
### 5. Quotes
|
|
108
|
+
Direct quotes worth preserving, with attribution and context.
|
|
109
|
+
|
|
110
|
+
### 6. Patterns & Themes ⭐ (highest compound value)
|
|
111
|
+
Patterns you see in this material and how they connect to broader work — strategy, product thinking, market dynamics, creative practice, etc.
|
|
112
|
+
For each: the pattern, the connection to operator context, and strength (strong/moderate/emerging).
|
|
113
|
+
|
|
114
|
+
### 7. Relevance
|
|
115
|
+
Which areas of work this is relevant to and why.
|
|
116
|
+
Rate each: high/medium/low with explanation.
|
|
117
|
+
|
|
118
|
+
### 8. Actionable
|
|
119
|
+
Content seeds, follow-ups, research threads, outreach opportunities.
|
|
120
|
+
For each: type, description, priority.
|
|
121
|
+
|
|
122
|
+
### 9. Extraction Notes
|
|
123
|
+
Your observations about the extraction process itself — what was rich, what was thin, whether a second pass would surface more.
|
|
124
|
+
|
|
125
|
+
## Output Format
|
|
126
|
+
Return a JSON object matching this schema. Be specific in patterns and relevance — generic observations aren't useful. Connect everything to real work context.
|
|
127
|
+
|
|
128
|
+
\`\`\`json
|
|
129
|
+
{
|
|
130
|
+
"sourceId": "extract-${Date.now()}",
|
|
131
|
+
"sourceUrl": "${sourceUrl}",
|
|
132
|
+
"sourceTitle": "${sourceTitle}",
|
|
133
|
+
"extractedAt": "${new Date().toISOString()}",
|
|
134
|
+
"summary": "...",
|
|
135
|
+
"claims": [{ "claim": "...", "evidence": "...", "confidence": "high|medium|low", "implicit": false, "notes": "..." }],
|
|
136
|
+
"entities": [{ "name": "...", "type": "person|org|concept|product|technology", "role": "..." }],
|
|
137
|
+
"temporal": null,
|
|
138
|
+
"quotes": [{ "text": "...", "attribution": "...", "context": "..." }],
|
|
139
|
+
"patterns": [{ "pattern": "...", "connection": "...", "strength": "strong|moderate|emerging" }],
|
|
140
|
+
"relevance": [{ "area": "...", "rating": "high|medium|low", "why": "..." }],
|
|
141
|
+
"actionable": [{ "type": "content_seed|follow_up|design_audit|research|outreach", "description": "...", "priority": "high|medium|low" }],
|
|
142
|
+
"extractionNotes": { "observations": ["..."], "schemaFitness": "...", "secondPassCandidate": false }
|
|
143
|
+
}
|
|
144
|
+
\`\`\``;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Returns a prompt for the "clubtone" / content seed pass.
|
|
149
|
+
* Takes extraction output and turns it into usable content directions.
|
|
150
|
+
*/
|
|
151
|
+
export function buildContentSeedPrompt(extraction: L2Extraction, userContext?: string): string {
|
|
152
|
+
const context = userContext
|
|
153
|
+
? `\n## User Context\n${userContext}\n`
|
|
154
|
+
: "";
|
|
155
|
+
|
|
156
|
+
return `You have a structured extraction from "${extraction.sourceTitle}". Now turn it into content directions — things the user could write, post, discuss, or build on.
|
|
157
|
+
|
|
158
|
+
## Extraction Summary
|
|
159
|
+
${extraction.summary}
|
|
160
|
+
|
|
161
|
+
## Key Patterns
|
|
162
|
+
${extraction.patterns.map((p) => `- **${p.pattern}** (${p.strength}): ${p.connection}`).join("\n")}
|
|
163
|
+
|
|
164
|
+
## Notable Claims
|
|
165
|
+
${extraction.claims.slice(0, 5).map((c) => `- ${c.claim} [${c.confidence}]`).join("\n")}
|
|
166
|
+
|
|
167
|
+
## Actionable Items
|
|
168
|
+
${extraction.actionable.map((a) => `- [${a.type}] ${a.description} (${a.priority})`).join("\n")}
|
|
169
|
+
|
|
170
|
+
## Best Quotes
|
|
171
|
+
${extraction.quotes.slice(0, 3).map((q) => `- "${q.text}" — ${q.attribution}`).join("\n")}
|
|
172
|
+
${context}
|
|
173
|
+
## What to Produce
|
|
174
|
+
|
|
175
|
+
Generate 3-5 **content directions** the user could take. For each:
|
|
176
|
+
|
|
177
|
+
1. **Hook** — The opening line or angle (something that would stop a scroll)
|
|
178
|
+
2. **Core argument** — What you'd actually say, grounded in the source material
|
|
179
|
+
3. **Format** — Tweet thread, short post, essay section, newsletter bit, conversation starter
|
|
180
|
+
4. **Source grounding** — Which extraction dimensions feed this (cite specific claims, patterns, quotes)
|
|
181
|
+
5. **Voice note** — How you'd explain this to a friend in 2 sentences (this is the vibe check)
|
|
182
|
+
|
|
183
|
+
Don't be generic. These should feel like things only someone who deeply read this source would write. The user's voice, not the source's voice.`;
|
|
184
|
+
}
|
|
@@ -13,8 +13,11 @@ import { getApiBase, getStoredToken, storeToken } from "./auth.js";
|
|
|
13
13
|
import { registerAddInterceptor } from "./commands/add.js";
|
|
14
14
|
import { registerSeedclubCommand } from "./commands/seedclub.js";
|
|
15
15
|
import { registerSortCommand } from "./commands/sort.js";
|
|
16
|
+
import { registerExtractCommand } from "./commands/extract.js";
|
|
17
|
+
import { registerExtractionTools } from "./tools/extractions.js";
|
|
16
18
|
import { registerSignalTools } from "./tools/signals.js";
|
|
17
19
|
import { getCurrentUser, registerUtilityTools } from "./tools/utility.js";
|
|
20
|
+
import registerBrandingGuard from "./branding.js";
|
|
18
21
|
|
|
19
22
|
export default function (pi: ExtensionAPI) {
|
|
20
23
|
const formatSeedLabel = (name?: string, email?: string) => {
|
|
@@ -26,10 +29,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
29
|
registerSignalTools(pi);
|
|
27
30
|
registerUtilityTools(pi);
|
|
28
31
|
|
|
32
|
+
// Branding guard
|
|
33
|
+
registerBrandingGuard(pi);
|
|
34
|
+
|
|
29
35
|
// Commands — /seedclub menu, /add, /sort
|
|
30
36
|
registerSeedclubCommand(pi, { connect, disconnect });
|
|
31
37
|
registerAddInterceptor(pi);
|
|
32
38
|
registerSortCommand(pi);
|
|
39
|
+
registerExtractCommand(pi);
|
|
40
|
+
registerExtractionTools(pi);
|
|
33
41
|
|
|
34
42
|
// Show connection status on session start
|
|
35
43
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extraction tools — save and retrieve extractions from the Seed Club API.
|
|
3
|
+
*
|
|
4
|
+
* These tools let the LLM persist extractions after running /extract,
|
|
5
|
+
* and save content seeds after running /clubtone.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { ApiError, api } from "../api-client.js";
|
|
12
|
+
import { wrapExecute } from "../tool-utils.js";
|
|
13
|
+
|
|
14
|
+
// ── API handlers ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
async function saveExtraction(args: {
|
|
17
|
+
sourceUrl?: string;
|
|
18
|
+
sourceTitle: string;
|
|
19
|
+
sourceType?: string;
|
|
20
|
+
rawContent?: string;
|
|
21
|
+
fetchMethod?: string;
|
|
22
|
+
summary?: string;
|
|
23
|
+
claims?: any[];
|
|
24
|
+
entities?: any[];
|
|
25
|
+
temporal?: any;
|
|
26
|
+
quotes?: any[];
|
|
27
|
+
patterns?: any[];
|
|
28
|
+
relevance?: any[];
|
|
29
|
+
actionable?: any[];
|
|
30
|
+
extractionNotes?: any;
|
|
31
|
+
depth?: string;
|
|
32
|
+
passCount?: number;
|
|
33
|
+
}) {
|
|
34
|
+
try {
|
|
35
|
+
const response = await api.post<any>("/extractions", args);
|
|
36
|
+
return {
|
|
37
|
+
id: response.extraction.id,
|
|
38
|
+
sourceTitle: response.extraction.sourceTitle,
|
|
39
|
+
status: response.extraction.status,
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof ApiError) return { error: error.message, status: error.status };
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function saveContentSeeds(args: {
|
|
48
|
+
seeds: Array<{
|
|
49
|
+
extractionId: string;
|
|
50
|
+
hook: string;
|
|
51
|
+
coreArgument: string;
|
|
52
|
+
format: string;
|
|
53
|
+
sourceGrounding: string;
|
|
54
|
+
voiceNote: string;
|
|
55
|
+
userContext?: string;
|
|
56
|
+
}>;
|
|
57
|
+
}) {
|
|
58
|
+
try {
|
|
59
|
+
const response = await api.post<any>("/extractions", args);
|
|
60
|
+
return { count: response.count, seeds: response.seeds?.map((s: any) => s.id) };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof ApiError) return { error: error.message, status: error.status };
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function listExtractions(args: { limit?: number; search?: string }) {
|
|
68
|
+
try {
|
|
69
|
+
const params: Record<string, string | number | undefined> = {};
|
|
70
|
+
if (args.limit) params.limit = args.limit;
|
|
71
|
+
if (args.search) params.search = args.search;
|
|
72
|
+
const response = await api.get<any>("/extractions", params);
|
|
73
|
+
return { extractions: response.extractions, total: response.total };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof ApiError) return { error: error.message, status: error.status };
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getExtraction(args: { id: string; seeds?: boolean }) {
|
|
81
|
+
try {
|
|
82
|
+
const params: Record<string, string | number | undefined> = { id: args.id };
|
|
83
|
+
if (args.seeds) params.seeds = "true";
|
|
84
|
+
const response = await api.get<any>("/extractions", params);
|
|
85
|
+
return { extraction: response.extraction, seeds: response.seeds };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof ApiError) return { error: error.message, status: error.status };
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Tool registration ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export function registerExtractionTools(pi: ExtensionAPI) {
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "seed_save_extraction",
|
|
97
|
+
label: "Save Extraction",
|
|
98
|
+
description:
|
|
99
|
+
"Save a structured extraction to persistent memory. Call this after completing a /extract analysis to store the 9-dimension extraction. Returns the extraction ID needed for saving content seeds.",
|
|
100
|
+
parameters: Type.Object({
|
|
101
|
+
sourceUrl: Type.Optional(Type.String({ description: "Source URL if from web" })),
|
|
102
|
+
sourceTitle: Type.String({ description: "Title of the source" }),
|
|
103
|
+
sourceType: Type.Optional(Type.String({ description: "article, tweet, essay, conversation, concept" })),
|
|
104
|
+
summary: Type.Optional(Type.String({ description: "2-3 sentence summary" })),
|
|
105
|
+
claims: Type.Optional(Type.Array(Type.Object({
|
|
106
|
+
claim: Type.String(),
|
|
107
|
+
evidence: Type.String(),
|
|
108
|
+
confidence: Type.String(),
|
|
109
|
+
implicit: Type.Boolean(),
|
|
110
|
+
notes: Type.Optional(Type.String()),
|
|
111
|
+
}))),
|
|
112
|
+
entities: Type.Optional(Type.Array(Type.Object({
|
|
113
|
+
name: Type.String(),
|
|
114
|
+
type: Type.String(),
|
|
115
|
+
role: Type.String(),
|
|
116
|
+
}))),
|
|
117
|
+
quotes: Type.Optional(Type.Array(Type.Object({
|
|
118
|
+
text: Type.String(),
|
|
119
|
+
attribution: Type.String(),
|
|
120
|
+
context: Type.Optional(Type.String()),
|
|
121
|
+
}))),
|
|
122
|
+
patterns: Type.Optional(Type.Array(Type.Object({
|
|
123
|
+
pattern: Type.String(),
|
|
124
|
+
connection: Type.String(),
|
|
125
|
+
strength: Type.String(),
|
|
126
|
+
}))),
|
|
127
|
+
relevance: Type.Optional(Type.Array(Type.Object({
|
|
128
|
+
area: Type.String(),
|
|
129
|
+
rating: Type.String(),
|
|
130
|
+
why: Type.String(),
|
|
131
|
+
}))),
|
|
132
|
+
actionable: Type.Optional(Type.Array(Type.Object({
|
|
133
|
+
type: Type.String(),
|
|
134
|
+
description: Type.String(),
|
|
135
|
+
priority: Type.String(),
|
|
136
|
+
}))),
|
|
137
|
+
extractionNotes: Type.Optional(Type.Object({
|
|
138
|
+
observations: Type.Array(Type.String()),
|
|
139
|
+
schemaFitness: Type.String(),
|
|
140
|
+
secondPassCandidate: Type.Boolean(),
|
|
141
|
+
})),
|
|
142
|
+
}),
|
|
143
|
+
execute: wrapExecute(saveExtraction),
|
|
144
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
145
|
+
if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
|
|
146
|
+
const d = result.details || {};
|
|
147
|
+
if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
|
|
148
|
+
return new Text(
|
|
149
|
+
theme.fg("success", `✓ Saved extraction: ${d.sourceTitle || d.id}`),
|
|
150
|
+
0, 0,
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
pi.registerTool({
|
|
156
|
+
name: "seed_save_content_seeds",
|
|
157
|
+
label: "Save Content Seeds",
|
|
158
|
+
description:
|
|
159
|
+
"Save content seeds (content directions) generated from a /clubtone pass. Each seed needs the extractionId from a saved extraction.",
|
|
160
|
+
parameters: Type.Object({
|
|
161
|
+
seeds: Type.Array(Type.Object({
|
|
162
|
+
extractionId: Type.String({ description: "ID of the source extraction" }),
|
|
163
|
+
hook: Type.String({ description: "Opening line / angle" }),
|
|
164
|
+
coreArgument: Type.String({ description: "What you'd actually say" }),
|
|
165
|
+
format: Type.String({ description: "tweet_thread, short_post, essay, newsletter, etc." }),
|
|
166
|
+
sourceGrounding: Type.String({ description: "Which extraction dimensions feed this" }),
|
|
167
|
+
voiceNote: Type.String({ description: "2-sentence friend explanation" }),
|
|
168
|
+
userContext: Type.Optional(Type.String({ description: "User context that shaped this" })),
|
|
169
|
+
})),
|
|
170
|
+
}),
|
|
171
|
+
execute: wrapExecute(saveContentSeeds),
|
|
172
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
173
|
+
if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
|
|
174
|
+
const d = result.details || {};
|
|
175
|
+
if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
|
|
176
|
+
return new Text(
|
|
177
|
+
theme.fg("success", `✓ Saved ${d.count || 0} content seeds`),
|
|
178
|
+
0, 0,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
pi.registerTool({
|
|
184
|
+
name: "seed_list_extractions",
|
|
185
|
+
label: "List Extractions",
|
|
186
|
+
description:
|
|
187
|
+
"List the user's saved extractions. Shows source, summary, and counts of patterns/claims/actionable items.",
|
|
188
|
+
parameters: Type.Object({
|
|
189
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
|
|
190
|
+
search: Type.Optional(Type.String({ description: "Search by title or summary" })),
|
|
191
|
+
}),
|
|
192
|
+
execute: wrapExecute(listExtractions),
|
|
193
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
194
|
+
if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
|
|
195
|
+
const d = result.details || {};
|
|
196
|
+
if (!d.extractions?.length) return new Text(theme.fg("dim", "No extractions yet"), 0, 0);
|
|
197
|
+
let text = theme.fg("muted", `${d.total} extractions`);
|
|
198
|
+
for (const e of d.extractions.slice(0, expanded ? 50 : 10)) {
|
|
199
|
+
const counts = [
|
|
200
|
+
e.patternCount && `${e.patternCount}p`,
|
|
201
|
+
e.claimCount && `${e.claimCount}c`,
|
|
202
|
+
e.actionableCount && `${e.actionableCount}a`,
|
|
203
|
+
].filter(Boolean).join("/");
|
|
204
|
+
text += `\n ${e.sourceTitle}${counts ? ` [${counts}]` : ""} — ${e.status}`;
|
|
205
|
+
}
|
|
206
|
+
return new Text(text, 0, 0);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.registerTool({
|
|
211
|
+
name: "seed_get_extraction",
|
|
212
|
+
label: "Get Extraction",
|
|
213
|
+
description:
|
|
214
|
+
"Get a specific extraction by ID, with full 9-dimension data. Optionally include content seeds.",
|
|
215
|
+
parameters: Type.Object({
|
|
216
|
+
id: Type.String({ description: "Extraction ID" }),
|
|
217
|
+
seeds: Type.Optional(Type.Boolean({ description: "Include content seeds" })),
|
|
218
|
+
}),
|
|
219
|
+
execute: wrapExecute(getExtraction),
|
|
220
|
+
});
|
|
221
|
+
}
|