careervivid 1.12.38 → 1.12.40

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.
@@ -291,7 +291,7 @@ export class CareerVividProxyEngine {
291
291
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
292
292
  this.history.push({
293
293
  role: 'user',
294
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
294
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
295
295
  });
296
296
  continue;
297
297
  }
@@ -232,7 +232,7 @@ export class QueryEngine {
232
232
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
233
233
  this.history.push({
234
234
  role: 'user',
235
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
235
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
236
236
  });
237
237
  continue;
238
238
  }
@@ -345,7 +345,7 @@ export class QueryEngine {
345
345
  hooks.onThinking("Harness intercepted permission seeking. Auto-correcting...");
346
346
  this.history.push({
347
347
  role: 'user',
348
- parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., update_local_job or add_local_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
348
+ parts: [{ text: "[Harness Instruction]: You halted execution to ask for permission or missing information. RE-READ YOUR HARNESS DIRECTIVES. You must autonomously execute the mutation (e.g., tracker_update_job or tracker_add_job) using defaults for missing fields. Do not wait for confirmation. Do it now." }]
349
349
  });
350
350
  continue;
351
351
  }
@@ -26,6 +26,8 @@ export interface AuditEntry {
26
26
  ok: boolean;
27
27
  }
28
28
  export declare let SESSION_ID: string;
