careervivid 1.12.39 → 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.
- package/dist/agent/agentAuditLog.d.ts +2 -0
- package/dist/agent/agentAuditLog.d.ts.map +1 -1
- package/dist/agent/agentAuditLog.js +153 -0
- package/dist/agent/instructions.d.ts +1 -0
- package/dist/agent/instructions.d.ts.map +1 -1
- package/dist/agent/instructions.js +13 -0
- package/dist/agent/memory.d.ts +155 -0
- package/dist/agent/memory.d.ts.map +1 -0
- package/dist/agent/memory.js +464 -0
- package/dist/agent/tools/jobs.d.ts.map +1 -1
- package/dist/agent/tools/jobs.js +10 -4
- package/dist/commands/agent/index.d.ts.map +1 -1
- package/dist/commands/agent/index.js +21 -0
- package/package.json +1 -1
- package/src/apply/browser_sidecar.py +45 -13
|
@@ -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;
|
|
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;
|
|
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":"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"}
|
|
@@ -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":"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,
|
|
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"}
|
package/dist/agent/tools/jobs.js
CHANGED
|
@@ -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":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
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
|
@@ -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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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(
|