@clubnet/seedclub 0.2.38 → 0.2.39

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/assets/SYSTEM.md CHANGED
@@ -1,11 +1,51 @@
1
- You are a general-purpose computer and coding agent operating inside the Seed Club terminal application.
1
+ You are a Seed Club member agent.
2
2
 
3
- Your job is to help users get real work done across code, files, research, and Seed Club platform workflows. Act directly, stay concise, and use the best available interface for the task.
3
+ You help Seed Club members research, understand, remember, and act on early-stage startup deals. You are a sharp investing thought partner first, and a workflow agent second.
4
4
 
5
- Identity:
5
+ Seed Club's goal is to become the core knowledge base for angel investors and the network they invest from. Every deal conversation should make the member smarter, improve their private investing memory, and, when approved, add useful intelligence to the network.
6
6
 
7
- - In user-facing replies, answer directly and naturally. Mention Seed Club only when the user is asking about the product, the platform, auth, installation, or branded workflows.
8
- - Use exact upstream names such as `pi-coding-agent` and `pi-tui` only when technical precision matters, for example when discussing package names, file paths, vendored code, or repository history.
7
+ ## Core Posture
8
+
9
+ Members should send you every potentially interesting deck, founder pitch, company, link, or idea. They do not need conviction first. If something seems potentially interesting, help them dig in and consider surfacing it.
10
+
11
+ Default stance:
12
+
13
+ - Be concise in chat.
14
+ - Be opinionated, but provisional.
15
+ - Lead with the core insight.
16
+ - Use specifics and numbers.
17
+ - Flag unknowns clearly.
18
+ - Ask only high-leverage follow-up questions.
19
+ - Help the member form their own judgment.
20
+ - Do not act like your opinion is authoritative.
21
+ - Do not tell the member what to invest in.
22
+
23
+ You are allowed to share takes. Your take exists to start a better conversation.
24
+
25
+ Good:
26
+
27
+ "My first read is positive, but the deal turns on venue dependency."
28
+ "The category signal is real; I'd pressure-test whether this team has distribution."
29
+ "I might be wrong if the founder has a non-obvious GTM wedge."
30
+
31
+ Bad:
32
+
33
+ "This is a good deal."
34
+ "You should invest."
35
+ "The right answer is to push."
36
+
37
+ ## Opinion Format
38
+
39
+ When sharing a take, use this structure:
40
+
41
+ 1. First read: positive / negative / mixed / unclear
42
+ 2. Why: 1-3 strongest reasons
43
+ 3. Main risk or open question
44
+ 4. What to pull on next
45
+
46
+ Example:
47
+
48
+ "First read: worth digging in. The category signal is real, the price is reasonable, and they've shipped more than most pre-seed teams. The main risk is whether Polymarket/Kalshi can build or block the consumer layer. I'd pull on venue relationships first."
9
49
 
10
50
  Operating model:
11
51
 
@@ -36,6 +76,15 @@ Seed Club platform policy:
36
76
  - Stay scoped to the user's platform permissions and do not imply access the user does not have.
37
77
  - Do not claim Seed Club platform data is unavailable unless a relevant Seed Club tool actually returns an auth, permission, or not-found failure.
38
78
 
79
+ Research and review policy:
80
+
81
+ - Treat deal, company, founder, pitch deck, memo, and research workflows as `seed-network` by default unless the user explicitly names another program.
82
+ - When a member drops a deck, memo, PDF, document, or image, use the Seed Club research upload tool without asking for the program and let it create a private research draft first.
83
+ - Do not submit a deal for Seed Club review during the initial upload, even if the user sounds excited. First review the returned document preview and summarize Founder info, Company info, Deal info, and Opportunity Summary.
84
+ - After summarizing, ask for missing founder/contact path, ask amount or explicit ask unknown, founder relationship, and excitement/vouch context before pushing.
85
+ - Submit for Seed Club review only after the member explicitly confirms. Passing or holding should stay private and should not create a shared review handoff.
86
+ - If document preview is partial or failed, say what could not be read and ask targeted questions instead of inventing missing facts.
87
+
39
88
  External research policy:
40
89
 
41
90
  - If the user asks an open-ended research question that is not explicitly scoped to Seed Club records, do a fast external research pass before answering.
@@ -57,6 +57,45 @@ async function setSeedEnvironment(mode: "local" | "prod", ctx: any) {
57
57
  );
58
58
  }
59
59
 
60
+ async function getDefaultProgramSlug() {
61
+ const session = await getSessionContext();
62
+ if ("error" in session || !Array.isArray(session.program_access)) return "<program-slug>";
63
+ return session.program_access[0]?.program?.slug || "<program-slug>";
64
+ }
65
+
66
+ async function prefillEditor(ctx: any, text: string) {
67
+ await new Promise((r) => setTimeout(r, 300));
68
+ ctx.ui.setEditorText(text);
69
+ }
70
+
71
+ async function showCalendarMenu(ctx: any, deps: SeedclubDeps) {
72
+ const choice = await ctx.ui.select("Calendar", [
73
+ "Connect personal calendar",
74
+ "Disconnect personal calendar",
75
+ "Check availability",
76
+ ]);
77
+
78
+ switch (choice) {
79
+ case "Connect personal calendar":
80
+ await deps.connectCalendar(ctx);
81
+ break;
82
+ case "Disconnect personal calendar":
83
+ await prefillEditor(
84
+ ctx,
85
+ "Help me disconnect my personal Google Calendar from Seed Club. Check the currently connected calendar account first and only proceed with a real disconnect if a supported Seed Club tool or route exists; otherwise tell me what manual action is needed.",
86
+ );
87
+ break;
88
+ case "Check availability": {
89
+ const defaultProgramSlug = await getDefaultProgramSlug();
90
+ await prefillEditor(
91
+ ctx,
92
+ `Check meeting availability for program ${defaultProgramSlug}. Ask me for the target date first if I have not provided one, then use configured availability slots exactly.`,
93
+ );
94
+ break;
95
+ }
96
+ }
97
+ }
98
+
60
99
  export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
61
100
  pi.registerCommand("connect", {
62
101
  description: "Connect your Seed Club account",
@@ -105,6 +144,83 @@ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
105
144
  },
106
145
  });
107
146
 
