@clubnet/seedclub 0.2.10 → 0.2.13

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.
@@ -1,169 +1,123 @@
1
1
  /**
2
- * /extract the main entry point for the extraction system.
2
+ * /extract, /clubtone, /extractions commands.
3
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
4
+ * Key design: the LLM responds BY calling the save tool.
5
+ * The tool parameters ARE the extraction. No text-then-save — saving IS responding.
13
6
  */
14
7
 
15
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
9
  import { capture } from "../extraction/capture.js";
17
- import { buildExtractionPrompt, buildContentSeedPrompt } from "../extraction/schema.js";
10
+ import { buildExtractionPrompt } from "../extraction/schema.js";
18
11
 
19
12
  export function registerExtractCommand(pi: ExtensionAPI) {
20
- // ── /extract command ──────────────────────────────────────────────
21
-
22
13
  pi.registerCommand("extract", {
23
14
  description: "Extract structured knowledge from a URL or conversation content",
24
15
  handler: async (args, ctx) => {
25
16
  const arg = args?.trim() || "";
26
17
 
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
- );
18
+ if (arg === "clubtone" || arg === "club" || arg === "tone") {
19
+ runClubtone(pi);
39
20
  return;
40
21
  }
41
22
 
42
- // /extract <url> — capture + extract
43
23
  const url = extractUrl(arg);
44
24
  if (url) {
45
- ctx.ui.notify(`Capturing ${url}...`, "info");
46
-
25
+ ctx.ui.notify("Capturing " + url + "...", "info");
47
26
  const result = await capture(url, pi);
48
27
  if (!result.complete || !result.content) {
49
- ctx.ui.notify(`Failed to capture ${url}${result.note ? `: ${result.note}` : ""}`, "error");
28
+ ctx.ui.notify("Failed to capture " + url + (result.note ? ": " + result.note : ""), "error");
50
29
  return;
51
30
  }
52
-
53
31
  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
32
+ ctx.ui.notify("Captured (" + result.fetchMethod + "). Running extraction...", "info");
57
33
  const content = result.content.length > 40000
58
34
  ? result.content.slice(0, 40000) + "\n\n[Content truncated at 40K chars]"
59
35
  : result.content;
60
-
61
36
  const prompt = buildExtractionPrompt(content, url, title);
62
37
  pi.sendUserMessage(
63
38
  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."
39
+ "\n\nIMPORTANT: Respond ONLY by calling the seed_save_extraction tool with your complete analysis as the parameters. " +
40
+ "Do not write the extraction as text first. The tool call IS your response. " +
41
+ "Fill in every dimension directly in the tool parameters: sourceUrl, sourceTitle, sourceType, summary, claims, entities, quotes, patterns, relevance, actionable, extractionNotes. " +
42
+ "After the tool confirms the save, give a brief summary of what you found."
67
43
  );
68
44
  return;
69
45
  }
70
46
 
71
- // /extract (no args) — extract from conversation context
72
47
  if (!arg) {
73
48
  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.`,
49
+ "Look at the content shared in this conversation and extract structured knowledge from it.\n\n" +
50
+ "IMPORTANT: Respond ONLY by calling the seed_save_extraction tool. " +
51
+ "Do not write the extraction as text. The tool call IS your response.\n\n" +
52
+ "Fill in all 9 dimensions as tool parameters:\n" +
53
+ "- sourceTitle, sourceType, summary (2-3 sentences)\n" +
54
+ "- claims (every claim with evidence, confidence, implicit flag)\n" +
55
+ "- entities (people, orgs, concepts, products, technologies)\n" +
56
+ "- quotes (direct quotes worth preserving)\n" +
57
+ "- patterns (how themes connect to broader work — highest value dimension)\n" +
58
+ "- relevance (which areas this matters for and why)\n" +
59
+ "- actionable (content seeds, follow-ups, research threads)\n" +
60
+ "- extractionNotes (observations, schema fitness, second pass candidate)\n\n" +
61
+ "Be thorough. After the tool confirms the save, give a brief summary of what you found."
87
62
  );
88
63
  return;
89
64
  }
90
65
 
91
- // /extract <something that's not a URL> — treat as a topic/concept
92
66
  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.`,
67
+ "Extract structured knowledge from the concept: \"" + arg + "\"\n\n" +
68
+ "IMPORTANT: Respond ONLY by calling the seed_save_extraction tool with your analysis as the parameters. " +
69
+ "Do not write the extraction as text first. The tool call IS your response. " +
70
+ "Fill in all 9 dimensions (sourceTitle, summary, claims, entities, quotes, patterns, relevance, actionable, extractionNotes). " +
71
+ "After the tool confirms the save, give a brief summary of what you found."
107
72
  );
108
73
  },
109
74
  });