29
+ /** Call once at startup so the session summary records the correct mode and model. */
30
+ export declare function initSessionContext(mode: string, model: string): void;
29
31
  export declare function auditLog(entry: {
30
32
  sessionId?: string;
31
33
  tool: string;
@@ -1 +1 @@
1
- {"version":3,"file":"agentAuditLog.d.ts","sourceRoot":"","sources":["../../src/agent/agentAuditLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AASH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;CACb;AAID,eAAO,IAAI,UAAU,QAA2B,CAAC;AA8BjD,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,IAAI,CAuBP;AAwCD,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAMnD;AAID,wBAAsB,mBAAmB,CAAC,KAAK,EAAE;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBhB"}
1
+ {"version":3,"file":"agentAuditLog.d.ts","sourceRoot":"","sources":["../../src/agent/agentAuditLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAYH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;CACb;AAID,eAAO,IAAI,UAAU,QAA2B,CAAC;AAoCjD,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAGpE;AA8BD,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,IAAI,CA8BP;AAyJD,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAMnD;AAID,wBAAsB,mBAAmB,CAAC,KAAK,EAAE;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgDhB"}
@@ -20,9 +20,30 @@ import { appendFileSync, existsSync, mkdirSync } from "fs";
20
20
  import { resolve } from "path";
21
21
  import { homedir } from "os";
22
22
  import { randomUUID } from "crypto";
23
+ import { saveSessionMemory } from "./memory.js";
23
24
  // ── Session-level state ───────────────────────────────────────────────────────
24
25
  export let SESSION_ID = randomUUID().slice(0, 8);
25
26
  const pendingFirestore = [];
27
+ const sessionState = {
28
+ mode: "general",
29
+ model: "unknown",
30
+ highlights: [],
31
+ careerGoals: [],
32
+ targetRoles: [],
33
+ targetCompanies: [],
34
+ skills: [],
35
+ activeResumeId: "",
36
+ activeResumeTitle: "",
37
+ jobActions: [],
38
+ coverLetters: [],
39
+ interviewPrep: [],
40
+ facts: {},
41
+ };
42
+ /** Call once at startup so the session summary records the correct mode and model. */
43
+ export function initSessionContext(mode, model) {
44
+ sessionState.mode = mode;
45
+ sessionState.model = model;
46
+ }
26
47
  // ── Local JSONL path ──────────────────────────────────────────────────────────
27
48
  function getAuditLogPath() {
28
49
  const dir = resolve(homedir(), ".careervivid");
@@ -65,12 +86,120 @@ export function auditLog(entry) {
65
86
  catch {
66
87
  // Silently swallow — never break the agent because of logging
67
88
  }
89
+ // ── 1b. Extract structured session facts from tool results ─────────────────
90
+ // This is the core of the rich memory system — we mine tool call results
91
+ // in real-time to populate the structured knowledge base.
92
+ if (record.ok) {
93
+ extractSessionFacts(entry.tool, entry.args, entry.result);
94
+ }
68
95
  // ── 2. Firebase Firestore (fire-and-forget — non-blocking) ───────────────
69
96
  pendingFirestore.push(record);
70
97
  void writeToFirestore(record).catch(() => {
71
98
  // Silently swallow — Firebase being unavailable must not affect the agent
72
99
  });
73
100
  }
101
+ // ── Session fact extractor ────────────────────────────────────────────────────────────────────
102
+ function extractSessionFacts(tool, args, result) {
103
+ const MAX_HIGHLIGHTS = 12;
104
+ const addHighlight = (outcome) => {
105
+ if (sessionState.highlights.length < MAX_HIGHLIGHTS) {
106
+ sessionState.highlights.push({ tool, args: JSON.stringify(args).slice(0, 60), outcome });
107
+ }
108
+ };
109
+ const addCompany = (c) => {
110
+ if (c && !sessionState.targetCompanies.includes(c)) {
111
+ sessionState.targetCompanies.push(c);
112
+ }
113
+ };
114
+ switch (tool) {
115
+ // ── Resume tools ────────────────────────────────────────────────────────────────────
116
+ case "get_resume":
117
+ case "list_resumes": {
118
+ // Extract resume ID from result string e.g. "ID: abc123"
119
+ const idMatch = result.match(/ID:\s*([\w\-]+)/);
120
+ const titleMatch = result.match(/Resume:\s*"([^"]+)"/);
121
+ if (idMatch) {
122
+ sessionState.activeResumeId = idMatch[1];
123
+ sessionState.activeResumeTitle = titleMatch?.[1] || "Resume";
124
+ }
125
+ break;
126
+ }
127
+ case "tailor_resume": {
128
+ // Extract new resume ID from result
129
+ const idMatch = result.match(/\/edit\/([\w]+)/);
130
+ const company = String(args.job_description || "").match(/(\w[\w\s]*)(?:\s+role|\s+position)/i)?.[1]?.trim() || "";
131
+ if (idMatch) {
132
+ sessionState.activeResumeId = idMatch[1];
133
+ addHighlight(`Tailored resume${company ? ` for ${company}` : ""} → new ID: ${idMatch[1]}`);
134
+ }
135
+ break;
136
+ }
137
+ // ── Job search tools ───────────────────────────────────────────────────────────────────
138
+ case "search_jobs": {
139
+ const role = String(args.role || "");
140
+ const location = String(args.location || "");
141
+ if (role && !sessionState.targetRoles.includes(role))
142
+ sessionState.targetRoles.push(role);
143
+ addHighlight(`Searched: "${role}"${location ? ` in ${location}` : ""}`);
144
+ break;
145
+ }
146
+ case "kanban_add_job":
147
+ case "tracker_add_job": {
148
+ const company = String(args.company_name || args.company || "");
149
+ const role = String(args.job_title || args.role || "");
150
+ addCompany(company);
151
+ sessionState.jobActions.push({ company, role, status: "To Apply", note: `Added ${new Date().toLocaleDateString()}` });
152
+ addHighlight(`Added ${company} (${role}) to pipeline`);
153
+ break;
154
+ }
155
+ case "kanban_update_status":
156
+ case "tracker_update_job": {
157
+ const status = String(args.new_status || args.status || "");
158
+ const jobId = String(args.job_id || args.id || "");
159
+ const note = String(args.notes || "");
160
+ // Try to find this job in our local pipeline to enrich the entry
161
+ const existing = sessionState.jobActions.find(j => j.note?.includes(jobId));
162
+ if (existing)
163
+ existing.status = status;
164
+ addHighlight(`Updated job ${jobId} → ${status}${note ? `: ${note.slice(0, 50)}` : ""}`);
165
+ break;
166
+ }
167
+ case "openings_scan": {
168
+ const companies = args.companies || [];
169
+ companies.forEach(addCompany);
170
+ addHighlight(`Scanned ${companies.join(", ")} for open roles`);
171
+ break;
172
+ }
173
+ // ── Cover letter tools ─────────────────────────────────────────────────────────────────
174
+ case "save_cover_letter": {
175
+ const company = String(args.company || args.company_name || "");
176
+ const role = String(args.role || args.job_title || "");
177
+ const clIdMatch = result.match(/ID:\s*([\w\-]+)/);
178
+ addCompany(company);
179
+ sessionState.coverLetters.push({ company, role, savedId: clIdMatch?.[1] });
180
+ addHighlight(`Drafted cover letter for ${company} (${role})`);
181
+ break;
182
+ }
183
+ // ── Browser/apply tools ────────────────────────────────────────────────────────────────
184
+ case "browser_autofill_application": {
185
+ const company = String(args.company_name || "");
186
+ const role = String(args.job_title || "");
187
+ const url = String(args.job_url || "");
188
+ addCompany(company);
189
+ const existingJob = sessionState.jobActions.find(j => j.company === company);
190
+ if (existingJob) {
191
+ existingJob.status = "Auto-fill completed (not submitted)";
192
+ }
193
+ else {
194
+ sessionState.jobActions.push({ company, role, status: "Auto-fill completed (not submitted)", note: url.slice(0, 80) });
195
+ }
196
+ addHighlight(`Auto-filled application: ${company} (${role})`);
197
+ break;
198
+ }
199
+ default:
200
+ break;
201
+ }
202
+ }
74
203
  // ── Firebase write (dynamic import to avoid hard dependency) ─────────────────
75
204
  async function writeToFirestore(record) {
76
205
  // Only attempt if firebase-admin is installed (it's optional)
@@ -124,6 +253,30 @@ export async function writeSessionSummary(stats) {
124
253
  appendFileSync(getAuditLogPath(), JSON.stringify(record) + "\n", "utf-8");
125
254
  }
126
255
  catch { /* silent */ }
256
+ // ── Save rich structured memory for next session ──────────────────────────────
257
+ if (stats.turns > 0) {
258
+ try {
259
+ const memoryInput = {
260
+ turns: stats.turns,
261
+ mutations: stats.mutations,
262
+ highlights: sessionState.highlights,
263
+ mode: sessionState.mode,
264
+ model: sessionState.model,
265
+ careerGoals: sessionState.careerGoals,
266
+ targetRoles: sessionState.targetRoles,
267
+ targetCompanies: sessionState.targetCompanies,
268
+ skills: sessionState.skills,
269
+ activeResumeId: sessionState.activeResumeId,
270
+ activeResumeTitle: sessionState.activeResumeTitle,
271
+ jobActions: sessionState.jobActions,
272
+ coverLetters: sessionState.coverLetters,
273
+ interviewPrep: sessionState.interviewPrep,
274
+ facts: sessionState.facts,
275
+ };
276
+ saveSessionMemory(memoryInput);
277
+ }
278
+ catch { /* memory write failure is non-fatal */ }
279
+ }
127
280
  // Firestore session summary
128
281
  let admin;
129
282
  try {
@@ -18,6 +18,7 @@ export declare const JOBS_HARNESS: string;
18
18
  export declare const GREETING_PROTOCOL: string;
19
19
  /**
20
20
  * Returns the assembled system prompt for a given agent mode.
21
+ * Injects the user's past session memory (from ~/.careervivid/memory.md) when available.
21
22
  * This is the ONLY function that engineResolver.ts should call.
22
23
  */
23
24
  export declare function buildSystemPrompt(options: {
@@ -1 +1 @@
1
- {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAwCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA2CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QActB,CAAC;AAMT;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAyBT"}
1
+ {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAwCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA2CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QAiBtB,CAAC;AAOT;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAkCT"}
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * DO NOT scatter instructions across QueryEngine.ts, engineResolver.ts, or anywhere else.
12
12
  */
13
+ import { buildMemoryBlock, MEMORY_SECTION } from "./memory.js";
13
14
  export const BASE_IDENTITY = `
14
15
  You are CareerVivid AI — an autonomous career intelligence agent built into the CareerVivid CLI.
15
16
  You help users manage their resume, track job applications, find new opportunities, prep for interviews, and grow their career.
@@ -168,23 +169,33 @@ When the user sends a generic greeting ("hey", "hi", "hello", "start"), respond
168
169
  • 📊 Check my job pipeline / tracker
169
170
  • ✉️ Draft a cover letter or tailor my resume
170
171
  • 📈 Get an overview of my job search progress
172
+ • 🗓️ Pick up where we left off
171
173
 
172
174
  Just tell me what you need!"
175
+
176
+ If the user says "pick up where we left off" or similar, immediately reference the Recent Session Memory block (if any) and resume the most recent task. If there is no memory yet, say so and offer to start fresh.
173
177
  `.trim();
174
178
  // ---------------------------------------------------------------------------
175
179
  // §7 — Assembled system prompts per mode (the public API)
176
180
  // ---------------------------------------------------------------------------
177
181
  /**
178
182
  * Returns the assembled system prompt for a given agent mode.
183
+ * Injects the user's past session memory (from ~/.careervivid/memory.md) when available.
179
184
  * This is the ONLY function that engineResolver.ts should call.
180
185
  */
181
186
  export function buildSystemPrompt(options) {
187
+ // Load memory block (empty string if no memory file exists yet)
188
+ const memoryBlock = buildMemoryBlock();
189
+ const memorySections = memoryBlock
190
+ ? [MEMORY_SECTION, memoryBlock]
191
+ : [];
182
192
  if (options.jobs) {
183
193
  return [
184
194
  BASE_IDENTITY,
185
195
  RESUME_SECTION,
186
196
  JOBS_TOOLS_SECTION,
187
197
  JOBS_HARNESS,
198
+ ...memorySections,
188
199
  GREETING_PROTOCOL,
189
200
  ].join("\n\n---\n\n");
190
201
  }
@@ -192,6 +203,7 @@ export function buildSystemPrompt(options) {
192
203
  return [
193
204
  BASE_IDENTITY,
194
205
  RESUME_SECTION,
206
+ ...memorySections,
195
207
  GREETING_PROTOCOL,
196
208
  ].join("\n\n---\n\n");
197
209
  }
@@ -199,6 +211,7 @@ export function buildSystemPrompt(options) {
199
211
  return [
200
212
  BASE_IDENTITY,
201
213
  CODING_SECTION,
214
+ ...memorySections,
202
215
  GREETING_PROTOCOL,
203
216
  ].join("\n\n---\n\n");
204
217
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Agent Session Memory — ~/.careervivid/memory.md
3
+ *
4
+ * Architecture
5
+ * ════════════
6
+ * The memory file has TWO layers:
7
+ *
8
+ * ┌─────────────────────────────────────────────────────────────────┐
9
+ * │ LAYER 1 — Knowledge Base (🔒 persisted forever, never deleted) │
10
+ * │ Structured sections extracted across ALL sessions: │
11
+ * │ • Career Goals & Preferences │
12
+ * │ • Resume State (active IDs, versions, tailoring history) │
13
+ * │ • Job Pipeline (tracked companies, statuses, decisions) │
14
+ * │ • Skills & Experience Context │
15
+ * │ • Interview Prep Context │
16
+ * │ • Cover Letter History │
17
+ * └─────────────────────────────────────────────────────────────────┘
18
+ * ┌─────────────────────────────────────────────────────────────────┐
19
+ * │ LAYER 2 — Recent Session Log (rolling, auto-compacted) │
20
+ * │ Last N sessions with full context of what was done. │
21
+ * │ When compaction fires, sessions are merged into the │
22
+ * │ Knowledge Base above (not discarded). │
23
+ * └─────────────────────────────────────────────────────────────────┘
24
+ *
25
+ * Compaction strategy
26
+ * ───────────────────
27
+ * When total file size > MAX_CHARS:
28
+ * 1. Parse the knowledge base sections
29
+ * 2. Merge each older session's facts INTO the knowledge base (upsert)
30
+ * 3. Drop sessions older than KEEP_SESSIONS, keeping the most recent N
31
+ * 4. Optionally call the LLM (via CV proxy) to produce a true distillation
32
+ * if an api key is available — this is the "smart" path
33
+ * 5. Write the compacted file back
34
+ *
35
+ * System prompt injection
36
+ * ───────────────────────
37
+ * Only injects the knowledge base + last 2 sessions (≤ PROMPT_INJECT_CHARS).
38
+ * The agent always sees what matters, never sees everything.
39
+ *
40
+ * Public API
41
+ * ──────────
42
+ * loadMemory() → raw file string
43
+ * buildMemoryBlock() → string for system prompt injection
44
+ * saveSessionMemory(opts) → append + compact
45
+ * buildSessionSummary(opts) → build summary string from session data
46
+ * MEMORY_SECTION → static system-prompt header
47
+ */
48
+ export interface SessionHighlight {
49
+ tool: string;
50
+ args: string;
51
+ outcome: string;
52
+ }
53
+ export interface KnowledgeBase {
54
+ /** e.g. ["harness engineering", "AI-first companies", "solutions engineering"] */
55
+ careerGoals: string[];
56
+ /** Preferred role titles */
57
+ targetRoles: string[];
58
+ /** Preferred companies / sectors */
59
+ targetCompanies: string[];
60
+ /** Active resume ID and title */
61
+ activeResume: {
62
+ id: string;
63
+ title: string;
64
+ lastUpdated: string;
65
+ } | null;
66
+ /** All known resume IDs */
67
+ resumeHistory: Array<{
68
+ id: string;
69
+ title: string;
70
+ }>;
71
+ /** Job pipeline entries worth remembering */
72
+ jobPipeline: Array<{
73
+ company: string;
74
+ role: string;
75
+ status: string;
76
+ note?: string;
77
+ }>;
78
+ /** Cover letters generated */
79
+ coverLetters: Array<{
80
+ company: string;
81
+ role: string;
82
+ savedId?: string;
83
+ }>;
84
+ /** Interview prep done */
85
+ interviewPrep: Array<{
86
+ company: string;
87
+ role: string;
88
+ topics: string[];
89
+ }>;
90
+ /** Skills the user has mentioned or had added */
91
+ skills: string[];
92
+ /** Misc facts (work auth, location, salary expectation, etc.) */
93
+ facts: Record<string, string>;
94
+ /** Last updated ISO timestamp */
95
+ updatedAt: string;
96
+ }
97
+ export declare function loadMemory(): string;
98
+ /**
99
+ * Build the system-prompt memory block.
100
+ *
101
+ * Design principle: O(1) token cost regardless of pipeline size.
102
+ * - 10 jobs → same output size as 1,000 jobs
103
+ * - Full details always come from tool calls (tracker_list_jobs, etc.)
104
+ * - This block is context/orientation, not a data dump
105
+ *
106
+ * Typical output: ~250–400 chars (~65–100 tokens)
107
+ * Hard ceiling: 1,200 chars (~300 tokens) — never exceeded by realistic data
108
+ */
109
+ export declare function buildMemoryBlock(): string;
110
+ /**
111
+ * Static system-prompt section instructing the agent how to use memory.
112
+ * Imported by instructions.ts.
113
+ */
114
+ export declare const MEMORY_SECTION: string;
115
+ export interface SessionSummaryInput {
116
+ turns: number;
117
+ mutations: number;
118
+ highlights: SessionHighlight[];
119
+ mode: string;
120
+ model: string;
121
+ careerGoals?: string[];
122
+ targetRoles?: string[];
123
+ targetCompanies?: string[];
124
+ skills?: string[];
125
+ activeResumeId?: string;
126
+ activeResumeTitle?: string;
127
+ jobActions?: Array<{
128
+ company: string;
129
+ role: string;
130
+ status: string;
131
+ note?: string;
132
+ }>;
133
+ coverLetters?: Array<{
134
+ company: string;
135
+ role: string;
136
+ savedId?: string;
137
+ }>;
138
+ interviewPrep?: Array<{
139
+ company: string;
140
+ role: string;
141
+ topics: string[];
142
+ }>;
143
+ facts?: Record<string, string>;
144
+ summary?: string;
145
+ }
146
+ /**
147
+ * Build a rich session entry block.
148
+ * Returns a markdown string with embedded JSON metadata for later parsing.
149
+ */
150
+ export declare function buildSessionSummary(opts: SessionSummaryInput): string;
151
+ /**
152
+ * Append a session summary to the memory file, merge into KB, compact if needed.
153
+ */
154
+ export declare function saveSessionMemory(opts: SessionSummaryInput): void;
155
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/agent/memory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAgCH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAK,MAAM,CAAC;IAChB,IAAI,EAAK,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,WAAW,EAAM,MAAM,EAAE,CAAC;IAC1B,4BAA4B;IAC5B,WAAW,EAAM,MAAM,EAAE,CAAC;IAC1B,oCAAoC;IACpC,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,iCAAiC;IACjC,YAAY,EAAK;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,2BAA2B;IAC3B,aAAa,EAAI,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,6CAA6C;IAC7C,WAAW,EAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzF,8BAA8B;IAC9B,YAAY,EAAK,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E,0BAA0B;IAC1B,aAAa,EAAI,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC5E,iDAAiD;IACjD,MAAM,EAAW,MAAM,EAAE,CAAC;IAC1B,iEAAiE;IACjE,KAAK,EAAY,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,iCAAiC;IACjC,SAAS,EAAQ,MAAM,CAAC;CACzB;AAMD,wBAAgB,UAAU,IAAI,MAAM,CAOnC;AAuPD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CA6EzC;AAGD;;;GAGG;AACH,eAAO,MAAM,cAAc,QAwBnB,CAAC;AAOT,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAc,MAAM,CAAC;IAC1B,SAAS,EAAU,MAAM,CAAC;IAC1B,UAAU,EAAS,gBAAgB,EAAE,CAAC;IACtC,IAAI,EAAe,MAAM,CAAC;IAC1B,KAAK,EAAc,MAAM,CAAC;IAE1B,WAAW,CAAC,EAAO,MAAM,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAO,MAAM,EAAE,CAAC;IAC5B,eAAe,CAAC,EAAG,MAAM,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAY,MAAM,EAAE,CAAC;IAC5B,cAAc,CAAC,EAAI,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAC,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAQ,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3F,YAAY,CAAC,EAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9E,aAAa,CAAC,EAAK,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAa,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,OAAO,CAAC,EAAW,MAAM,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM,CA8CrE;AAsBD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,mBAAmB,GAAG,IAAI,CAUjE"}
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Agent Session Memory — ~/.careervivid/memory.md
3
+ *
4
+ * Architecture
5
+ * ════════════
6
+ * The memory file has TWO layers:
7
+ *
8
+ * ┌─────────────────────────────────────────────────────────────────┐
9
+ * │ LAYER 1 — Knowledge Base (🔒 persisted forever, never deleted) │
10
+ * │ Structured sections extracted across ALL sessions: │
11
+ * │ • Career Goals & Preferences │
12
+ * │ • Resume State (active IDs, versions, tailoring history) │
13
+ * │ • Job Pipeline (tracked companies, statuses, decisions) │
14
+ * │ • Skills & Experience Context │
15
+ * │ • Interview Prep Context │
16
+ * │ • Cover Letter History │
17
+ * └─────────────────────────────────────────────────────────────────┘
18
+ * ┌─────────────────────────────────────────────────────────────────┐
19
+ * │ LAYER 2 — Recent Session Log (rolling, auto-compacted) │
20
+ * │ Last N sessions with full context of what was done. │
21
+ * │ When compaction fires, sessions are merged into the │
22
+ * │ Knowledge Base above (not discarded). │
23
+ * └─────────────────────────────────────────────────────────────────┘
24
+ *
25
+ * Compaction strategy
26
+ * ───────────────────
27
+ * When total file size > MAX_CHARS:
28
+ * 1. Parse the knowledge base sections
29
+ * 2. Merge each older session's facts INTO the knowledge base (upsert)
30
+ * 3. Drop sessions older than KEEP_SESSIONS, keeping the most recent N
31
+ * 4. Optionally call the LLM (via CV proxy) to produce a true distillation
32
+ * if an api key is available — this is the "smart" path
33
+ * 5. Write the compacted file back
34
+ *
35
+ * System prompt injection
36
+ * ───────────────────────
37
+ * Only injects the knowledge base + last 2 sessions (≤ PROMPT_INJECT_CHARS).
38
+ * The agent always sees what matters, never sees everything.
39
+ *
40
+ * Public API
41
+ * ──────────
42
+ * loadMemory() → raw file string
43
+ * buildMemoryBlock() → string for system prompt injection
44
+ * saveSessionMemory(opts) → append + compact
45
+ * buildSessionSummary(opts) → build summary string from session data
46
+ * MEMORY_SECTION → static system-prompt header
47
+ */
48
+ import { homedir } from "os";
49
+ import { join } from "path";
50
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, } from "fs";
51
+ // ═══════════════════════════════════════════════════════════════════════════
52
+ // Constants
53
+ // ═══════════════════════════════════════════════════════════════════════════
54
+ const MEMORY_DIR = join(homedir(), ".careervivid");
55
+ const MEMORY_FILE = join(MEMORY_DIR, "memory.md");
56
+ /** Max total file chars before compaction runs. ~20k gives lots of room. */
57
+ const MAX_CHARS = 20_000;
58
+ /** Number of full sessions to keep in the session log after compaction. */
59
+ const KEEP_SESSIONS = 5;
60
+ /** Max chars injected into the system prompt. ~2k = ~500 tokens max — lean by design. */
61
+ const PROMPT_INJECT_CHARS = 2_000;
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+ // File I/O helpers
64
+ // ═══════════════════════════════════════════════════════════════════════════
65
+ export function loadMemory() {
66
+ if (!existsSync(MEMORY_FILE))
67
+ return "";
68
+ try {
69
+ return readFileSync(MEMORY_FILE, "utf-8").trim();
70
+ }
71
+ catch {
72
+ return "";
73
+ }
74
+ }
75
+ function saveMemory(content) {
76
+ if (!existsSync(MEMORY_DIR)) {
77
+ mkdirSync(MEMORY_DIR, { recursive: true });
78
+ }
79
+ writeFileSync(MEMORY_FILE, content.trim() + "\n", { encoding: "utf-8" });
80
+ }
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+ // Knowledge Base — parse / merge / serialize
83
+ // ═══════════════════════════════════════════════════════════════════════════
84
+ const EMPTY_KB = {
85
+ careerGoals: [],
86
+ targetRoles: [],
87
+ targetCompanies: [],
88
+ activeResume: null,
89
+ resumeHistory: [],
90
+ jobPipeline: [],
91
+ coverLetters: [],
92
+ interviewPrep: [],
93
+ skills: [],
94
+ facts: {},
95
+ updatedAt: new Date().toISOString(),
96
+ };
97
+ /**
98
+ * Extract the JSON knowledge base block from the memory file.
99
+ * The KB is stored as a fenced JSON block after the `<!-- KB_START -->` marker.
100
+ */
101
+ function parseKnowledgeBase(raw) {
102
+ const match = raw.match(/<!-- KB_START -->([\s\S]*?)<!-- KB_END -->/);
103
+ if (!match)
104
+ return { ...EMPTY_KB };
105
+ try {
106
+ return { ...EMPTY_KB, ...JSON.parse(match[1].trim()) };
107
+ }
108
+ catch {
109
+ return { ...EMPTY_KB };
110
+ }
111
+ }
112
+ function serializeKnowledgeBase(kb) {
113
+ return [
114
+ "<!-- KB_START -->",
115
+ JSON.stringify(kb, null, 2),
116
+ "<!-- KB_END -->",
117
+ ].join("\n");
118
+ }
119
+ /** Merge new facts from a session into the existing knowledge base. */
120
+ function mergeIntoKB(kb, session) {
121
+ const updated = { ...kb, updatedAt: new Date().toISOString() };
122
+ // Merge career goals from session metadata
123
+ if (session.careerGoals?.length) {
124
+ updated.careerGoals = dedupe([...kb.careerGoals, ...session.careerGoals]);
125
+ }
126
+ if (session.targetRoles?.length) {
127
+ updated.targetRoles = dedupe([...kb.targetRoles, ...session.targetRoles]);
128
+ }
129
+ if (session.targetCompanies?.length) {
130
+ updated.targetCompanies = dedupe([...kb.targetCompanies, ...session.targetCompanies]);
131
+ }
132
+ if (session.skills?.length) {
133
+ updated.skills = dedupe([...kb.skills, ...session.skills]);
134
+ }
135
+ // Active resume — prefer most recent
136
+ if (session.activeResumeId) {
137
+ updated.activeResume = {
138
+ id: session.activeResumeId,
139
+ title: session.activeResumeTitle || "Untitled Resume",
140
+ lastUpdated: session.timestamp,
141
+ };
142
+ // Also add to history
143
+ const alreadyInHistory = updated.resumeHistory.some(r => r.id === session.activeResumeId);
144
+ if (!alreadyInHistory) {
145
+ updated.resumeHistory = [
146
+ { id: session.activeResumeId, title: session.activeResumeTitle || "Untitled Resume" },
147
+ ...updated.resumeHistory,
148
+ ].slice(0, 10); // keep last 10
149
+ }
150
+ }
151
+ // Job pipeline
152
+ for (const job of session.jobActions) {
153
+ const existing = updated.jobPipeline.findIndex(j => j.company.toLowerCase() === job.company.toLowerCase());
154
+ if (existing >= 0) {
155
+ updated.jobPipeline[existing] = { ...updated.jobPipeline[existing], ...job };
156
+ }
157
+ else {
158
+ updated.jobPipeline = [job, ...updated.jobPipeline].slice(0, 30);
159
+ }
160
+ }
161
+ // Cover letters
162
+ for (const cl of session.coverLetters) {
163
+ const exists = updated.coverLetters.some(c => c.company === cl.company && c.role === cl.role);
164
+ if (!exists) {
165
+ updated.coverLetters = [cl, ...updated.coverLetters].slice(0, 20);
166
+ }
167
+ }
168
+ // Interview prep
169
+ for (const ip of session.interviewPrep) {
170
+ const existing = updated.interviewPrep.findIndex(i => i.company === ip.company);
171
+ if (existing >= 0) {
172
+ updated.interviewPrep[existing].topics = dedupe([
173
+ ...updated.interviewPrep[existing].topics,
174
+ ...ip.topics,
175
+ ]);
176
+ }
177
+ else {
178
+ updated.interviewPrep = [ip, ...updated.interviewPrep].slice(0, 10);
179
+ }
180
+ }
181
+ // Facts (work auth, location, salary, etc.)
182
+ if (session.facts) {
183
+ updated.facts = { ...updated.facts, ...session.facts };
184
+ }
185
+ return updated;
186
+ }
187
+ function dedupe(arr) {
188
+ return [...new Set(arr)];
189
+ }
190
+ /** Parse session blocks from the memory file (raw markdown). */
191
+ function parseSessions(raw) {
192
+ const blocks = raw.split(/(?=^## Session )/m).filter(b => b.trimStart().startsWith("## Session"));
193
+ return blocks.map(block => {
194
+ const headerLine = block.split("\n")[0];
195
+ const timestamp = headerLine.replace("## Session", "").trim();
196
+ // Parse JSON metadata block within the session if present
197
+ let meta = {};
198
+ const metaMatch = block.match(/<!-- SESSION_META\n([\s\S]*?)\nMETA_END -->/);
199
+ if (metaMatch) {
200
+ try {
201
+ meta = JSON.parse(metaMatch[1]);
202
+ }
203
+ catch { /* ignore */ }
204
+ }
205
+ return {
206
+ header: headerLine,
207
+ body: block,
208
+ parsed: {
209
+ timestamp,
210
+ mode: meta.mode || "general",
211
+ model: meta.model || "unknown",
212
+ turns: meta.turns || 0,
213
+ summary: meta.summary || block.slice(headerLine.length).replace(/<!-- SESSION_META[\s\S]*?META_END -->/g, "").trim(),
214
+ highlights: meta.highlights || [],
215
+ careerGoals: meta.careerGoals || [],
216
+ targetRoles: meta.targetRoles || [],
217
+ targetCompanies: meta.targetCompanies || [],
218
+ skills: meta.skills || [],
219
+ activeResumeId: meta.activeResumeId || "",
220
+ activeResumeTitle: meta.activeResumeTitle || "",
221
+ jobActions: meta.jobActions || [],
222
+ coverLetters: meta.coverLetters || [],
223
+ interviewPrep: meta.interviewPrep || [],
224
+ facts: meta.facts || {},
225
+ },
226
+ };
227
+ });
228
+ }
229
+ // ═══════════════════════════════════════════════════════════════════════════
230
+ // Compaction engine
231
+ // ═══════════════════════════════════════════════════════════════════════════
232
+ /**
233
+ * Smart compaction:
234
+ * 1. Parse all sessions
235
+ * 2. Merge ALL sessions into the knowledge base (no data is deleted)
236
+ * 3. Keep only the most recent KEEP_SESSIONS in the session log
237
+ * 4. Serialize back to the two-layer format
238
+ */
239
+ function compactMemory(raw) {
240
+ if (raw.length <= MAX_CHARS)
241
+ return raw;
242
+ const kb = parseKnowledgeBase(raw);
243
+ const sessions = parseSessions(raw);
244
+ // Merge every session into the KB before we trim the session log
245
+ let mergedKB = kb;
246
+ for (const s of sessions) {
247
+ mergedKB = mergeIntoKB(mergedKB, s.parsed);
248
+ }
249
+ // Keep only the most recent KEEP_SESSIONS
250
+ const recentSessions = sessions.slice(-KEEP_SESSIONS);
251
+ return buildMemoryFile(mergedKB, recentSessions.map(s => s.body));
252
+ }
253
+ function buildMemoryFile(kb, sessionBlocks) {
254
+ const sections = [
255
+ "# CareerVivid Agent Memory",
256
+ "",
257
+ "> Auto-maintained by the CareerVivid AI agent. Do not edit manually.",
258
+ "",
259
+ "## 🧠 Knowledge Base",
260
+ "",
261
+ "This section accumulates facts across ALL sessions and is never deleted.",
262
+ "",
263
+ serializeKnowledgeBase(kb),
264
+ "",
265
+ "---",
266
+ "",
267
+ "## 📅 Recent Session Log",
268
+ "",
269
+ ...sessionBlocks,
270
+ ];
271
+ return sections.join("\n").trim();
272
+ }
273
+ // ═══════════════════════════════════════════════════════════════════════════
274
+ // Public API
275
+ // ═══════════════════════════════════════════════════════════════════════════
276
+ /**
277
+ * Build the system-prompt memory block.
278
+ *
279
+ * Design principle: O(1) token cost regardless of pipeline size.
280
+ * - 10 jobs → same output size as 1,000 jobs
281
+ * - Full details always come from tool calls (tracker_list_jobs, etc.)
282
+ * - This block is context/orientation, not a data dump
283
+ *
284
+ * Typical output: ~250–400 chars (~65–100 tokens)
285
+ * Hard ceiling: 1,200 chars (~300 tokens) — never exceeded by realistic data
286
+ */
287
+ export function buildMemoryBlock() {
288
+ const raw = loadMemory();
289
+ if (!raw)
290
+ return "";
291
+ const kb = parseKnowledgeBase(raw);
292
+ const lines = [];
293
+ // 1. Career focus (max ~100 chars)
294
+ if (kb.targetRoles.length || kb.careerGoals.length) {
295
+ const focus = [
296
+ ...kb.targetRoles.slice(0, 2),
297
+ ...kb.careerGoals.slice(0, 1),
298
+ ].filter(Boolean).join(" • ");
299
+ if (focus)
300
+ lines.push(`Focus: ${focus}`);
301
+ }
302
+ // 2. Active resume ID (single line, ~60 chars)
303
+ if (kb.activeResume) {
304
+ lines.push(`Resume: "${kb.activeResume.title}" [${kb.activeResume.id}]`);
305
+ }
306
+ // 3. Pipeline stats — ALWAYS a count summary, never a full list
307
+ // 100 jobs or 1000 jobs = same 1-line output
308
+ if (kb.jobPipeline.length > 0) {
309
+ const counts = {};
310
+ for (const j of kb.jobPipeline) {
311
+ const s = j.status || "Unknown";
312
+ counts[s] = (counts[s] || 0) + 1;
313
+ }
314
+ const breakdown = Object.entries(counts)
315
+ .sort((a, b) => b[1] - a[1])
316
+ .slice(0, 4) // top 4 statuses only
317
+ .map(([s, n]) => `${n} ${s}`)
318
+ .join(" · ");
319
+ lines.push(`Pipeline: ${kb.jobPipeline.length} jobs tracked (${breakdown})`);
320
+ // Most recent 2 active (Interviewing > Applied > To Apply) — gives user the "hot" context
321
+ const PRIORITY = ["Offered", "Interviewing", "Applied", "To Apply"];
322
+ const hot = kb.jobPipeline
323
+ .filter(j => PRIORITY.indexOf(j.status) >= 0)
324
+ .sort((a, b) => PRIORITY.indexOf(a.status) - PRIORITY.indexOf(b.status))
325
+ .slice(0, 2);
326
+ for (const j of hot) {
327
+ lines.push(` → ${j.company} (${j.role}) — ${j.status}`);
328
+ }
329
+ }
330
+ // 4. Cover letter count (1 line max)
331
+ if (kb.coverLetters.length > 0) {
332
+ lines.push(`Cover letters: ${kb.coverLetters.length} drafted`);
333
+ }
334
+ // 5. Interview prep (1 line max)
335
+ if (kb.interviewPrep.length > 0) {
336
+ lines.push(`Interview prep: ${kb.interviewPrep.map(i => i.company).slice(0, 3).join(", ")}`);
337
+ }
338
+ // 6. Key facts (work auth, location — max 2)
339
+ const factEntries = Object.entries(kb.facts).slice(0, 2);
340
+ for (const [k, v] of factEntries) {
341
+ lines.push(`${k}: ${v}`);
342
+ }
343
+ if (lines.length === 0)
344
+ return "";
345
+ // 7. Last session hint (always 1 line, trimmed to 100 chars)
346
+ const sessions = parseSessions(raw);
347
+ const last = sessions[sessions.length - 1];
348
+ const lastHint = last
349
+ ? `Last session: ${last.parsed.summary.slice(0, 100).replace(/\n/g, " ")}`
350
+ : "";
351
+ if (lastHint)
352
+ lines.push(""); // spacer
353
+ if (lastHint)
354
+ lines.push(lastHint);
355
+ return ["## Career Context", "", ...lines].join("\n");
356
+ }
357
+ /**
358
+ * Static system-prompt section instructing the agent how to use memory.
359
+ * Imported by instructions.ts.
360
+ */
361
+ export const MEMORY_SECTION = `
362
+ ## Persistent Career Context
363
+
364
+ A Career Context block is injected below. It is a HIGH-LEVEL SNAPSHOT only.
365
+ The full job pipeline, resume history, and cover letter details are stored locally
366
+ and accessed via tool calls when needed.
367
+
368
+ Rules:
369
+ 1. GREETING: If the user sends a greeting and the Career Context block has a
370
+ "Last session" line, open with a one-line recap:
371
+ e.g. "Last time we tailored your resume for Anthropic — want to continue?"
372
+ Don't list everything — just the most relevant thread.
373
+
374
+ 2. RESUME ID: If the Career Context block has an active resume ID, use it directly
375
+ when calling resume tools. Never ask the user for it again.
376
+
377
+ 3. PIPELINE DETAILS: The context block shows COUNTS only (e.g. "182 jobs tracked").
378
+ For specifics (which companies, which status), call tracker_list_jobs or
379
+ tracker_dashboard — do NOT guess from the count.
380
+
381
+ 4. COVER LETTERS / INTERVIEW PREP: Context shows counts. Call tools for full content.
382
+
383
+ 5. NEVER fabricate: If it's not in the Career Context block and no tool was called,
384
+ don't reference it as if it happened.
385
+ `.trim();
386
+ /**
387
+ * Build a rich session entry block.
388
+ * Returns a markdown string with embedded JSON metadata for later parsing.
389
+ */
390
+ export function buildSessionSummary(opts) {
391
+ const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
392
+ // Human-readable summary paragraph
393
+ const summaryPara = opts.summary
394
+ || buildAutoSummary(opts);
395
+ // Highlights bullets
396
+ const highlightLines = opts.highlights.slice(0, 8).map(h => `- ${h.outcome || h.tool}`);
397
+ // Embed structured metadata as a hidden comment for later parsing
398
+ const meta = {
399
+ timestamp,
400
+ mode: opts.mode,
401
+ model: opts.model,
402
+ turns: opts.turns,
403
+ summary: summaryPara,
404
+ highlights: highlightLines,
405
+ careerGoals: opts.careerGoals || [],
406
+ targetRoles: opts.targetRoles || [],
407
+ targetCompanies: opts.targetCompanies || [],
408
+ skills: opts.skills || [],
409
+ activeResumeId: opts.activeResumeId || "",
410
+ activeResumeTitle: opts.activeResumeTitle || "",
411
+ jobActions: opts.jobActions || [],
412
+ coverLetters: opts.coverLetters || [],
413
+ interviewPrep: opts.interviewPrep || [],
414
+ facts: opts.facts || {},
415
+ };
416
+ const lines = [
417
+ `## Session ${timestamp}`,
418
+ `**Mode:** ${opts.mode} | **Model:** ${opts.model} | **Turns:** ${opts.turns}`,
419
+ "",
420
+ summaryPara,
421
+ ];
422
+ if (highlightLines.length > 0) {
423
+ lines.push("", "**Key actions:**", ...highlightLines);
424
+ }
425
+ // Embed metadata for compaction parsing (invisible in rendered markdown)
426
+ lines.push("", `<!-- SESSION_META\n${JSON.stringify(meta, null, 2)}\nMETA_END -->`);
427
+ lines.push("");
428
+ return lines.join("\n");
429
+ }
430
+ function buildAutoSummary(opts) {
431
+ const parts = [];
432
+ if (opts.mode === "jobs")
433
+ parts.push("Job search session");
434
+ else if (opts.mode === "resume")
435
+ parts.push("Resume session");
436
+ else
437
+ parts.push("General coding session");
438
+ if (opts.targetRoles?.length)
439
+ parts.push(`targeting ${opts.targetRoles.join(", ")}`);
440
+ if (opts.targetCompanies?.length)
441
+ parts.push(`at ${opts.targetCompanies.join(", ")}`);
442
+ if (opts.activeResumeId)
443
+ parts.push(`active resume: ${opts.activeResumeId}`);
444
+ if (opts.coverLetters?.length)
445
+ parts.push(`drafted ${opts.coverLetters.length} cover letter(s)`);
446
+ if (opts.interviewPrep?.length)
447
+ parts.push(`interview prep for ${opts.interviewPrep.map(i => i.company).join(", ")}`);
448
+ return parts.join(" — ") + ".";
449
+ }
450
+ // ═══════════════════════════════════════════════════════════════════════════
451
+ // Save session memory (main entry point called at session end)
452
+ // ═══════════════════════════════════════════════════════════════════════════
453
+ /**
454
+ * Append a session summary to the memory file, merge into KB, compact if needed.
455
+ */
456
+ export function saveSessionMemory(opts) {
457
+ if (opts.turns === 0)
458
+ return;
459
+ const entry = buildSessionSummary(opts);
460
+ const existing = loadMemory();
461
+ const base = existing || buildMemoryFile({ ...EMPTY_KB }, []);
462
+ const updated = compactMemory(base + "\n" + entry);
463
+ saveMemory(updated);
464
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"jobOpenings.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobOpenings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAgclC,eAAO,MAAM,gBAAgB,EAAE,IA6O9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IA4G9B,CAAC;AAQF,eAAO,MAAM,iBAAiB,EAAE,IA8F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAIxC,CAAC"}
1
+ {"version":3,"file":"jobOpenings.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobOpenings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAkdlC,eAAO,MAAM,gBAAgB,EAAE,IA2O9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IA4G9B,CAAC;AAQF,eAAO,MAAM,iBAAiB,EAAE,IA8F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAIxC,CAAC"}
@@ -14,11 +14,10 @@
14
14
  * Direct — JSON-LD schema.org/JobPosting + heuristic <a> tag parsing
15
15
  */
16
16
  import { Type } from "@google/genai";
17
- import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, renameSync, } from "fs";
17
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, } from "fs";
18
18
  import { resolve, dirname } from "path";
19
19
  import { fileURLToPath } from "url";
20
20
  import { homedir } from "os";
21
- const __dirname = dirname(fileURLToPath(import.meta.url));
22
21
  // ---------------------------------------------------------------------------
23
22
  // CSV Schema — job_openings.csv
24
23
  // ---------------------------------------------------------------------------
@@ -40,16 +39,39 @@ const OPENING_HEADERS = [
40
39
  // ---------------------------------------------------------------------------
41
40
  // File path
42
41
  // ---------------------------------------------------------------------------
42
+ /** Resolves the jobs.csv path the same way local-tracker.ts does. */
43
+ function getJobsCsvPath() {
44
+ const __filename = fileURLToPath(import.meta.url);
45
+ const __dirpath = dirname(__filename);
46
+ // 1. Check dev repo locations (for local development)
47
+ const devCandidates = [
48
+ resolve(__dirpath, "../../../../career-ops/data/jobs.csv"),
49
+ resolve(__dirpath, "../../../../../career-ops/data/jobs.csv"),
50
+ resolve(process.cwd(), "career-ops/data/jobs.csv"),
51
+ ];
52
+ for (const p of devCandidates) {
53
+ if (existsSync(p))
54
+ return p;
55
+ }
56
+ // 2. Global ~/.careervivid/jobs.csv for installed users
57
+ return resolve(homedir(), ".careervivid", "jobs.csv");
58
+ }
43
59
  function getOpeningsPath() {
44
- // Mirror jobs.csv location: career-ops/data/job_openings.csv
45
- const careerOpsPath = resolve(__dirname, "..", "..", "..", "..", "career-ops", "data");
46
- if (existsSync(careerOpsPath))
47
- return resolve(careerOpsPath, "job_openings.csv");
48
- // Fallback: ~/.careervivid/
49
- const dir = resolve(homedir(), ".careervivid");
50
- if (!existsSync(dir))
51
- mkdirSync(dir, { recursive: true });
52
- return resolve(dir, "job_openings.csv");
60
+ const __filename = fileURLToPath(import.meta.url);
61
+ const __dirpath = dirname(__filename);
62
+ // Mirror next to jobs.csv
63
+ const devCandidates = [
64
+ resolve(__dirpath, "../../../../career-ops/data/job_openings.csv"),
65
+ resolve(__dirpath, "../../../../../career-ops/data/job_openings.csv"),
66
+ resolve(process.cwd(), "career-ops/data/job_openings.csv"),
67
+ ];
68
+ for (const p of devCandidates) {
69
+ if (existsSync(p))
70
+ return p;
71
+ }
72
+ // Fallback: mirror next to jobs.csv
73
+ const jobsPath = getJobsCsvPath();
74
+ return resolve(dirname(jobsPath), "job_openings.csv");
53
75
  }
54
76
  function loadOpenings() {
55
77
  const path = getOpeningsPath();
@@ -452,10 +474,8 @@ openings_scan is for drilling into companies the user has already vetted.`,
452
474
  },
453
475
  execute: async (args) => {
454
476
  try {
455
- // Load companies from jobs.csv
456
- const jobsCsvPath = resolve(__dirname, "..", "..", "..", "..", "career-ops", "data", "jobs.csv");
457
- const altPath = resolve(homedir(), ".careervivid", "jobs.csv");
458
- const csvPath = existsSync(jobsCsvPath) ? jobsCsvPath : altPath;
477
+ // Load companies from jobs.csv — use the same path-resolution as local-tracker.ts
478
+ const csvPath = getJobsCsvPath();
459
479
  if (!existsSync(csvPath)) {
460
480
  return "❌ No jobs.csv found. Add some companies to your tracker first with tracker_add_job.";
461
481
  }
@@ -1 +1 @@
1
- {"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAmBlC,eAAO,MAAM,cAAc,EAAE,IAmG5B,CAAC;AAMF,eAAO,MAAM,WAAW,EAAE,IA4EzB,CAAC;AAMF,eAAO,MAAM,YAAY,EAAE,IAgE1B,CAAC;AAMF,eAAO,MAAM,mBAAmB,EAAE,IAwDjC,CAAC;AAMF,eAAO,MAAM,aAAa,EAAE,IAgC3B,CAAC;AAMF,eAAO,MAAM,eAAe,EAAE,IA6B7B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IAmE9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IAwB9B,CAAC;AAMF,eAAO,MAAM,cAAc,EAAE,IAyO5B,CAAC;AAMF,eAAO,MAAM,aAAa,EAAE,IAAI,EAU/B,CAAC"}
1
+ {"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAmBlC,eAAO,MAAM,cAAc,EAAE,IAmG5B,CAAC;AAMF,eAAO,MAAM,WAAW,EAAE,IA4EzB,CAAC;AAMF,eAAO,MAAM,YAAY,EAAE,IAgE1B,CAAC;AAMF,eAAO,MAAM,mBAAmB,EAAE,IAwDjC,CAAC;AAMF,eAAO,MAAM,aAAa,EAAE,IAgC3B,CAAC;AAMF,eAAO,MAAM,eAAe,EAAE,IA6B7B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IAmE9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IAwB9B,CAAC;AAMF,eAAO,MAAM,cAAc,EAAE,IAiP5B,CAAC;AAMF,eAAO,MAAM,aAAa,EAAE,IAAI,EAU/B,CAAC"}
@@ -547,14 +547,20 @@ FILLING RULES:
547
547
  return `\u274c browser_sidecar.py not found at expected path: ${sidecarPath}`;
548
548
  }
549
549
  // ── 5. Resolve LLM config ─────────────────────────────────────────
550
+ // CV_SESSION_LLM_* env vars are injected at agent startup and reflect
551
+ // the provider/model the user actually picked — always use them first.
550
552
  const { loadConfig, getGeminiKey, getLlmConfig } = await import("../../config.js");
551
553
  const cfg = loadConfig();
552
554
  const llmCfg = getLlmConfig();
555
+ const sessionProvider = process.env.CV_SESSION_LLM_PROVIDER;
556
+ const sessionModel = process.env.CV_SESSION_LLM_MODEL;
557
+ const sessionApiKey = process.env.CV_SESSION_LLM_APIKEY;
558
+ const sessionBaseUrl = process.env.CV_SESSION_LLM_BASE_URL;
553
559
  const llmConfig = {
554
- provider: llmCfg.provider,
555
- model: args.model || llmCfg.model || "gemini-3.1-flash-lite-preview",
556
- apiKey: llmCfg.apiKey || cfg.geminiKey || cfg.llmApiKey || getGeminiKey() || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || "",
557
- baseUrl: llmCfg.baseUrl,
560
+ provider: sessionProvider ?? llmCfg.provider,
561
+ model: args.model || sessionModel || llmCfg.model || "gemini-3.1-flash-lite-preview",
562
+ apiKey: sessionApiKey || llmCfg.apiKey || cfg.geminiKey || cfg.llmApiKey || getGeminiKey() || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || "",
563
+ baseUrl: sessionBaseUrl || llmCfg.baseUrl,
558
564
  };
559
565
  // For CareerVivid Cloud, use the CV account key for the proxy — no personal LLM key needed
560
566
  if (llmConfig.provider === "careervivid") {
@@ -1 +1 @@
1
- {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,QAuBvB"}
1
+ {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,QAwBvB"}
@@ -53,10 +53,11 @@ export function printBanner(options, selectedProvider, selectedModel, thinkingBu
53
53
  if (options.coding)
54
54
  console.log(chalk.green(" ✔ Coding mode: file I/O, shell, search tools active"));
55
55
  if (options.jobs) {
56
- console.log(chalk.cyan(" ✔ Job mode: search, save, list, status update, apply_to_job tools active"));
56
+ console.log(chalk.cyan(" ✔ Job mode: search, score, apply_to_job, openings_scan tools active"));
57
57
  console.log(chalk.magenta(" ✔ Browser mode: navigate, click, type, select, scroll, screenshot tools active"));
58
- console.log(chalk.yellow(" ✔ Local tracker: list_local_jobs · update_local_job · add_local_job"));
59
- console.log(chalk.yellow(" + score_pipeline · get_pipeline_metrics · flag_stale_jobs (jobs.csv v2)"));
58
+ console.log(chalk.yellow(" ✔ Local tracker: tracker_list_jobs · tracker_update_job · tracker_add_job"));
59
+ console.log(chalk.yellow(" + tracker_rank_priority · tracker_dashboard · tracker_find_stale"));
60
+ console.log(chalk.yellow(" + tracker_recheck_urls · openings_scan · openings_list (jobs.csv v2)"));
60
61
  }
61
62
  else if (options.resume)
62
63
  console.log(chalk.cyan(" ✔ Resume mode: get_resume tool active"));
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAWpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QAwHpD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAapC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QA6IpD"}
@@ -5,6 +5,7 @@ import { runAgentConfig, promptForAgentModel } from "./configurator.js";
5
5
  import { getTools } from "./toolRegistry.js";
6
6
  import { getSystemInstruction, buildEngine, printBanner } from "./engineResolver.js";
7
7
  import { askLoop } from "./repl.js";
8
+ import { initSessionContext } from "../../agent/agentAuditLog.js";
8
9
  const { prompt } = pkg;
9
10
  export function registerAgentCommand(program) {
10
11
  const agentCmd = program
@@ -94,8 +95,28 @@ export function registerAgentCommand(program) {
94
95
  }
95
96
  const includeThoughts = Boolean(options.verbose);
96
97
  const systemInstruction = getSystemInstruction(options);
98
+ // ── Inject session LLM config into env so sub-tools (e.g. ApplyToJobTool)
99
+ // always use the provider/model the user selected in this session, not the
100
+ // stale value from ~/.careervividrc.json.
101
+ process.env.CV_SESSION_LLM_PROVIDER = selectedProvider;
102
+ process.env.CV_SESSION_LLM_MODEL = selectedModel;
103
+ if (options["api-key"]) {
104
+ process.env.CV_SESSION_LLM_APIKEY = options["api-key"];
105
+ }
106
+ else if (geminiApiKey) {
107
+ process.env.CV_SESSION_LLM_APIKEY = geminiApiKey;
108
+ }
109
+ else if (cvApiKey) {
110
+ process.env.CV_SESSION_LLM_APIKEY = cvApiKey;
111
+ }
112
+ if (options["base-url"]) {
113
+ process.env.CV_SESSION_LLM_BASE_URL = options["base-url"];
114
+ }
97
115
  const engine = buildEngine(selectedProvider, selectedModel, systemInstruction, tools, thinkingBudget, includeThoughts, cvApiKey, geminiApiKey, project);
98
116
  printBanner(options, selectedProvider, selectedModel, thinkingBudget);
117
+ // ── Record session context for memory ────────────────────────────────────────────────
118
+ const agentMode = options.jobs ? "jobs" : options.resume ? "resume" : "coding";
119
+ initSessionContext(agentMode, selectedModel);
99
120
  await askLoop(engine, options, selectedProvider, selectedModel, cvApiKey, systemInstruction, tools);
100
121
  });
101
122
  agentCmd.command("config")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "1.12.38",
3
+ "version": "1.12.40",
4
4
  "description": "Official CLI for CareerVivid — publish articles, diagrams, and portfolio updates from your terminal or AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -158,24 +158,56 @@ def get_llm(llm_config: dict):
158
158
 
159
159
  if provider == "careervivid":
160
160
  return ChatCareerVivid(model=model, cv_api_key=api_key)
161
-
162
- if provider == "openai":
163
- from browser_use.llm.openai import ChatOpenAI
164
- return ChatOpenAI(model=model, api_key=api_key, base_url=base_url)
165
-
161
+
166
162
  if provider == "anthropic":
167
163
  from browser_use.llm.anthropic import ChatAnthropic
168
164
  return ChatAnthropic(model=model, api_key=api_key)
169
-
165
+
170
166
  if provider == "gemini" or provider == "google":
171
- from browser_use.llm.google import ChatGoogle
172
- return ChatGoogle(model=model, api_key=api_key)
173
-
167
+ try:
168
+ from browser_use.llm.google import ChatGoogle
169
+ return ChatGoogle(model=model, api_key=api_key)
170
+ except ImportError:
171
+ # Fallback: google-genai via openai-compatible shim
172
+ from browser_use.llm.openai import ChatOpenAI
173
+ return ChatOpenAI(
174
+ model=model,
175
+ api_key=api_key,
176
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
177
+ )
178
+
174
179
  if provider == "openrouter":
175
- from browser_use.llm.openrouter import ChatOpenRouter
176
- return ChatOpenRouter(model=model, api_key=api_key)
177
-
178
- raise ValueError(f"Unsupported LLM provider: {provider}")
180
+ # OpenRouter is OpenAI-compatible — use ChatOpenAI with the OpenRouter base URL.
181
+ # browser_use.llm.openrouter does NOT exist as a separate module.
182
+ try:
183
+ from browser_use.llm.openai import ChatOpenAI
184
+ return ChatOpenAI(
185
+ model=model,
186
+ api_key=api_key,
187
+ base_url=base_url or "https://openrouter.ai/api/v1",
188
+ )
189
+ except ImportError:
190
+ raise ImportError(
191
+ "browser_use.llm.openai not found. "
192
+ "Install: pip install 'browser-use[openai]'"
193
+ )
194
+
195
+ if provider == "openai" or provider == "custom":
196
+ from browser_use.llm.openai import ChatOpenAI
197
+ kwargs = dict(model=model, api_key=api_key)
198
+ if base_url:
199
+ kwargs["base_url"] = base_url
200
+ return ChatOpenAI(**kwargs)
201
+
202
+ # Last-resort fallback: treat as OpenAI-compatible
203
+ try:
204
+ from browser_use.llm.openai import ChatOpenAI
205
+ kwargs = dict(model=model, api_key=api_key)
206
+ if base_url:
207
+ kwargs["base_url"] = base_url
208
+ return ChatOpenAI(**kwargs)
209
+ except ImportError:
210
+ raise ValueError(f"Unsupported LLM provider: {provider}")
179
211
 
180
212
 
181
213
  async def run_agent(