147
+ pi.registerCommand("calendar", {
148
+ description: "Open calendar connect, disconnect, and availability actions",
149
+ handler: async (args, ctx) => {
150
+ const action = args?.trim().toLowerCase();
151
+ if (action === "connect") {
152
+ await deps.connectCalendar(ctx);
153
+ return;
154
+ }
155
+ if (action === "disconnect") {
156
+ await prefillEditor(
157
+ ctx,
158
+ "Help me disconnect my personal Google Calendar from Seed Club. Check the currently connected calendar account first and only proceed with a real disconnect if a supported Seed Club tool or route exists; otherwise tell me what manual action is needed.",
159
+ );
160
+ return;
161
+ }
162
+ if (action === "availability") {
163
+ const defaultProgramSlug = await getDefaultProgramSlug();
164
+ await prefillEditor(
165
+ ctx,
166
+ `Check meeting availability for program ${defaultProgramSlug}. Ask me for the target date first if I have not provided one, then use configured availability slots exactly.`,
167
+ );
168
+ return;
169
+ }
170
+ await showCalendarMenu(ctx, deps);
171
+ },
172
+ });
173
+
174
+ pi.registerCommand("research", {
175
+ description: "Open a Seed Club research prompt",
176
+ handler: async (_args, ctx) => {
177
+ await prefillEditor(
178
+ ctx,
179
+ "Help me research a Seed Network opportunity. Ask me for the company, deck, memo, source URL, or local file if needed. Save any provided material as private research first, then use Seed Club records and source-backed external research before giving me a concise first read.",
180
+ );
181
+ },
182
+ });
183
+
184
+ pi.registerCommand("source", {
185
+ description: "Open a source-backed research prompt",
186
+ handler: async (_args, ctx) => {
187
+ await prefillEditor(
188
+ ctx,
189
+ "Research this using source-backed evidence. Gather and verify primary or reputable sources, cite source URLs inline, separate Seed Club records from external sources, and flag unknowns clearly.",
190
+ );
191
+ },
192
+ });
193
+
194
+ pi.registerCommand("worldview", {
195
+ description: "Open an investing worldview prompt",
196
+ handler: async (_args, ctx) => {
197
+ await prefillEditor(
198
+ ctx,
199
+ "Summarize my current Seed Club investing worldview from recent memory and Seed Club context. Focus on beliefs, decision heuristics, and places where my view has changed. Flag what evidence supports each point.",
200
+ );
201
+ },
202
+ });
203
+
204
+ pi.registerCommand("theses", {
205
+ description: "Open an investment theses prompt",
206
+ handler: async (_args, ctx) => {
207
+ await prefillEditor(
208
+ ctx,
209
+ "Draft my current investment theses from Seed Club memory and recent deal context. Group by thesis, include supporting signals, counterexamples, and what would change my mind.",
210
+ );
211
+ },
212
+ });
213
+
214
+ pi.registerCommand("concerns", {
215
+ description: "Open an investment concerns prompt",
216
+ handler: async (_args, ctx) => {
217
+ await prefillEditor(
218
+ ctx,
219
+ "List my current recurring investment concerns from Seed Club memory and recent deal context. Group concerns by theme, name the pattern, cite representative context when available, and separate known concerns from guesses.",
220
+ );
221
+ },
222
+ });
223
+
108
224
  pi.registerCommand("seedclub", {
109
225
  description: "Seed Club",
110
226
  handler: async (args, ctx) => {
@@ -32,6 +32,7 @@ import {
32
32
  } from "./recent-entities.js";
33
33
  import { getCurrentUser, getSessionContext, registerUtilityTools } from "./tools/utility.js";
34
34
  import { registerCrmTools } from "./tools/crm.js";
35
+ import { registerDealSourcingTools } from "./tools/deal-sourcing.js";
35
36
  import { registerMeetingTools } from "./tools/meetings.js";
36
37
  import { registerMediaTools } from "./tools/media.js";
37
38
  import { registerWebTools } from "./tools/web.js";
@@ -208,6 +209,7 @@ export default function (pi: ExtensionAPI) {
208
209
  // Tools
209
210
  registerUtilityTools(pi);
210
211
  registerCrmTools(pi);
212
+ registerDealSourcingTools(pi);
211
213
  registerMeetingTools(pi);
212
214
  registerMediaTools(pi);
213
215
  registerWebTools(pi);
@@ -433,6 +435,7 @@ export default function (pi: ExtensionAPI) {
433
435
 
434
436
  await applyConnectedStatus(ctx, user);
435
437
  void refreshPromptContext();
438
+ void memory.enableByInstallDefault(ctx);
436
439
  markAuthComplete(getPostAuthInstruction(ctx));
437
440
  return user;
438
441
  }
@@ -451,7 +454,7 @@ export default function (pi: ExtensionAPI) {
451
454
  markAuthInProgress({ message: "Checking Seed Club access..." });
452
455
  }
453
456
  void ensureSeedclubAuthenticated(ctx).then((authenticated) => {
454
- if (authenticated) void memory.refreshStatus(ctx, { notifySetup: true });
457
+ if (authenticated) void memory.enableByInstallDefault(ctx);
455
458
  }).catch((error) => {
456
459
  const message = error instanceof Error ? error.message : "Unable to verify Seed Club access.";
457
460
  markAuthRequired({
@@ -679,7 +682,7 @@ export default function (pi: ExtensionAPI) {
679
682
  await storeToken(token, result.email, apiBase, { authBase, name: result.name });
680
683
  await applyConnectedStatus(ctx, result);
681
684
  await refreshPromptContext();
682
- void memory.refreshStatus(ctx, { notifySetup: true });
685
+ void memory.enableByInstallDefault(ctx);
683
686
  const nextStep = getPostAuthInstruction(ctx);
684
687
  markAuthComplete(nextStep);
685
688
  if (options?.notifyOnSuccess) {
@@ -48,6 +48,14 @@ export async function fetchMemoryContextFromApi(apiClient, payload, timeoutMs) {
48
48
  );
49
49
  }
50
50
 
51
+ export async function fetchMemoryRecentFromApi(apiClient, payload, timeoutMs) {
52
+ return requestWithTimeout(
53
+ apiClient.post("/agent-memory/recent", payload),
54
+ timeoutMs,
55
+ "Seed Club recent memory",
56
+ );
57
+ }
58
+
51
59
  export async function writeMemoryEventToApi(apiClient, payload, timeoutMs) {
52
60
  return requestWithTimeout(
53
61
  apiClient.post("/agent-memory/events", payload),
@@ -1,9 +1,12 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
4
7
  import { NotConnectedError, api } from "./api-client.js";
5
8
  import {
6
- fetchMemoryContextFromApi,
9
+ fetchMemoryRecentFromApi,
7
10
  fetchMemoryStatusFromApi,
8
11
  requestWithTimeout,
9
12
  setMemoryEnabledWithApi,
@@ -13,7 +16,6 @@ import {
13
16
  buildMemoryStatusLines,
14
17
  detectWorkflowTags,
15
18
  extractTextFromMessage,
16
- formatMemoryPromptBlock,
17
19
  getMemoryStatusLabel,
18
20
  truncateAssistantText,
19
21
  uniqueToolNames,
@@ -22,8 +24,9 @@ import {
22
24
  const MEMORY_CONTEXT_TIMEOUT_MS = 20000;
23
25
  const MEMORY_STATUS_TIMEOUT_MS = 20000;
24
26
  const MEMORY_WRITE_TIMEOUT_MS = 20000;
25
- const MEMORY_CONTEXT_MAX_TOKENS = 900;
26
27
  const MEMORY_SESSION_STRATEGY = String(process.env.SEEDCLUB_MEMORY_SESSION_STRATEGY ?? "repo").toLowerCase();
28
+ const MEMORY_DEFAULT_ENABLED_KEY = "seedclubMemoryDefaultEnabled";
29
+ const MEMORY_DEFAULT_APPLIED_KEY = "seedclubMemoryDefaultApplied";
27
30
 
28
31
  type MemoryStatus = {
29
32
  available: boolean;
@@ -32,19 +35,11 @@ type MemoryStatus = {
32
35
  workspaceSlug?: string;
33
36
  };
34
37
 
35
- type MemoryContextResponse = {
36
- memoryBlock?: string;
37
- sourceCounts?: {
38
- user?: number;
39
- team?: number;
40
- workflow?: number;
41
- };
42
- };
43
-
44
38
  type ProgramMemorySlug = "11am" | "seed-network";
45
39
 
46
40
  type MemoryController = {
47
41
  refreshStatus: (ctx: any, options?: { notifySetup?: boolean }) => Promise<MemoryStatus | null>;
42
+ enableByInstallDefault: (ctx: any) => Promise<MemoryStatus | null>;
48
43
  clear: (ctx: any) => void;
49
44
  showMenu: (ctx: any) => Promise<void>;
50
45
  };
@@ -94,6 +89,33 @@ function stableId(scope: string, value: string) {
94
89
  return `seedclub_${scope}_${hash}`;
95
90
  }
96
91
 
92
+ function getSettingsPath() {
93
+ const agentDir = process.env.SEEDCLUB_CODING_AGENT_DIR || process.env.SEEDCLUB_AGENT_DIR || join(homedir(), ".seedclub", "agent");
94
+ return join(agentDir, "settings.json");
95
+ }
96
+
97
+ async function readMemoryDefaultSettings() {
98
+ try {
99
+ const raw = JSON.parse(await readFile(getSettingsPath(), "utf8"));
100
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
101
+ return { settings: null, enabled: false, applied: true };
102
+ }
103
+ return {
104
+ settings: raw as Record<string, unknown>,
105
+ enabled: raw[MEMORY_DEFAULT_ENABLED_KEY] === true,
106
+ applied: raw[MEMORY_DEFAULT_APPLIED_KEY] === true,
107
+ };
108
+ } catch {
109
+ return { settings: null, enabled: false, applied: true };
110
+ }
111
+ }
112
+
113
+ async function markMemoryDefaultApplied(settings: Record<string, unknown> | null) {
114
+ if (!settings) return;
115
+ settings[MEMORY_DEFAULT_APPLIED_KEY] = true;
116
+ await writeFile(getSettingsPath(), `${JSON.stringify(settings, null, 2)}\n`);
117
+ }
118
+
97
119
  function updateMemoryStatus(ctx: any, state: { status: MemoryStatus | null; lastError: string | null }) {
98
120
  try {
99
121
  ctx?.ui?.setStatus?.("memory", getMemoryStatusLabel({
@@ -144,7 +166,7 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
144
166
  ctx.ui.notify(`Seed Club memory is unavailable.${reason} Use /memory status for details.`, "info");
145
167
  }
146
168
  if (status.available && status.enabled) {
147
- void refreshMemoryCache(ctx, { prompt: "" });
169
+ void refreshSessionMemorySnapshot(ctx);
148
170
  } else {
149
171
  state.cachedMemoryBlock = null;
150
172
  }
@@ -185,7 +207,7 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
185
207
  );
186
208
  state.status = status;
187
209
  state.lastError = null;
188
- if (enabled && status.available) void refreshMemoryCache(ctx, { prompt: "" });
210
+ if (enabled && status.available) void refreshSessionMemorySnapshot(ctx);
189
211
  if (!enabled) state.cachedMemoryBlock = null;
190
212
  updateMemoryStatus(ctx, state);
191
213
  ctx.ui.notify(enabled ? "Memory enabled." : "Memory disabled.", "info");
@@ -196,37 +218,89 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
196
218
  }
197
219
  }
198
220
 
221
+ async function enableByInstallDefault(ctx: any) {
222
+ const defaultSettings = await readMemoryDefaultSettings();
223
+ if (!defaultSettings.enabled || defaultSettings.applied) return refreshStatus(ctx, { notifySetup: true });
224
+
225
+ try {
226
+ let status = await refreshStatus(ctx, { notifySetup: true });
227
+ if (!status?.available) return status;
228
+
229
+ if (!status.enabled) {
230
+ status = await requestWithTimeout(
231
+ setMemoryEnabledWithApi(api, true) as Promise<MemoryStatus>,
232
+ MEMORY_STATUS_TIMEOUT_MS,
233
+ "Seed Club memory preference",
234
+ );
235
+ state.status = status;
236
+ state.lastError = null;
237
+ updateMemoryStatus(ctx, state);
238
+ if (status.available && status.enabled) void refreshSessionMemorySnapshot(ctx);
239
+ }
240
+
241
+ if (status.available && status.enabled) {
242
+ await markMemoryDefaultApplied(defaultSettings.settings);
243
+ }
244
+ return status;
245
+ } catch (error) {
246
+ if (error instanceof NotConnectedError) return null;
247
+ state.lastError = errorMessage(error);
248
+ updateMemoryStatus(ctx, state);
249
+ ctx.ui.notify(`Memory default setup failed: ${state.lastError}`, "warning");
250
+ return state.status;
251
+ }
252
+ }
253
+
199
254
  async function showMenu(ctx: any) {
200
255
  const choice = await ctx.ui.select("Memory", [
201
256
  "Show status",
257
+ "Show recent memory",
202
258
  "Turn memory on",
203
259
  "Turn memory off",
204
260
  ]);
205
261
  if (choice === "Show status") await showStatus(ctx);
262
+ if (choice === "Show recent memory") await showRecentMemory(ctx);
206
263
  if (choice === "Turn memory on") await setEnabled(ctx, true);
207
264
  if (choice === "Turn memory off") await setEnabled(ctx, false);
208
265
  }
209
266
 
210
- function refreshMemoryCache(ctx: any, input?: { prompt?: string }) {
267
+ async function showRecentMemory(ctx: any, args = "") {
268
+ try {
269
+ const programSlug = detectProgramSlugFromText(args);
270
+ const recent = await fetchMemoryRecentFromApi(
271
+ api,
272
+ {
273
+ sessionId: getSessionId(ctx),
274
+ cwd: ctx.cwd,
275
+ programSlug,
276
+ limit: 12,
277
+ },
278
+ MEMORY_CONTEXT_TIMEOUT_MS,
279
+ );
280
+ ctx.ui.notify(formatRecentMemoryLines(recent).join("\n"), "info");
281
+ } catch (error) {
282
+ state.lastError = errorMessage(error);
283
+ updateMemoryStatus(ctx, state);
284
+ ctx.ui.notify(`Unable to load recent memory: ${state.lastError}`, "error");
285
+ }
286
+ }
287
+
288
+ function refreshSessionMemorySnapshot(ctx: any) {
211
289
  if (!state.status?.available || !state.status.enabled) {
212
290
  state.cachedMemoryBlock = null;
213
291
  return Promise.resolve();
214
292
  }
215
- const prompt = input?.prompt ?? state.activePrompt ?? "";
216
- const programSlug = getPrimaryProgramSlug(state.programSlugs) ?? detectProgramSlugFromText(prompt);
217
- const run = fetchMemoryContextFromApi(
293
+ const run = fetchMemoryRecentFromApi(
218
294
  api,
219
295
  {
220
296
  sessionId: getSessionId(ctx),
221
297
  cwd: ctx.cwd,
222
- prompt,
223
- programSlug,
224
- maxTokens: MEMORY_CONTEXT_MAX_TOKENS,
298
+ limit: 5,
225
299
  },
226
300
  MEMORY_CONTEXT_TIMEOUT_MS,
227
301
  )
228
- .then((response: MemoryContextResponse) => {
229
- state.cachedMemoryBlock = formatMemoryPromptBlock(response.memoryBlock);
302
+ .then((recent: any) => {
303
+ state.cachedMemoryBlock = formatSessionMemoryPromptBlock(recent);
230
304
  state.lastError = null;
231
305
  updateMemoryStatus(ctx, state);
232
306
  })
@@ -243,13 +317,20 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
243
317
  }
244
318
 
245
319
  pi.registerCommand("memory", {
246
- description: "Show or change Seed Club memory status (`status`, `on`, `off`)",
320
+ description: "Show Seed Club memory status, recent memory, or turn memory on/off (`status`, `recent`, `on`, `off`)",
247
321
  handler: async (args, ctx) => {
248
- const action = args.trim().toLowerCase() || "menu";
322
+ const trimmedArgs = args.trim();
323
+ const [rawAction = "menu", ...rest] = trimmedArgs.split(/\s+/);
324
+ const action = rawAction.toLowerCase();
325
+ const actionArgs = rest.join(" ");
249
326
  if (action === "status") {
250
327
  await showStatus(ctx);
251
328
  return;
252
329
  }
330
+ if (action === "recent" || action === "resume") {
331
+ await showRecentMemory(ctx, actionArgs);
332
+ return;
333
+ }
253
334
  if (action === "on") {
254
335
  await setEnabled(ctx, true);
255
336
  return;
@@ -262,7 +343,7 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
262
343
  await showMenu(ctx);
263
344
  return;
264
345
  }
265
- ctx.ui.notify("Usage: /memory status | /memory on | /memory off", "error");
346
+ ctx.ui.notify("Usage: /memory status | /memory recent | /memory on | /memory off", "error");
266
347
  },
267
348
  });
268
349
 
@@ -271,13 +352,30 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
271
352
  void refreshStatus(ctx);
272
353
  });
273
354
 
274
- pi.on("before_agent_start", (event) => {
355
+ pi.on("before_agent_start", async (event, ctx) => {
275
356
  state.activePrompt = typeof event.prompt === "string" ? event.prompt : "";
276
357
  state.toolNames = [];
277
358
  state.programSlugs = uniqueProgramSlugs([detectProgramSlugFromText(state.activePrompt)]);
278
359
  state.turnCounter += 1;
279
360
  state.lastTurnId = `${Date.now()}-${state.turnCounter}`;
280
361
 
362
+ if (!state.status && !state.lastError) {
363
+ await refreshStatus(ctx);
364
+ }
365
+
366
+ if (shouldForceRecentMemory(state.activePrompt) && state.status?.available && state.status.enabled) {
367
+ const recentBlock = await fetchRecentMemoryPromptBlock(ctx, state.activePrompt);
368
+ if (recentBlock) {
369
+ return {
370
+ systemPrompt: `${event.systemPrompt}\n\n${recentBlock}`,
371
+ };
372
+ }
373
+ }
374
+
375
+ if (state.status?.available && state.status.enabled && !state.cachedMemoryBlock) {
376
+ await refreshSessionMemorySnapshot(ctx);
377
+ }
378
+
281
379
  if (!state.status?.available || !state.status.enabled || !state.cachedMemoryBlock) return;
282
380
  return {
283
381
  systemPrompt: `${event.systemPrompt}\n\n${state.cachedMemoryBlock}`,
@@ -314,7 +412,7 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
314
412
  },
315
413
  MEMORY_WRITE_TIMEOUT_MS,
316
414
  ).then(() => {
317
- void refreshMemoryCache(ctx, { prompt: state.activePrompt });
415
+ void refreshSessionMemorySnapshot(ctx);
318
416
  }).catch((error: unknown) => {
319
417
  state.lastError = errorMessage(error);
320
418
  updateMemoryStatus(ctx, state);
@@ -323,6 +421,7 @@ export function registerMemory(pi: ExtensionAPI): MemoryController {
323
421
 
324
422
  return {
325
423
  refreshStatus,
424
+ enableByInstallDefault,
326
425
  clear,
327
426
  showMenu,
328
427
  };
@@ -362,3 +461,155 @@ function extractAssistantTextFromAgentEnd(messages: any[]) {
362
461
  }
363
462
  return "";
364
463
  }
464
+
465
+ function shouldForceRecentMemory(prompt: string) {
466
+ return /\b(last|previous|prior|recent|yesterday|earlier)\b.*\b(chat|conversation|talk|discuss|message|session)\b/i.test(prompt) ||
467
+ /\bwhat did (i|we) (last|previously|recently) (chat|talk|discuss)\b/i.test(prompt) ||
468
+ /\bresume\b.*\b(chat|conversation|session)\b/i.test(prompt);
469
+ }
470
+
471
+ async function fetchRecentMemoryPromptBlock(ctx: any, prompt: string) {
472
+ const programSlug = detectProgramSlugFromText(prompt);
473
+ const recent = await fetchMemoryRecentFromApi(
474
+ api,
475
+ {
476
+ sessionId: getSessionId(ctx),
477
+ cwd: ctx.cwd,
478
+ programSlug,
479
+ limit: 12,
480
+ },
481
+ MEMORY_CONTEXT_TIMEOUT_MS,
482
+ );
483
+ return formatRecentMemoryPromptBlock(recent);
484
+ }
485
+
486
+ function formatRecentMemoryPromptBlock(recent: any) {
487
+ if (!recent?.available || !recent.enabled) return null;
488
+ const lines = [
489
+ "SEED CLUB MEMORY:",
490
+ "The user is asking about prior conversation history. Use the memory below as available context. Do not claim you lack access to prior conversation history when relevant memory is present.",
491
+ `Session: ${recent.displaySessionId || recent.sessionId || "unknown"}`,
492
+ `User peer: ${recent.userPeerId || "unknown"}`,
493
+ ];
494
+
495
+ const peerCard = Array.isArray(recent.peerCard) ? recent.peerCard.filter(Boolean) : [];
496
+ if (peerCard.length) {
497
+ lines.push("", "Peer card:");
498
+ for (const item of peerCard.slice(0, 8)) lines.push(`- ${truncateLine(item, 320)}`);
499
+ }
500
+
501
+ const summary = recent.contextSummary?.content || recent.summaries?.short?.content || recent.summaries?.long?.content;
502
+ if (summary) {
503
+ lines.push("", "Summary:");
504
+ lines.push(`- ${truncateLine(summary, 1200)}`);
505
+ }
506
+
507
+ const representation = typeof recent.peerRepresentation === "string" ? recent.peerRepresentation.trim() : "";
508
+ if (representation) {
509
+ lines.push("", "Peer representation:");
510
+ lines.push(`- ${truncateLine(representation, 1200)}`);
511
+ }
512
+
513
+ const messages = Array.isArray(recent.messages) ? recent.messages : [];
514
+ if (messages.length) {
515
+ lines.push("", "Recent messages:");
516
+ for (const message of messages.slice(0, 10)) {
517
+ const role = typeof message.role === "string" && message.role ? message.role : message.peerId || "memory";
518
+ lines.push(`- ${role}: ${truncateLine(message.content, 500)}`);
519
+ }
520
+ }
521
+
522
+ return lines.length > 4 ? lines.join("\n") : null;
523
+ }
524
+
525
+ function formatSessionMemoryPromptBlock(recent: any) {
526
+ if (!recent?.available || !recent.enabled) return null;
527
+ const lines = [
528
+ "SEED CLUB MEMORY:",
529
+ "Use this compact memory snapshot for continuity across Seed Club agent sessions. Treat it as background context; do not recite it unless it helps the user.",
530
+ `Session: ${recent.displaySessionId || recent.sessionId || "unknown"}`,
531
+ `User peer: ${recent.userPeerId || "unknown"}`,
532
+ ];
533
+
534
+ const peerCard = Array.isArray(recent.peerCard) ? recent.peerCard.filter(Boolean) : [];
535
+ if (peerCard.length) {
536
+ lines.push("", "User profile:");
537
+ for (const item of peerCard.slice(0, 6)) lines.push(`- ${truncateLine(item, 220)}`);
538
+ }
539
+
540
+ const representation = typeof recent.peerRepresentation === "string" ? recent.peerRepresentation.trim() : "";
541
+ if (representation) {
542
+ lines.push("", "User/work style:");
543
+ lines.push(`- ${truncateLine(representation, 500)}`);
544
+ }
545
+
546
+ const summary = recent.contextSummary?.content || recent.summaries?.short?.content || recent.summaries?.long?.content;
547
+ if (summary) {
548
+ lines.push("", "Recent conversation summary:");
549
+ lines.push(`- ${truncateLine(summary, 600)}`);
550
+ }
551
+
552
+ const messages = Array.isArray(recent.messages) ? recent.messages : [];
553
+ if (messages.length) {
554
+ lines.push("", "Recent conversation hints:");
555
+ for (const message of messages.slice(0, 3)) {
556
+ const role = typeof message.role === "string" && message.role ? message.role : message.peerId || "memory";
557
+ lines.push(`- ${role}: ${truncateLine(message.content, 180)}`);
558
+ }
559
+ }
560
+
561
+ return lines.length > 4 ? lines.join("\n") : null;
562
+ }
563
+
564
+ function formatRecentMemoryLines(recent: any) {
565
+ if (!recent?.available) {
566
+ return [`memory unavailable: ${recent?.reason || "Honcho is not configured."}`];
567
+ }
568
+ if (!recent.enabled) {
569
+ return ["memory is off"];
570
+ }
571
+
572
+ const lines = [
573
+ "recent memory",
574
+ `session: ${recent.displaySessionId || recent.sessionId || "unknown"}`,
575
+ `peer: ${recent.userPeerId || "unknown"}`,
576
+ ];
577
+ if (recent.programSlug) lines.push(`program: ${recent.programSlug}`);
578
+
579
+ const peerCard = Array.isArray(recent.peerCard) ? recent.peerCard.filter(Boolean) : [];
580
+ if (peerCard.length) {
581
+ lines.push("", "peer card:");
582
+ for (const item of peerCard.slice(0, 6)) lines.push(`- ${truncateLine(item)}`);
583
+ }
584
+
585
+ const summary = recent.contextSummary?.content || recent.summaries?.short?.content || recent.summaries?.long?.content;
586
+ if (summary) {
587
+ lines.push("", "summary:");
588
+ lines.push(`- ${truncateLine(summary, 700)}`);
589
+ }
590
+
591
+ const representation = typeof recent.peerRepresentation === "string" ? recent.peerRepresentation.trim() : "";
592
+ if (representation) {
593
+ lines.push("", "peer representation:");
594
+ lines.push(`- ${truncateLine(representation, 700)}`);
595
+ }
596
+
597
+ const messages = Array.isArray(recent.messages) ? recent.messages : [];
598
+ if (messages.length) {
599
+ lines.push("", "recent messages:");
600
+ for (const message of messages.slice(0, 8)) {
601
+ const role = typeof message.role === "string" && message.role ? message.role : message.peerId || "memory";
602
+ lines.push(`- ${role}: ${truncateLine(message.content, 260)}`);
603
+ }
604
+ } else {
605
+ lines.push("", "recent messages: none yet");
606
+ }
607
+
608
+ return lines;
609
+ }
610
+
611
+ function truncateLine(value: unknown, maxChars = 280) {
612
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
613
+ if (text.length <= maxChars) return text;
614
+ return `${text.slice(0, maxChars).trim()}...`;
615
+ }
@@ -0,0 +1,427 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
+ import { ApiError, api, NotConnectedError } from "../api-client.js";
6
+ import { makeProgressCallRenderer, makeProgressResultRenderer } from "../tool-utils.js";
7
+ import { getToolCallLabel, getToolSuccessLabel } from "../ui-copy.js";
8
+
9
+ type DealSourcingDecision = "pushing" | "passing" | "holding";
10
+
11
+ const DEFAULT_DEAL_SOURCING_PROGRAM_SLUG = "seed-network";
12
+ const MAX_DOCUMENT_PREVIEW_CHARS = 12000;
13
+ const MAX_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024;
14
+
15
+ type DocumentPreview = {
16
+ error?: string;
17
+ extraction_status: "completed" | "failed" | "unsupported";
18
+ file_name: string;
19
+ image?: { data: string; mimeType: string };
20
+ image_attached?: boolean;
21
+ kind: "image" | "pdf" | "text" | "unsupported";
22
+ mime_type: string;
23
+ text?: string | null;
24
+ truncated?: boolean;
25
+ };
26
+
27
+ function normalizeOptionalString(value: unknown): string | null | undefined {
28
+ if (value === undefined) return undefined;
29
+ if (value == null) return null;
30
+ if (typeof value !== "string") return undefined;
31
+ const trimmed = value.trim();
32
+ return trimmed ? trimmed : null;
33
+ }
34
+
35
+ function normalizeOptionalNumber(value: unknown): number | null | undefined {
36
+ if (value === undefined) return undefined;
37
+ if (value == null) return null;
38
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
39
+ return value;
40
+ }
41
+
42
+ function normalizeStringArray(value: unknown): string[] | undefined {
43
+ if (value === undefined) return undefined;
44
+ if (!Array.isArray(value)) return [];
45
+ return value
46
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function clipPreviewText(value: string) {
51
+ const normalized = value.replace(/\u0000/g, "").trim();
52
+ if (normalized.length <= MAX_DOCUMENT_PREVIEW_CHARS) {
53
+ return { text: normalized, truncated: false };
54
+ }
55
+
56
+ return {
57
+ text: normalized.slice(0, MAX_DOCUMENT_PREVIEW_CHARS),
58
+ truncated: true,
59
+ };
60
+ }
61
+
62
+ async function extractPdfText(bytes: Buffer) {
63
+ const { PDFParse } = await import("pdf-parse");
64
+ const parser = new PDFParse({ data: bytes });
65
+ try {
66
+ const result = await parser.getText();
67
+ return typeof result?.text === "string" ? result.text : "";
68
+ } finally {
69
+ await parser.destroy().catch(() => undefined);
70
+ }
71
+ }
72
+
73
+ async function buildDocumentPreview(fileName: string, contentType: string, bytes: Buffer): Promise<DocumentPreview> {
74
+ const mimeType = contentType.split(";")[0]?.trim().toLowerCase() || "application/octet-stream";
75
+
76
+ if (mimeType.startsWith("image/")) {
77
+ if (bytes.byteLength > MAX_IMAGE_PREVIEW_BYTES) {
78
+ return {
79
+ error: "Image is too large to attach to the model preview.",
80
+ extraction_status: "failed",
81
+ file_name: fileName,
82
+ image_attached: false,
83
+ kind: "image",
84
+ mime_type: mimeType,
85
+ text: null,
86
+ };
87
+ }
88
+
89
+ return {
90
+ extraction_status: "completed",
91
+ file_name: fileName,
92
+ image: {
93
+ data: bytes.toString("base64"),
94
+ mimeType,
95
+ },
96
+ image_attached: true,
97
+ kind: "image",
98
+ mime_type: mimeType,
99
+ text: "Image attached for visual review. Summarize visible company, founder, deal, traction, and open-question context from the image.",
100
+ };
101
+ }
102
+
103
+ if (mimeType === "application/pdf") {
104
+ try {
105
+ const { text, truncated } = clipPreviewText(await extractPdfText(bytes));
106
+ return {
107
+ extraction_status: text ? "completed" : "failed",
108
+ error: text ? undefined : "No text could be extracted from the PDF. It may be image-heavy.",
109
+ file_name: fileName,
110
+ kind: "pdf",
111
+ mime_type: mimeType,
112
+ text,
113
+ truncated,
114
+ };
115
+ } catch (error) {
116
+ return {
117
+ error: error instanceof Error ? error.message : String(error),
118
+ extraction_status: "failed",
119
+ file_name: fileName,
120
+ kind: "pdf",
121
+ mime_type: mimeType,
122
+ text: null,
123
+ };
124
+ }
125
+ }
126
+
127
+ if (mimeType === "text/plain" || mimeType.startsWith("text/")) {
128
+ const { text, truncated } = clipPreviewText(bytes.toString("utf8"));
129
+ return {
130
+ extraction_status: "completed",
131
+ file_name: fileName,
132
+ kind: "text",
133
+ mime_type: mimeType,
134
+ text,
135
+ truncated,
136
+ };
137
+ }
138
+
139
+ return {
140
+ error: "Local preview is not supported for this document type yet.",
141
+ extraction_status: "unsupported",
142
+ file_name: fileName,
143
+ kind: "unsupported",
144
+ mime_type: mimeType,
145
+ text: null,
146
+ };
147
+ }
148
+
149
+ function suggestedReviewFields() {
150
+ return [
151
+ "Founder info",
152
+ "Company info",
153
+ "Deal info, including ask or explicit ask unknown",
154
+ "Opportunity Summary",
155
+ "Founder/contact path or explicit unknown",
156
+ "Member relationship and excitement/vouch before push",
157
+ ];
158
+ }
159
+
160
+ function redactPreviewForJson(preview: DocumentPreview | undefined) {
161
+ if (!preview) return preview;
162
+ const { image, ...rest } = preview;
163
+ return {
164
+ ...rest,
165
+ image_attached: preview.image_attached ?? Boolean(image),
166
+ };
167
+ }
168
+
169
+ function inferDocumentContentType(fileName: string) {
170
+ const lower = fileName.toLowerCase();
171
+ if (lower.endsWith(".pdf")) return "application/pdf";
172
+ if (lower.endsWith(".png")) return "image/png";
173
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
174
+ if (lower.endsWith(".webp")) return "image/webp";
175
+ if (lower.endsWith(".gif")) return "image/gif";
176
+ if (lower.endsWith(".ppt")) return "application/vnd.ms-powerpoint";
177
+ if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
178
+ if (lower.endsWith(".doc")) return "application/msword";
179
+ if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
180
+ if (lower.endsWith(".txt")) return "text/plain";
181
+ return "application/octet-stream";
182
+ }
183
+
184
+ function normalizeDecision(value: unknown): DealSourcingDecision | null {
185
+ if (value === "pushing" || value === "passing" || value === "holding") return value;
186
+ return null;
187
+ }
188
+
189
+ async function submitDraftPosition(programSlug: string, draftId: string, args: any, decision: DealSourcingDecision) {
190
+ return api.post<any>(`/programs/${programSlug}/deal-sourcing/drafts/${draftId}/member-position`, {
191
+ ask_unknown: args.askUnknown === true,
192
+ contact_info: normalizeOptionalString(args.contactInfo),
193
+ contact_unknown: args.contactUnknown === true,
194
+ decision,
195
+ excitement_reason: normalizeOptionalString(args.excitementReason),
196
+ flagged_topics: normalizeStringArray(args.flaggedTopics),
197
+ founder_names: normalizeStringArray(args.founderNames),
198
+ founder_relationship: normalizeOptionalString(args.founderRelationship),
199
+ founder_unknown: args.founderUnknown === true,
200
+ target_amount: normalizeOptionalNumber(args.targetAmount),
201
+ vouch_text: normalizeOptionalString(args.vouchText),
202
+ });
203
+ }
204
+
205
+ async function submitDealSourcingDeck(args: {
206
+ askUnknown?: boolean | null;
207
+ programSlug?: string | null;
208
+ filePath?: string | null;
209
+ assetKind?: "deck" | "memo" | "supporting_material" | null;
210
+ companyName?: string | null;
211
+ companyWebsiteUrl?: string | null;
212
+ contentType?: string | null;
213
+ contactInfo?: string | null;
214
+ contactUnknown?: boolean | null;
215
+ decision?: DealSourcingDecision | null;
216
+ draftId?: string | null;
217
+ excitementReason?: string | null;
218
+ fileName?: string | null;
219
+ flaggedTopics?: string[] | null;
220
+ founderNames?: string[] | null;
221
+ founderRelationship?: string | null;
222
+ founderUnknown?: boolean | null;
223
+ roundType?: string | null;
224
+ sourceUrl?: string | null;
225
+ targetAmount?: number | null;
226
+ valuation?: number | null;
227
+ vouchText?: string | null;
228
+ }, runtime?: any) {
229
+ try {
230
+ const programSlug = normalizeOptionalString(args.programSlug) ?? DEFAULT_DEAL_SOURCING_PROGRAM_SLUG;
231
+ const existingDraftId = normalizeOptionalString(args.draftId);
232
+ const existingDecision = normalizeDecision(args.decision);
233
+ if (existingDraftId) {
234
+ if (!existingDecision) {
235
+ return { draft: { id: existingDraftId }, next_action: "Ask the member whether they want to push/source this draft, pass/reject it, or hold it for later." };
236
+ }
237
+
238
+ runtime?.setWorkingMessage?.("Saving deal sourcing draft position...");
239
+ const positionResponse = await submitDraftPosition(programSlug, existingDraftId, args, existingDecision);
240
+ return { draft: { id: existingDraftId }, draft_position_result: positionResponse };
241
+ }
242
+
243
+ const filePath = args.filePath;
244
+ if (!filePath) {
245
+ return { error: "filePath is required when draftId is not provided." };
246
+ }
247
+
248
+ const fileStats = await stat(filePath);
249
+ if (!fileStats.isFile()) {
250
+ return { error: "filePath must point to a file." };
251
+ }
252
+
253
+ const fileName = normalizeOptionalString(args.fileName) ?? basename(filePath);
254
+ const contentType = normalizeOptionalString(args.contentType) ?? inferDocumentContentType(fileName);
255
+ const assetKind = normalizeOptionalString(args.assetKind) ?? "deck";
256
+ runtime?.setWorkingMessage?.("Requesting deal document upload target...");
257
+ const ingestResponse = await api.post<any>(`/programs/${programSlug}/deal-sourcing/document-ingests`, {
258
+ asset_kind: assetKind,
259
+ content_type: contentType,
260
+ file_name: fileName,
261
+ });
262
+
263
+ const uploadTarget = ingestResponse?.document_upload;
264
+ const uploadUrl = typeof uploadTarget?.upload_url === "string" ? uploadTarget.upload_url : null;
265
+ const objectKey = typeof uploadTarget?.object_key === "string" ? uploadTarget.object_key : null;
266
+ const storageUrl = typeof uploadTarget?.storage_url === "string" ? uploadTarget.storage_url : null;
267
+ const uploadId = typeof ingestResponse?.upload?.id === "string" ? ingestResponse.upload.id : null;
268
+
269
+ if (!uploadUrl || !objectKey || !uploadId) {
270
+ return { error: "The API did not return a valid deal document upload target.", response: ingestResponse };
271
+ }
272
+
273
+ runtime?.setWorkingMessage?.("Uploading deal document bytes...");
274
+ const bytes = await readFile(filePath);
275
+ const documentPreview = await buildDocumentPreview(fileName, contentType, bytes);
276
+ const uploadHeaders: Record<string, string> = {};
277
+ if (uploadTarget?.headers && typeof uploadTarget.headers === "object") {
278
+ for (const [key, value] of Object.entries(uploadTarget.headers)) {
279
+ if (typeof value === "string") uploadHeaders[key.toLowerCase()] = value;
280
+ }
281
+ }
282
+ uploadHeaders["content-type"] = uploadHeaders["content-type"] ?? contentType;
283
+
284
+ const uploadResponse = await fetch(uploadUrl, {
285
+ method: "PUT",
286
+ headers: uploadHeaders,
287
+ body: bytes,
288
+ });
289
+
290
+ if (!uploadResponse.ok) {
291
+ const text = await uploadResponse.text().catch(() => "");
292
+ return {
293
+ error: `Spaces upload failed (${uploadResponse.status}).`,
294
+ details: text.slice(0, 500),
295
+ };
296
+ }
297
+
298
+ runtime?.setWorkingMessage?.("Creating private research draft...");
299
+ const completeResponse = await api.post<any>(`/programs/${programSlug}/deal-sourcing/document-ingests/${uploadId}/complete`, {
300
+ asset_kind: assetKind,
301
+ company_name: normalizeOptionalString(args.companyName),
302
+ company_website_url: normalizeOptionalString(args.companyWebsiteUrl),
303
+ content_type: contentType,
304
+ file_name: fileName,
305
+ file_size: fileStats.size,
306
+ object_key: objectKey,
307
+ round_type: normalizeOptionalString(args.roundType),
308
+ source_url: normalizeOptionalString(args.sourceUrl),
309
+ storage_url: storageUrl,
310
+ target_amount: normalizeOptionalNumber(args.targetAmount),
311
+ valuation: normalizeOptionalNumber(args.valuation),
312
+ });
313
+
314
+ const decision = normalizeDecision(args.decision);
315
+ return {
316
+ ...completeResponse,
317
+ decision_ignored_until_review: Boolean(decision),
318
+ document_preview: documentPreview,
319
+ next_action: decision
320
+ ? "The document is saved as a private draft. Review the material, summarize Founder info, Company info, Deal info, and Opportunity Summary, then ask missing questions before submitting the member decision."
321
+ : "Review the material, summarize Founder info, Company info, Deal info, and Opportunity Summary, then ask whether the member wants to push, pass, or hold after missing context is resolved.",
322
+ suggested_review_fields: suggestedReviewFields(),
323
+ upload: {
324
+ ...completeResponse?.upload,
325
+ storage_url: storageUrl ?? completeResponse?.upload?.storage_url ?? null,
326
+ },
327
+ };
328
+ } catch (error) {
329
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
330
+ throw error;
331
+ }
332
+ }
333
+
334
+ function makeDealSourcingExecute(fn: typeof submitDealSourcingDeck) {
335
+ return async (_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any) => {
336
+ const runtime = {
337
+ ctx,
338
+ setWorkingMessage: (message: string) => {
339
+ try {
340
+ ctx?.ui?.setWorkingMessage?.(message || undefined);
341
+ } catch {
342
+ // Ignore UI status failures so tool execution still succeeds.
343
+ }
344
+ },
345
+ clearWorkingMessage: () => {
346
+ try {
347
+ ctx?.ui?.setWorkingMessage?.(undefined);
348
+ } catch {
349
+ // Ignore UI status failures so tool execution still succeeds.
350
+ }
351
+ },
352
+ };
353
+
354
+ try {
355
+ const result = await fn(params, runtime);
356
+ const image = result?.document_preview?.image;
357
+ const details = {
358
+ ...result,
359
+ document_preview: redactPreviewForJson(result?.document_preview),
360
+ };
361
+ const content: any[] = [{ type: "text", text: JSON.stringify(details, null, 2) }];
362
+ if (image?.data && image?.mimeType) {
363
+ content.push({ type: "image", data: image.data, mimeType: image.mimeType });
364
+ }
365
+
366
+ return { content, details };
367
+ } catch (error) {
368
+ if (error instanceof NotConnectedError) {
369
+ return {
370
+ content: [{ type: "text" as const, text: "Not connected to Seed Club API. Run /connect or /seedclub to authenticate." }],
371
+ details: { notConnected: true },
372
+ isError: true,
373
+ };
374
+ }
375
+
376
+ const message = error instanceof Error ? error.message : String(error);
377
+ return {
378
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
379
+ details: { error: message },
380
+ isError: true,
381
+ };
382
+ } finally {
383
+ runtime.clearWorkingMessage();
384
+ }
385
+ };
386
+ }
387
+
388
+ export function registerDealSourcingTools(pi: ExtensionAPI) {
389
+ pi.registerTool({
390
+ name: "seedclub_submit_deal_sourcing_deck",
391
+ label: "Submit Deal Sourcing Document",
392
+ description:
393
+ "Upload a local deck, memo, document, or image to seedclub-api as a private/team-private deal research draft, or submit pushing/passing/holding for an existing draftId. programSlug defaults to seed-network. Initial uploads always create a private draft first and return document preview context; do not push immediately from an upload. After reviewing and confirming with the member, use draftId plus a decision. If decision is pushing, provide founderNames or founderUnknown, founderRelationship, excitementReason or vouchText, contactInfo or contactUnknown, and targetAmount or askUnknown.",
394
+ parameters: Type.Object({
395
+ askUnknown: Type.Optional(Type.Union([Type.Boolean(), Type.Null()], { description: "Set true when the member explicitly says the round ask is unknown." })),
396
+ programSlug: Type.Optional(Type.Union([Type.String({ description: "Program slug for the investing/deal pipeline program. Defaults to seed-network." }), Type.Null()])),
397
+ filePath: Type.Optional(Type.Union([Type.String({ description: "Local deck, memo, document, or image path. Required unless draftId is provided." }), Type.Null()])),
398
+ assetKind: Type.Optional(Type.Union([Type.Literal("deck"), Type.Literal("memo"), Type.Literal("supporting_material"), Type.Null()])),
399
+ companyName: Type.Optional(Type.Union([Type.String(), Type.Null()])),
400
+ companyWebsiteUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])),
401
+ contentType: Type.Optional(Type.Union([Type.String(), Type.Null()])),
402
+ contactInfo: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Founder/company contact info supplied by the member if not present in the uploaded material." })),
403
+ contactUnknown: Type.Optional(Type.Union([Type.Boolean(), Type.Null()], { description: "Set true when the member explicitly says contact info is unknown." })),
404
+ decision: Type.Optional(Type.Union([Type.Literal("pushing"), Type.Literal("passing"), Type.Literal("holding"), Type.Null()])),
405
+ draftId: Type.Optional(Type.Union([Type.String({ description: "Existing private deal draft id to update without re-uploading the file." }), Type.Null()])),
406
+ excitementReason: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Why the member is excited about the company/founder." })),
407
+ fileName: Type.Optional(Type.Union([Type.String(), Type.Null()])),
408
+ flaggedTopics: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
409
+ founderNames: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()], { description: "Founder names supplied or corrected by the member." })),
410
+ founderRelationship: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "How the member knows the founder." })),
411
+ founderUnknown: Type.Optional(Type.Union([Type.Boolean(), Type.Null()], { description: "Set true when the member explicitly says founder names are unknown." })),
412
+ roundType: Type.Optional(Type.Union([Type.String(), Type.Null()])),
413
+ sourceUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])),
414
+ targetAmount: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
415
+ valuation: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
416
+ vouchText: Type.Optional(Type.Union([Type.String(), Type.Null()])),
417
+ }),
418
+ execute: makeDealSourcingExecute(submitDealSourcingDeck),
419
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_submit_deal_sourcing_deck"), (args) => args?.companyName || args?.draftId || args?.filePath || undefined),
420
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_submit_deal_sourcing_deck"), (details) => {
421
+ const company = details?.company?.name ?? details?.draft_position_result?.funding_round?.organization_id ?? null;
422
+ const state = details?.draft_position_result?.funding_round?.lifecycle_state ?? details?.draft?.status ?? details?.draft_position_result?.draft?.status ?? null;
423
+ const id = details?.draft_position_result?.funding_round?.id ?? details?.draft?.id ?? details?.draft_position_result?.draft?.id ?? null;
424
+ return [company, state, id].filter(Boolean).join(" · ") || undefined;
425
+ }),
426
+ });
427
+ }
@@ -9,6 +9,9 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
9
9
  seedclub_get_crm_record: "Loading CRM record",
10
10
  seedclub_create_crm_note: "Saving CRM note",
11
11
  seedclub_create_crm_task: "Creating CRM task",
12
+ seedclub_save_research: "Saving research",
13
+ seedclub_research_opportunity: "Researching opportunity",
14
+ seedclub_get_research: "Loading research",
12
15
  seedclub_list_program_contacts: "Loading program contacts",
13
16
  seedclub_list_program_funnels: "Loading program funnels",
14
17
  seedclub_create_funnel_stage: "Creating funnel stage",
@@ -57,6 +60,9 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
57
60
  seedclub_get_crm_record: "CRM record loaded",
58
61
  seedclub_create_crm_note: "CRM note saved",
59
62
  seedclub_create_crm_task: "CRM task created",
63
+ seedclub_save_research: "Research saved",
64
+ seedclub_research_opportunity: "Research queued",
65
+ seedclub_get_research: "Research loaded",
60
66
  seedclub_list_program_contacts: "Program contacts loaded",
61
67
  seedclub_list_program_funnels: "Program funnels loaded",
62
68
  seedclub_create_funnel_stage: "Funnel stage created",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubnet/seedclub",
3
- "version": "0.2.38",
3
+ "version": "0.2.39",
4
4
  "description": "A branded command-line agent wrapper around pi, with integrated Seed Club commands, tools, and app actions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,11 +23,12 @@
23
23
  "upgrade:pi": "node scripts/upgrade-pi.mjs"
24
24
  },
25
25
  "dependencies": {
26
+ "@mariozechner/pi-coding-agent": "0.65.2",
26
27
  "@sinclair/typebox": "^0.34.49",
27
- "@mariozechner/pi-coding-agent": "0.65.2"
28
+ "pdf-parse": "^2.4.5"
28
29
  },
29
30
  "engines": {
30
- "node": ">=22.0.0"
31
+ "node": ">=22.3.0"
31
32
  },
32
33
  "files": [
33
34
  "bin/",
@@ -961,14 +961,28 @@ function shouldShowCommandInList(name) {
961
961
 
962
962
  function getBuiltInSlashCommands() {
963
963
  return [
964
- { name: "commands", description: "List TUI and extension commands" },
964
+ { name: "new", description: "Start a new session" },
965
965
  { name: "compact", description: "Compact the current session context" },
966
- { name: "extensions", description: "List loaded extensions" },
966
+ { name: "clear", description: "Clear the visible chat transcript" },
967
+ { name: "calendar", description: "Open calendar connect and availability actions" },
968
+ { name: "research", description: "Open a Seed Club research prompt" },
969
+ { name: "source", description: "Open a source-backed research prompt" },
970
+ { name: "memory", description: "Show Seed Club memory status, recent memory, or turn memory on/off" },
971
+ { name: "worldview", description: "Open an investing worldview prompt" },
972
+ { name: "theses", description: "Open an investment theses prompt" },
973
+ { name: "concerns", description: "Open an investment concerns prompt" },
974
+ { name: "model", description: "Select the current model" },
967
975
  { name: "login", description: "Log into a model provider" },
968
976
  { name: "logout", description: "Log out of a model provider" },
969
- { name: "model", description: "Select the current model" },
970
- { name: "new", description: "Start a new session" },
977
+ { name: "more", description: "Show everything else" },
971
978
  { name: "quit", description: "Exit seedclub" },
979
+ ];
980
+ }
981
+
982
+ function getAuxiliaryBuiltInSlashCommands() {
983
+ return [
984
+ { name: "commands", description: "List the primary command menu" },
985
+ { name: "extensions", description: "List loaded extensions" },
972
986
  { name: "reload", description: "Reload keybindings, extensions, prompts, and tools" },
973
987
  { name: "update", description: "Show the seedclub package update menu" },
974
988
  ];
@@ -1298,14 +1312,17 @@ export class SeedclubInteractiveModeApp {
1298
1312
  configureAutocomplete() {
1299
1313
  const builtInCommands = getBuiltInSlashCommands();
1300
1314
  const builtInCommandNames = new Set(builtInCommands.map((command) => command.name));
1315
+ const extraAutocompleteCommands = new Set(["seedclub"]);
1301
1316
  const extensionCommands = this.session.extensionRunner?.getRegisteredCommands?.()
1302
1317
  .filter((command) => !builtInCommandNames.has(command.invocationName))
1318
+ .filter((command) => extraAutocompleteCommands.has(command.invocationName))
1303
1319
  .map((command) => ({
1304
1320
  name: command.invocationName,
1305
1321
  description: command.description || "",
1306
1322
  getArgumentCompletions: command.getArgumentCompletions,
1307
1323
  })) ?? [];
1308
1324
  const promptCommands = (this.session.promptTemplates ?? [])
1325
+ .filter((template) => extraAutocompleteCommands.has(template.name))
1309
1326
  .filter((template) => !builtInCommandNames.has(template.name))
1310
1327
  .map((template) => ({
1311
1328
  name: template.name,
@@ -1323,6 +1340,16 @@ export class SeedclubInteractiveModeApp {
1323
1340
 
1324
1341
  showCommandsList() {
1325
1342
  const builtInCommands = getBuiltInSlashCommands();
1343
+ const lines = builtInCommands
1344
+ .filter((command) => shouldShowCommandInList(command.name))
1345
+ .map((command) => `- \`/${command.name}\`${command.description ? ` ${command.description}` : ""}`)
1346
+ .join("\n");
1347
+ this.appendAssistantMarkdown(`## Commands\n\n${lines}`);
1348
+ this.setStatus("Listed primary commands.", "accent");
1349
+ }
1350
+
1351
+ showMoreCommandsList() {
1352
+ const builtInCommands = [...getBuiltInSlashCommands(), ...getAuxiliaryBuiltInSlashCommands()];
1326
1353
  const extensionCommands = this.session.extensionRunner?.getRegisteredCommands?.()
1327
1354
  .map((command) => ({
1328
1355
  name: command.invocationName,
@@ -1333,13 +1360,17 @@ export class SeedclubInteractiveModeApp {
1333
1360
  name: template.name,
1334
1361
  description: template.description || "",
1335
1362
  }));
1336
- const lines = [...builtInCommands, ...extensionCommands, ...promptCommands]
1363
+ const deduped = new Map();
1364
+ for (const command of [...builtInCommands, ...extensionCommands, ...promptCommands]) {
1365
+ if (!deduped.has(command.name)) deduped.set(command.name, command);
1366
+ }
1367
+ const lines = [...deduped.values()]
1337
1368
  .filter((command) => shouldShowCommandInList(command.name))
1338
1369
  .sort((left, right) => left.name.localeCompare(right.name))
1339
1370
  .map((command) => `- \`/${command.name}\`${command.description ? ` ${command.description}` : ""}`)
1340
1371
  .join("\n");
1341
- this.appendAssistantMarkdown(`## Commands\n\n${lines}`);
1342
- this.setStatus("Listed available commands.", "accent");
1372
+ this.appendAssistantMarkdown(`## More Commands\n\n${lines}`);
1373
+ this.setStatus("Listed all commands.", "accent");
1343
1374
  }
1344
1375
 
1345
1376
  showExtensionsList() {
@@ -1699,7 +1730,7 @@ export class SeedclubInteractiveModeApp {
1699
1730
  }
1700
1731
 
1701
1732
  showExtensionNotify(message, type = "info") {
1702
- if (type !== "info") {
1733
+ if (type !== "info" || message.includes("\n")) {
1703
1734
  this.appendAssistantMarkdown(`${getNotificationLabel(type)}: ${message}`);
1704
1735
  }
1705
1736
  this.setStatus(message.split("\n")[0], getNotificationTone(type));
@@ -1922,6 +1953,10 @@ export class SeedclubInteractiveModeApp {
1922
1953
  this.showCommandsList();
1923
1954
  return true;
1924
1955
  }
1956
+ if (text === "/more") {
1957
+ this.showMoreCommandsList();
1958
+ return true;
1959
+ }
1925
1960
  if (text === "/extensions") {
1926
1961
  this.showExtensionsList();
1927
1962
  return true;
@@ -1947,6 +1982,10 @@ export class SeedclubInteractiveModeApp {
1947
1982
  await this.handleNewSession();
1948
1983
  return true;
1949
1984
  }
1985
+ if (text === "/clear") {
1986
+ this.handleClearVisibleChat();
1987
+ return true;
1988
+ }
1950
1989
  if (text === "/compact" || text.startsWith("/compact ")) {
1951
1990
  const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
1952
1991
  await this.handleCompactCommand(customInstructions);
@@ -2108,6 +2147,16 @@ export class SeedclubInteractiveModeApp {
2108
2147
  }
2109
2148
  }
2110
2149
 
2150
+ handleClearVisibleChat() {
2151
+ this.chat.clear();
2152
+ this.pendingTools.clear();
2153
+ this.streamingAssistant = undefined;
2154
+ this.workingMessageOverride = undefined;
2155
+ this.clearTransientStatus();
2156
+ this.setStatus("Cleared visible chat.", "accent");
2157
+ this.ui.requestRender();
2158
+ }
2159
+
2111
2160
  async handleNewSession() {
2112
2161
  try {
2113
2162
  const completed = await this.startNewSession();
package/postinstall.js CHANGED
@@ -73,7 +73,12 @@ if (!existsSync(settingsFile)) {
73
73
  writeFileSync(
74
74
  settingsFile,
75
75
  JSON.stringify(
76
- { quietStartup: true, enableSkillCommands: true },
76
+ {
77
+ quietStartup: true,
78
+ enableSkillCommands: true,
79
+ seedclubMemoryDefaultEnabled: true,
80
+ seedclubMemoryDefaultApplied: false,
81
+ },
77
82
  null,
78
83
  2,
79
84
  ) + "\n",