110
75
 
111
- // ── /clubtone shorthand ───────────────────────────────────────────
112
-
113
76
  pi.registerCommand("clubtone", {
114
77
  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
- );
78
+ handler: async (args, _ctx) => {
79
+ runClubtone(pi, args?.trim());
133
80
  },
134
81
  });
135
- }
136
-
137
- // ── /extractions — list saved extractions ─────────────────────────────
138
82
 
139
83
  pi.registerCommand("extractions", {
140
84
  description: "Browse your saved extractions",
141
- handler: async (args, ctx) => {
85
+ handler: async (_args, _ctx) => {
142
86
  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."
87
+ "Call seed_list_extractions now. Show results in a clean format with title, date, and counts of patterns/claims/actionable items."
144
88
  );
145
89
  },
146
90
  });
91
+ }
147
92
 
148
- // ── Helpers ─────────────────────────────────────────────────────────
93
+ function runClubtone(pi: ExtensionAPI, context?: string) {
94
+ const extra = context ? "\n\nAdditional context: " + context : "";
95
+ pi.sendUserMessage(
96
+ "Look at the most recent extraction in our conversation. Generate 3-5 content directions.\n\n" +
97
+ "IMPORTANT: Respond ONLY by calling the seed_save_content_seeds tool. " +
98
+ "Do not write the directions as text first. The tool call IS your response.\n\n" +
99
+ "For each seed, fill in the tool parameters:\n" +
100
+ "- extractionId (from the most recent seed_save_extraction result)\n" +
101
+ "- hook (opening line that stops a scroll)\n" +
102
+ "- coreArgument (what I would actually say, grounded in the source)\n" +
103
+ "- format (tweet_thread, short_post, essay, newsletter, conversation_starter)\n" +
104
+ "- sourceGrounding (which claims, patterns, quotes feed this)\n" +
105
+ "- voiceNote (how I would explain this to a friend in 2 sentences)\n\n" +
106
+ "Do not be generic. My voice, not the source voice. " +
107
+ "After the tool confirms, give a brief summary of the directions." + extra
108
+ );
109
+ }
149
110
 
150
111
  function extractUrl(text: string): string | null {
151
- // Direct URL
152
112
  if (text.match(/^https?:\/\//)) return text.split(/\s/)[0];
153
-
154
- // URL somewhere in text
155
113
  const match = text.match(/(https?:\/\/[^\s]+)/);
156
114
  return match ? match[1] : null;
157
115
  }
158
116
 
159
117
  function extractTitle(markdown: string): string | null {
160
- // Try to find a title from Jina Reader markdown output
161
118
  const titleMatch = markdown.match(/^#\s+(.+)$/m);
162
119
  if (titleMatch) return titleMatch[1].trim();
163
-
164
- // Try Title: header
165
120
  const metaMatch = markdown.match(/^Title:\s*(.+)$/m);
166
121
  if (metaMatch) return metaMatch[1].trim();
167
-
168
122
  return null;
169
123
  }
@@ -1,11 +1,9 @@
1
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.
2
+ * L1 Raw Capture - fetch full-fidelity content from URLs.
3
+ * Uses Jina Reader for articles, FxTwitter for tweets.
6
4
  */
7
5
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { ExtensionAPI } from "@mariozechner/Seed Club-coding-agent";
9
7
 
10
8
  export interface CaptureResult {
11
9
  content: string;
@@ -15,74 +13,32 @@ export interface CaptureResult {
15
13
  note?: string;
16
14
  }
17
15
 
18
- /**
19
- * Fetch full content from a URL via Jina Reader.
20
- * Returns clean markdown — no summarization, no LLM processing.
21
- */
22
16
  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
- });
17
+ const jinaUrl = "https://r.jina.ai/" + url;
18
+ const result = await pi.exec("curl", ["-sL", "-H", "Accept: text/markdown", jinaUrl], { timeout: 30000 });
28
19
 
29
20
  if (result.code !== 0 || !result.stdout?.trim()) {
30
- // Fallback: try direct fetch
31
21
  const direct = await pi.exec("curl", ["-sL", url], { timeout: 15000 });
32
22
  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
- };
23
+ return { content: direct.stdout, contentType: "html", fetchMethod: "direct-curl", complete: true, note: "Jina Reader failed; raw HTML captured" };
40
24
  }
41
-
42
- return {
43
- content: "",
44
- contentType: "text",
45
- fetchMethod: "failed",
46
- complete: false,
47
- note: `Fetch failed for ${url}`,
48
- };
25
+ return { content: "", contentType: "text", fetchMethod: "failed", complete: false, note: "Fetch failed for " + url };
49
26
  }
50
27
 
51
- return {
52
- content: result.stdout,
53
- contentType: "markdown",
54
- fetchMethod: "jina-reader",
55
- complete: true,
56
- };
28
+ return { content: result.stdout, contentType: "markdown", fetchMethod: "jina-reader", complete: true };
57
29
  }
58
30
 
59
- /**
60
- * Capture a tweet via FxTwitter API (returns JSON with full tweet data).
61
- */
62
31
  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
-
32
+ const fxUrl = url.replace("x.com", "api.fxtwitter.com").replace("twitter.com", "api.fxtwitter.com");
68
33
  const result = await pi.exec("curl", ["-sL", fxUrl], { timeout: 15000 });
69
34
 
70
35
  if (result.code !== 0 || !result.stdout?.trim()) {
71
- // Fallback to Jina
72
36
  return captureUrl(url, pi);
73
37
  }
74
38
 
75
- return {
76
- content: result.stdout,
77
- contentType: "text",
78
- fetchMethod: "fxtwitter",
79
- complete: true,
80
- };
39
+ return { content: result.stdout, contentType: "text", fetchMethod: "fxtwitter", complete: true };
81
40
  }
82
41
 
83
- /**
84
- * Auto-detect content type and use the right capture method.
85
- */
86
42
  export async function capture(url: string, pi: ExtensionAPI): Promise<CaptureResult> {
87
43
  if (url.match(/(?:x\.com|twitter\.com)\/\w+\/status\/\d+/)) {
88
44
  return captureTweet(url, pi);
@@ -1,8 +1,5 @@
1
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.
2
+ * L2 Extraction Schema - the 9-dimension extraction format.
6
3
  */
7
4
 
8
5
  export interface Claim {
@@ -27,7 +24,7 @@ export interface Quote {
27
24
 
28
25
  export interface Pattern {
29
26
  pattern: string;
30
- connection: string; // How it connects to operator's context/work
27
+ connection: string;
31
28
  strength: "strong" | "moderate" | "emerging";
32
29
  }
33
30
 
@@ -49,20 +46,15 @@ export interface ExtractionNotes {
49
46
  secondPassCandidate: boolean;
50
47
  }
51
48
 
52
- /**
53
- * The full L2 extraction output for a single source.
54
- */
55
49
  export interface L2Extraction {
56
50
  sourceId: string;
57
51
  sourceUrl: string;
58
52
  sourceTitle: string;
59
53
  extractedAt: string;
60
-
61
- // The 9 dimensions
62
54
  summary: string;
63
55
  claims: Claim[];
64
56
  entities: Entity[];
65
- temporal?: { events: string[]; note: string }; // Optional — only if time-sensitive
57
+ temporal?: { events: string[]; note: string };
66
58
  quotes: Quote[];
67
59
  patterns: Pattern[];
68
60
  relevance: Relevance[];
@@ -70,115 +62,56 @@ export interface L2Extraction {
70
62
  extractionNotes: ExtractionNotes;
71
63
  }
72
64
 
73
- /**
74
- * Returns the extraction prompt for Claude.
75
- * This is what gets injected when the user runs /extract.
76
- */
77
65
  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.`;
66
+ return [
67
+ "Extract structured knowledge from this source material using the 9-dimension schema below.",
68
+ "",
69
+ "## Source",
70
+ "- **URL:** " + sourceUrl,
71
+ "- **Title:** " + sourceTitle,
72
+ "",
73
+ "## Content",
74
+ "<source_content>",
75
+ sourceContent,
76
+ "</source_content>",
77
+ "",
78
+ "## Extraction Schema (9 Dimensions)",
79
+ "",
80
+ "Extract into these dimensions. Be thorough.",
81
+ "",
82
+ "### 1. Summary",
83
+ "2-3 sentences capturing the core contribution.",
84
+ "",
85
+ "### 2. Claims & Arguments",
86
+ "Every claim the source makes, including implicit ones.",
87
+ "For each: the claim, supporting evidence, confidence (high/medium/low), whether implicit, and any notes.",
88
+ "",
89
+ "### 3. Entities",
90
+ "People, organizations, concepts, products, technologies mentioned.",
91
+ "For each: name, type, and their role in the source.",
92
+ "",
93
+ "### 4. Temporal (only if time-sensitive)",
94
+ "Skip if the content is not date-dependent. If it is: key events and timeline notes.",
95
+ "",
96
+ "### 5. Quotes",
97
+ "Direct quotes worth preserving, with attribution and context.",
98
+ "",
99
+ "### 6. Patterns & Themes (highest compound value)",
100
+ "Patterns you see in this material and how they connect to broader work.",
101
+ "For each: the pattern, the connection to operator context, and strength (strong/moderate/emerging).",
102
+ "",
103
+ "### 7. Relevance",
104
+ "Which areas of work this is relevant to and why.",
105
+ "Rate each: high/medium/low with explanation.",
106
+ "",
107
+ "### 8. Actionable",
108
+ "Content seeds, follow-ups, research threads, outreach opportunities.",
109
+ "For each: type, description, priority.",
110
+ "",
111
+ "### 9. Extraction Notes",
112
+ "Your observations about the extraction process itself.",
113
+ "",
114
+ "## Output Format",
115
+ "Return a JSON object with all 9 dimensions. Be specific in patterns and relevance.",
116
+ ].join("\n");
184
117
  }
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Extraction tools — save and retrieve extractions from the Seed Club API.
2
+ * Extraction tools — the full L0-L4 tool surface.
3
3
  *
4
- * These tools let the LLM persist extractions after running /extract,
5
- * and save content seeds after running /clubtone.
4
+ * L0: seed_register_source register a source before extraction
5
+ * L2: seed_save_extraction — save a 9-dimension extraction (tool call IS the save)
6
+ * L3: seed_synthesize — cross-source synthesis from 2+ extractions
7
+ * L4: seed_distill — promote a pattern to stable concept
8
+ * Content: seed_save_content_seeds — save content directions from extraction or synthesis
9
+ * Read: seed_list_extractions, seed_get_extraction, seed_list_sources, seed_list_syntheses
6
10
  */
7
11
 
8
12
  import { Text } from "@mariozechner/pi-tui";
@@ -13,10 +17,30 @@ import { wrapExecute } from "../tool-utils.js";
13
17
 
14
18
  // ── API handlers ────────────────────────────────────────────────────────
15
19
 
20
+ async function registerSource(args: {
21
+ url?: string;
22
+ title: string;
23
+ sourceType?: string;
24
+ author?: string;
25
+ provenance?: string;
26
+ tier?: string;
27
+ discoveryTrace?: string;
28
+ credibilityNotes?: string;
29
+ }) {
30
+ try {
31
+ const response = await api.post<any>("/extractions/sources", args);
32
+ return { id: response.source.id, title: response.source.title, status: response.source.status };
33
+ } catch (error) {
34
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
35
+ throw error;
36
+ }
37
+ }
38
+
16
39
  async function saveExtraction(args: {
17
40
  sourceUrl?: string;
18
41
  sourceTitle: string;
19
42
  sourceType?: string;
43
+ sourceId?: string;
20
44
  rawContent?: string;
21
45
  fetchMethod?: string;
22
46
  summary?: string;
@@ -44,9 +68,48 @@ async function saveExtraction(args: {
44
68
  }
45
69
  }
46
70
 
71
+ async function synthesize(args: {
72
+ title: string;
73
+ thesis?: string;
74
+ extractionIds: string[];
75
+ patterns?: any[];
76
+ entityResolution?: any[];
77
+ themeConvergence?: any[];
78
+ contradictions?: any[];
79
+ bridges?: any[];
80
+ emergentInsights?: string[];
81
+ trigger?: string;
82
+ }) {
83
+ try {
84
+ const response = await api.post<any>("/extractions/syntheses", args);
85
+ return { id: response.synthesis.id, title: response.synthesis.title, extractionCount: response.synthesis.extractionCount };
86
+ } catch (error) {
87
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function distill(args: {
93
+ title: string;
94
+ concept: string;
95
+ evidenceSummary?: string;
96
+ synthesisIds: string[];
97
+ tags?: string[];
98
+ relatedEntities?: any[];
99
+ }) {
100
+ try {
101
+ const response = await api.post<any>("/extractions/distillations", args);
102
+ return { id: response.distillation.id, title: response.distillation.title, synthesisCount: response.distillation.synthesisCount };
103
+ } catch (error) {
104
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
105
+ throw error;
106
+ }
107
+ }
108
+
47
109
  async function saveContentSeeds(args: {
48
110
  seeds: Array<{
49
- extractionId: string;
111
+ extractionId?: string;
112
+ synthesisId?: string;
50
113
  hook: string;
51
114
  coreArgument: string;
52
115
  format: string;
@@ -56,7 +119,7 @@ async function saveContentSeeds(args: {
56
119
  }>;
57
120
  }) {
58
121
  try {
59
- const response = await api.post<any>("/extractions", args);
122
+ const response = await api.post<any>("/extractions/seeds", args);
60
123
  return { count: response.count, seeds: response.seeds?.map((s: any) => s.id) };
61
124
  } catch (error) {
62
125
  if (error instanceof ApiError) return { error: error.message, status: error.status };
@@ -89,18 +152,96 @@ async function getExtraction(args: { id: string; seeds?: boolean }) {
89
152
  }
90
153
  }
91
154
 
155
+ async function listSources(args: { limit?: number; status?: string }) {
156
+ try {
157
+ const params: Record<string, string | number | undefined> = {};
158
+ if (args.limit) params.limit = args.limit;
159
+ if (args.status) params.status = args.status;
160
+ const response = await api.get<any>("/extractions/sources", params);
161
+ return { sources: response.sources, total: response.total };
162
+ } catch (error) {
163
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ async function listSyntheses(args: { limit?: number }) {
169
+ try {
170
+ const params: Record<string, string | number | undefined> = {};
171
+ if (args.limit) params.limit = args.limit;
172
+ const response = await api.get<any>("/extractions/syntheses", params);
173
+ return { syntheses: response.syntheses, total: response.total };
174
+ } catch (error) {
175
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ async function listSeeds(args: { limit?: number; unused?: boolean; extractionId?: string; synthesisId?: string }) {
181
+ try {
182
+ const params: Record<string, string | number | undefined> = {};
183
+ if (args.limit) params.limit = args.limit;
184
+ if (args.unused) params.unused = "true";
185
+ if (args.extractionId) params.extractionId = args.extractionId;
186
+ if (args.synthesisId) params.synthesisId = args.synthesisId;
187
+ const response = await api.get<any>("/extractions/seeds", params);
188
+ return { seeds: response.seeds, total: response.total };
189
+ } catch (error) {
190
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ async function markSeedUsed(args: { id: string }) {
196
+ try {
197
+ const response = await api.post<any>("/extractions/seeds/used", { id: args.id });
198
+ return { id: response.seed.id, used: true };
199
+ } catch (error) {
200
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
201
+ throw error;
202
+ }
203
+ }
204
+
92
205
  // ── Tool registration ───────────────────────────────────────────────────
93
206
 
94
207
  export function registerExtractionTools(pi: ExtensionAPI) {
208
+
209
+ // ── L0: Source Registration ───────────────────────────────────────────
210
+
211
+ pi.registerTool({
212
+ name: "seed_register_source",
213
+ label: "Register Source",
214
+ description: "Register a source in the L0 registry before extraction. Records provenance, credibility, and discovery trace. Returns source ID for linking to extraction.",
215
+ parameters: Type.Object({
216
+ url: Type.Optional(Type.String({ description: "Source URL" })),
217
+ title: Type.String({ description: "Human-readable title" }),
218
+ sourceType: Type.Optional(Type.String({ description: "article, tweet, essay, conversation, voice_note, code, transcript" })),
219
+ author: Type.Optional(Type.String({ description: "Creator(s)" })),
220
+ provenance: Type.Optional(Type.String({ description: "authored, licensed, public, restricted" })),
221
+ tier: Type.Optional(Type.String({ description: "external, internal_external, internal" })),
222
+ discoveryTrace: Type.Optional(Type.String({ description: "How found, who referred, what context" })),
223
+ credibilityNotes: Type.Optional(Type.String({ description: "Credibility assessment" })),
224
+ }),
225
+ execute: wrapExecute(registerSource),
226
+ renderResult(result: any, _opts: any, theme: any) {
227
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
228
+ const d = result.details || {};
229
+ if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
230
+ return new Text(theme.fg("success", "Registered: " + d.title + " [" + d.id + "]"), 0, 0);
231
+ },
232
+ });
233
+
234
+ // ── L2: Save Extraction ───────────────────────────────────────────────
235
+
95
236
  pi.registerTool({
96
237
  name: "seed_save_extraction",
97
238
  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.",
239
+ description: "Save a structured 9-dimension extraction. The tool call IS the extraction — fill all dimensions as parameters. Returns extraction ID for content seeds and synthesis.",
100
240
  parameters: Type.Object({
101
241
  sourceUrl: Type.Optional(Type.String({ description: "Source URL if from web" })),
102
242
  sourceTitle: Type.String({ description: "Title of the source" }),
103
243
  sourceType: Type.Optional(Type.String({ description: "article, tweet, essay, conversation, concept" })),
244
+ sourceId: Type.Optional(Type.String({ description: "L0 source registry ID if registered" })),
104
245
  summary: Type.Optional(Type.String({ description: "2-3 sentence summary" })),
105
246
  claims: Type.Optional(Type.Array(Type.Object({
106
247
  claim: Type.String(),
@@ -145,23 +286,100 @@ export function registerExtractionTools(pi: ExtensionAPI) {
145
286
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
146
287
  const d = result.details || {};
147
288
  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
- );
289
+ return new Text(theme.fg("success", "Saved extraction: " + (d.sourceTitle || d.id)), 0, 0);
152
290
  },
153
291
  });
154
292
 
293
+ // ── L3: Synthesize ────────────────────────────────────────────────────
294
+
295
+ pi.registerTool({
296
+ name: "seed_synthesize",
297
+ label: "Synthesize",
298
+ description: "Create an L3 cross-source synthesis from 2+ extractions. Identifies patterns, contradictions, entity resolution, theme convergence, and emergent insights across sources. The tool call IS the synthesis.",
299
+ parameters: Type.Object({
300
+ title: Type.String({ description: "Synthesis title" }),
301
+ thesis: Type.Optional(Type.String({ description: "Core insight from crossing these sources" })),
302
+ extractionIds: Type.Array(Type.String({ description: "Extraction IDs to synthesize" })),
303
+ patterns: Type.Optional(Type.Array(Type.Object({
304
+ pattern: Type.String(),
305
+ sources: Type.Array(Type.String()),
306
+ strength: Type.String(),
307
+ connection: Type.String(),
308
+ }))),
309
+ entityResolution: Type.Optional(Type.Array(Type.Object({
310
+ entity: Type.String(),
311
+ appearances: Type.Array(Type.Object({ extractionId: Type.String(), role: Type.String() })),
312
+ mergedUnderstanding: Type.String(),
313
+ }))),
314
+ themeConvergence: Type.Optional(Type.Array(Type.Object({
315
+ theme: Type.String(),
316
+ supportingExtractions: Type.Array(Type.String()),
317
+ divergences: Type.String(),
318
+ synthesisNote: Type.String(),
319
+ }))),
320
+ contradictions: Type.Optional(Type.Array(Type.Object({
321
+ claimA: Type.String(),
322
+ sourceA: Type.String(),
323
+ claimB: Type.String(),
324
+ sourceB: Type.String(),
325
+ resolution: Type.String(),
326
+ }))),
327
+ bridges: Type.Optional(Type.Array(Type.Object({
328
+ fromExtraction: Type.String(),
329
+ toExtraction: Type.String(),
330
+ bridgeInsight: Type.String(),
331
+ }))),
332
+ emergentInsights: Type.Optional(Type.Array(Type.String({ description: "Insights only visible when crossing sources" }))),
333
+ trigger: Type.Optional(Type.String({ description: "manual, theme_cluster, project_need" })),
334
+ }),
335
+ execute: wrapExecute(synthesize),
336
+ renderResult(result: any, _opts: any, theme: any) {
337
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
338
+ const d = result.details || {};
339
+ if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
340
+ return new Text(theme.fg("success", "Synthesis: " + d.title + " (" + d.extractionCount + " sources)"), 0, 0);
341
+ },
342
+ });
343
+
344
+ // ── L4: Distill ───────────────────────────────────────────────────────
345
+
346
+ pi.registerTool({
347
+ name: "seed_distill",
348
+ label: "Distill",
349
+ description: "Promote a pattern to an L4 distillation — a stable concept proven across 3+ syntheses. Returns distillation ID.",
350
+ parameters: Type.Object({
351
+ title: Type.String({ description: "Concept title" }),
352
+ concept: Type.String({ description: "The stable pattern or mental model" }),
353
+ evidenceSummary: Type.Optional(Type.String({ description: "Why this graduated" })),
354
+ synthesisIds: Type.Array(Type.String({ description: "Synthesis IDs that support this" })),
355
+ tags: Type.Optional(Type.Array(Type.String({ description: "Freeform tags for routing" }))),
356
+ relatedEntities: Type.Optional(Type.Array(Type.Object({
357
+ name: Type.String(),
358
+ type: Type.String(),
359
+ role: Type.String(),
360
+ }))),
361
+ }),
362
+ execute: wrapExecute(distill),
363
+ renderResult(result: any, _opts: any, theme: any) {
364
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
365
+ const d = result.details || {};
366
+ if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
367
+ return new Text(theme.fg("success", "Distilled: " + d.title + " (" + d.synthesisCount + " syntheses)"), 0, 0);
368
+ },
369
+ });
370
+
371
+ // ── Content Seeds ─────────────────────────────────────────────────────
372
+
155
373
  pi.registerTool({
156
374
  name: "seed_save_content_seeds",
157
375
  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.",
376
+ description: "Save content directions from an extraction or synthesis. Each seed needs either an extractionId or synthesisId (or both for cross-source seeds).",
160
377
  parameters: Type.Object({
161
378
  seeds: Type.Array(Type.Object({
162
- extractionId: Type.String({ description: "ID of the source extraction" }),
379
+ extractionId: Type.Optional(Type.String({ description: "Source extraction ID" })),
380
+ synthesisId: Type.Optional(Type.String({ description: "Source synthesis ID" })),
163
381
  hook: Type.String({ description: "Opening line / angle" }),
164
- coreArgument: Type.String({ description: "What you'd actually say" }),
382
+ coreArgument: Type.String({ description: "What you would actually say" }),
165
383
  format: Type.String({ description: "tweet_thread, short_post, essay, newsletter, etc." }),
166
384
  sourceGrounding: Type.String({ description: "Which extraction dimensions feed this" }),
167
385
  voiceNote: Type.String({ description: "2-sentence friend explanation" }),
@@ -173,18 +391,16 @@ export function registerExtractionTools(pi: ExtensionAPI) {
173
391
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
174
392
  const d = result.details || {};
175
393
  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
- );
394
+ return new Text(theme.fg("success", "Saved " + (d.count || 0) + " content seeds"), 0, 0);
180
395
  },
181
396
  });
182
397
 
398
+ // ── Read tools ────────────────────────────────────────────────────────
399
+
183
400
  pi.registerTool({
184
401
  name: "seed_list_extractions",
185
402
  label: "List Extractions",
186
- description:
187
- "List the user's saved extractions. Shows source, summary, and counts of patterns/claims/actionable items.",
403
+ description: "List saved extractions with summary and dimension counts.",
188
404
  parameters: Type.Object({
189
405
  limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
190
406
  search: Type.Optional(Type.String({ description: "Search by title or summary" })),
@@ -194,14 +410,14 @@ export function registerExtractionTools(pi: ExtensionAPI) {
194
410
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
195
411
  const d = result.details || {};
196
412
  if (!d.extractions?.length) return new Text(theme.fg("dim", "No extractions yet"), 0, 0);
197
- let text = theme.fg("muted", `${d.total} extractions`);
413
+ let text = theme.fg("muted", d.total + " extractions");
198
414
  for (const e of d.extractions.slice(0, expanded ? 50 : 10)) {
199
415
  const counts = [
200
- e.patternCount && `${e.patternCount}p`,
201
- e.claimCount && `${e.claimCount}c`,
202
- e.actionableCount && `${e.actionableCount}a`,
416
+ e.patternCount && e.patternCount + "p",
417
+ e.claimCount && e.claimCount + "c",
418
+ e.actionableCount && e.actionableCount + "a",
203
419
  ].filter(Boolean).join("/");
204
- text += `\n ${e.sourceTitle}${counts ? ` [${counts}]` : ""} ${e.status}`;
420
+ text += "\n " + e.sourceTitle + (counts ? " [" + counts + "]" : "") + " - " + e.status;
205
421
  }
206
422
  return new Text(text, 0, 0);
207
423
  },
@@ -210,12 +426,92 @@ export function registerExtractionTools(pi: ExtensionAPI) {
210
426
  pi.registerTool({
211
427
  name: "seed_get_extraction",
212
428
  label: "Get Extraction",
213
- description:
214
- "Get a specific extraction by ID, with full 9-dimension data. Optionally include content seeds.",
429
+ description: "Get a specific extraction by ID with full 9-dimension data. Optionally include content seeds.",
215
430
  parameters: Type.Object({
216
431
  id: Type.String({ description: "Extraction ID" }),
217
432
  seeds: Type.Optional(Type.Boolean({ description: "Include content seeds" })),
218
433
  }),
219
434
  execute: wrapExecute(getExtraction),
220
435
  });
436
+
437
+ pi.registerTool({
438
+ name: "seed_list_sources",
439
+ label: "List Sources",
440
+ description: "List registered sources from the L0 registry.",
441
+ parameters: Type.Object({
442
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
443
+ status: Type.Optional(Type.String({ description: "Filter by status: registered, captured, extracted, synthesized, distilled" })),
444
+ }),
445
+ execute: wrapExecute(listSources),
446
+ renderResult(result: any, _opts: any, theme: any) {
447
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
448
+ const d = result.details || {};
449
+ if (!d.sources?.length) return new Text(theme.fg("dim", "No sources registered"), 0, 0);
450
+ let text = theme.fg("muted", d.total + " sources");
451
+ for (const s of d.sources) {
452
+ text += "\n " + s.title + " [" + s.status + "] " + (s.provenance || "");
453
+ }
454
+ return new Text(text, 0, 0);
455
+ },
456
+ });
457
+
458
+ pi.registerTool({
459
+ name: "seed_list_syntheses",
460
+ label: "List Syntheses",
461
+ description: "List L3 cross-source syntheses.",
462
+ parameters: Type.Object({
463
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
464
+ }),
465
+ execute: wrapExecute(listSyntheses),
466
+ renderResult(result: any, _opts: any, theme: any) {
467
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
468
+ const d = result.details || {};
469
+ if (!d.syntheses?.length) return new Text(theme.fg("dim", "No syntheses yet"), 0, 0);
470
+ let text = theme.fg("muted", d.total + " syntheses");
471
+ for (const s of d.syntheses) {
472
+ text += "\n " + s.title + " (" + s.extractionCount + " sources)";
473
+ }
474
+ return new Text(text, 0, 0);
475
+ },
476
+ });
477
+
478
+ pi.registerTool({
479
+ name: "seed_list_seeds",
480
+ label: "List Content Seeds",
481
+ description: "List content seeds. Filter by unused, extraction, or synthesis.",
482
+ parameters: Type.Object({
483
+ limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
484
+ unused: Type.Optional(Type.Boolean({ description: "Only unused seeds" })),
485
+ extractionId: Type.Optional(Type.String({ description: "Filter by extraction" })),
486
+ synthesisId: Type.Optional(Type.String({ description: "Filter by synthesis" })),
487
+ }),
488
+ execute: wrapExecute(listSeeds),
489
+ renderResult(result: any, _opts: any, theme: any) {
490
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
491
+ const d = result.details || {};
492
+ if (!d.seeds?.length) return new Text(theme.fg("dim", "No content seeds yet"), 0, 0);
493
+ let text = theme.fg("muted", d.total + " seeds");
494
+ for (const s of d.seeds) {
495
+ const status = s.used ? "used" : "fresh";
496
+ text += "\n [" + s.format + "] " + s.hook.slice(0, 60) + "... (" + status + ")";
497
+ }
498
+ return new Text(text, 0, 0);
499
+ },
500
+ });
501
+
502
+ pi.registerTool({
503
+ name: "seed_mark_used",
504
+ label: "Mark Seed Used",
505
+ description: "Mark a content seed as used after drafting content from it.",
506
+ parameters: Type.Object({
507
+ id: Type.String({ description: "Content seed ID" }),
508
+ }),
509
+ execute: wrapExecute(markSeedUsed),
510
+ renderResult(result: any, _opts: any, theme: any) {
511
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
512
+ const d = result.details || {};
513
+ if (d.error) return new Text(theme.fg("error", d.error), 0, 0);
514
+ return new Text(theme.fg("success", "Marked as used"), 0, 0);
515
+ },
516
+ });
221
517
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubnet/seedclub",
3
- "version": "0.2.10",
3
+ "version": "0.2.13",
4
4
  "description": "The Human+ Venture Network — AI agent for deal sourcing, research, and signal tracking",
5
5
  "license": "MIT",
6
6
  "repository": {