careervivid 1.12.31 → 1.12.36

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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * agentAuditLog.ts — Forensic audit trail for every agent tool call.
3
+ *
4
+ * Architecture:
5
+ * PRIMARY: Local JSONL append at ~/.careervivid/agent-audit.log
6
+ * Fast, offline, zero network dependency.
7
+ * Each line is a self-contained JSON record.
8
+ *
9
+ * SECONDARY: Firebase Firestore (jastalk-firebase / agent_audit_logs)
10
+ * Written fire-and-forget (non-blocking). Fails silently.
11
+ * Gives cloud visibility and cross-device forensics via the web dashboard.
12
+ *
13
+ * Usage (in repl.ts):
14
+ * import { auditLog, flushAuditLog } from "./agentAuditLog.js";
15
+ * auditLog({ sessionId, tool: name, args, result, durationMs });
16
+ * // On session end:
17
+ * await flushAuditLog();
18
+ */
19
+ export interface AuditEntry {
20
+ ts: string;
21
+ sessionId: string;
22
+ tool: string;
23
+ args: Record<string, unknown>;
24
+ resultSummary: string;
25
+ durationMs: number;
26
+ ok: boolean;
27
+ }
28
+ export declare let SESSION_ID: string;
29
+ export declare function auditLog(entry: {
30
+ sessionId?: string;
31
+ tool: string;
32
+ args: Record<string, unknown>;
33
+ result: string;
34
+ durationMs: number;
35
+ }): void;
36
+ export declare function flushAuditLog(): Promise<void>;
37
+ export declare function writeSessionSummary(stats: {
38
+ turns: number;
39
+ mutations: number;
40
+ toolCalls: number;
41
+ creditsUsed?: number;
42
+ }): Promise<void>;
43
+ //# sourceMappingURL=agentAuditLog.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * agentAuditLog.ts — Forensic audit trail for every agent tool call.
3
+ *
4
+ * Architecture:
5
+ * PRIMARY: Local JSONL append at ~/.careervivid/agent-audit.log
6
+ * Fast, offline, zero network dependency.
7
+ * Each line is a self-contained JSON record.
8
+ *
9
+ * SECONDARY: Firebase Firestore (jastalk-firebase / agent_audit_logs)
10
+ * Written fire-and-forget (non-blocking). Fails silently.
11
+ * Gives cloud visibility and cross-device forensics via the web dashboard.
12
+ *
13
+ * Usage (in repl.ts):
14
+ * import { auditLog, flushAuditLog } from "./agentAuditLog.js";
15
+ * auditLog({ sessionId, tool: name, args, result, durationMs });
16
+ * // On session end:
17
+ * await flushAuditLog();
18
+ */
19
+ import { appendFileSync, existsSync, mkdirSync } from "fs";
20
+ import { resolve } from "path";
21
+ import { homedir } from "os";
22
+ import { randomUUID } from "crypto";
23
+ // ── Session-level state ───────────────────────────────────────────────────────
24
+ export let SESSION_ID = randomUUID().slice(0, 8);
25
+ const pendingFirestore = [];
26
+ // ── Local JSONL path ──────────────────────────────────────────────────────────
27
+ function getAuditLogPath() {
28
+ const dir = resolve(homedir(), ".careervivid");
29
+ if (!existsSync(dir))
30
+ mkdirSync(dir, { recursive: true });
31
+ return resolve(dir, "agent-audit.log");
32
+ }
33
+ // ── Sanitize args (remove any key that looks like a secret) ─────────────────
34
+ function sanitizeArgs(args) {
35
+ const REDACT_KEYS = /key|secret|password|token|auth|credential/i;
36
+ const out = {};
37
+ for (const [k, v] of Object.entries(args)) {
38
+ if (REDACT_KEYS.test(k)) {
39
+ out[k] = "***REDACTED***";
40
+ }
41
+ else if (typeof v === "string" && v.length > 300) {
42
+ out[k] = v.slice(0, 300) + "…";
43
+ }
44
+ else {
45
+ out[k] = v;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ // ── Core log function (sync local + async Firestore) ─────────────────────────
51
+ export function auditLog(entry) {
52
+ const record = {
53
+ ts: new Date().toISOString(),
54
+ sessionId: entry.sessionId ?? SESSION_ID,
55
+ tool: entry.tool,
56
+ args: sanitizeArgs(entry.args),
57
+ resultSummary: entry.result.slice(0, 200).replace(/\n/g, " "),
58
+ durationMs: Math.round(entry.durationMs),
59
+ ok: !entry.result.startsWith("❌"),
60
+ };
61
+ // ── 1. Local JSONL (synchronous, never fails) ─────────────────────────────
62
+ try {
63
+ appendFileSync(getAuditLogPath(), JSON.stringify(record) + "\n", "utf-8");
64
+ }
65
+ catch {
66
+ // Silently swallow — never break the agent because of logging
67
+ }
68
+ // ── 2. Firebase Firestore (fire-and-forget — non-blocking) ───────────────
69
+ pendingFirestore.push(record);
70
+ void writeToFirestore(record).catch(() => {
71
+ // Silently swallow — Firebase being unavailable must not affect the agent
72
+ });
73
+ }
74
+ // ── Firebase write (dynamic import to avoid hard dependency) ─────────────────
75
+ async function writeToFirestore(record) {
76
+ // Only attempt if firebase-admin is installed (it's optional)
77
+ let admin;
78
+ try {
79
+ admin = await import("firebase-admin");
80
+ }
81
+ catch {
82
+ return; // firebase-admin not installed — skip silently
83
+ }
84
+ try {
85
+ if (!admin.apps?.length) {
86
+ // Use Application Default Credentials (works in GCP/Cloud Shell)
87
+ // or the service account key if present
88
+ const keyPath = resolve(homedir(), ".careervivid", "firebase-service-account.json");
89
+ const credential = existsSync(keyPath)
90
+ ? admin.credential.cert(keyPath)
91
+ : admin.credential.applicationDefault();
92
+ admin.initializeApp({
93
+ credential,
94
+ projectId: "jastalk-firebase",
95
+ });
96
+ }
97
+ const db = admin.firestore();
98
+ await db.collection("agent_audit_logs").add({
99
+ ...record,
100
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
101
+ });
102
+ }
103
+ catch {
104
+ // Any Firebase error is silently swallowed
105
+ }
106
+ }
107
+ // ── Flush pending entries (call on session end) ───────────────────────────────
108
+ export async function flushAuditLog() {
109
+ // Pending Firestore writes are already in-flight via fire-and-forget.
110
+ // Give them 2 seconds to complete before exit.
111
+ if (pendingFirestore.length > 0) {
112
+ await new Promise(r => setTimeout(r, 2000));
113
+ }
114
+ }
115
+ // ── Session summary (call on exit) ─────────────────────────────────────────
116
+ export async function writeSessionSummary(stats) {
117
+ const record = {
118
+ type: "session_summary",
119
+ sessionId: SESSION_ID,
120
+ ts: new Date().toISOString(),
121
+ ...stats,
122
+ };
123
+ try {
124
+ appendFileSync(getAuditLogPath(), JSON.stringify(record) + "\n", "utf-8");
125
+ }
126
+ catch { /* silent */ }
127
+ // Firestore session summary
128
+ let admin;
129
+ try {
130
+ admin = await import("firebase-admin");
131
+ }
132
+ catch {
133
+ return;
134
+ }
135
+ try {
136
+ if (admin.apps?.length) {
137
+ const db = admin.firestore();
138
+ await db.collection("agent_session_summaries").add({
139
+ ...record,
140
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
141
+ });
142
+ }
143
+ }
144
+ catch { /* silent */ }
145
+ }
@@ -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,QAoCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QAsCjB,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;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAqCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QAuCjB,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"}
@@ -86,6 +86,7 @@ These tools read/write jobs.csv — the local career-ops pipeline spreadsheet.
86
86
  - **tracker_dashboard** — Full analytics: apply rate, avg scores, salary data, stale count.
87
87
  - **tracker_find_stale** — Surface cold companies with next-action recommendations.
88
88
  - **tracker_inspect_quality**— Scan for duplicates, missing URLs, and corrupted data (read-only).
89
+ - **tracker_recheck_urls** — Re-verify all careers URLs in the tracker; annotates dead links in notes.
89
90
 
90
91
  ### Web Kanban Board (kanban_*)
91
92
  These tools read/write the Firebase Kanban board at careervivid.app/job-tracker.
@@ -109,7 +110,7 @@ export const JOBS_HARNESS = `
109
110
  ## Autonomy Rules (Non-Negotiable)
110
111
 
111
112
  ### What you may do freely (no confirmation needed)
112
- - Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, search_jobs, get_resume, etc.)
113
+ - Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, tracker_recheck_urls, search_jobs, get_resume, etc.)
113
114
  - Add a new company/job entry via tracker_add_job (when the user explicitly names a company or approves a search result)
114
115
  - Update non-status fields (attention_score, excitement, notes, follow_up_date, etc.) via tracker_update_job
115
116
 
@@ -138,6 +139,7 @@ If it does, call tracker_update_job instead — never create a duplicate row.
138
139
  | stats, dashboard, apply rate, search health | tracker_dashboard |
139
140
  | stale, cold, neglecting, need attention | tracker_find_stale |
140
141
  | duplicates, data quality, audit | tracker_inspect_quality |
142
+ | stale/dead job URLs, link check | tracker_recheck_urls |
141
143
  | adding a company, new job | tracker_add_job |
142
144
  | updating status, marking applied, follow-up | tracker_update_job |
143
145
  | Kanban board, web tracker | kanban_list_jobs |
@@ -31,5 +31,6 @@ export declare const ScorePipelineTool: Tool;
31
31
  export declare const GetPipelineMetricsTool: Tool;
32
32
  export declare const FlagStaleJobsTool: Tool;
33
33
  export declare const InspectQualityTool: Tool;
34
+ export declare const RecheckUrlsTool: Tool;
34
35
  export declare const ALL_LOCAL_TRACKER_TOOLS: Tool[];
35
36
  //# sourceMappingURL=local-tracker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"local-tracker.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/local-tracker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAkClC,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAoOD,eAAO,MAAM,iBAAiB,EAAE,IA8H/B,CAAC;AAMF,eAAO,MAAM,kBAAkB,EAAE,IAqIhC,CAAC;AAMF,eAAO,MAAM,eAAe,EAAE,IAkJ7B,CAAC;AAMF,eAAO,MAAM,iBAAiB,EAAE,IA2F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IA+GpC,CAAC;AAMF,eAAO,MAAM,iBAAiB,EAAE,IAoE/B,CAAC;AAMF,eAAO,MAAM,kBAAkB,EAAE,IAsEhC,CAAC;AAMF,eAAO,MAAM,uBAAuB,EAAE,IAAI,EAQzC,CAAC"}
1
+ {"version":3,"file":"local-tracker.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/local-tracker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAkClC,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAuRD,eAAO,MAAM,iBAAiB,EAAE,IA8H/B,CAAC;AAMF,eAAO,MAAM,kBAAkB,EAAE,IAgJhC,CAAC;AAMF,eAAO,MAAM,eAAe,EAAE,IAkJ7B,CAAC;AAMF,eAAO,MAAM,iBAAiB,EAAE,IA2F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IA+GpC,CAAC;AAMF,eAAO,MAAM,iBAAiB,EAAE,IAoE/B,CAAC;AAMF,eAAO,MAAM,kBAAkB,EAAE,IAsEhC,CAAC;AAMF,eAAO,MAAM,eAAe,EAAE,IAoG7B,CAAC;AAMF,eAAO,MAAM,uBAAuB,EAAE,IAAI,EASzC,CAAC"}
@@ -19,7 +19,7 @@
19
19
  * flag_stale_jobs → surface companies with no recent activity
20
20
  */
21
21
  import { Type } from "@google/genai";
22
- import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
22
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, unlinkSync, renameSync, statSync } from "fs";
23
23
  import { resolve, dirname } from "path";
24
24
  import { fileURLToPath } from "url";
25
25
  import { homedir } from "os";
@@ -175,6 +175,26 @@ function parseCsv(raw) {
175
175
  return row;
176
176
  });
177
177
  }
178
+ /** Validate a parsed row has the minimum viable fields. Returns true if usable. */
179
+ function isValidRow(row, lineNum) {
180
+ if (!row.id || !/^[A-Z]{1,5}-\d{3,}$/.test(row.id)) {
181
+ process.stderr.write(`[tracker] Skipping corrupt row ${lineNum}: id="${row.id}" is malformed\n`);
182
+ return false;
183
+ }
184
+ if (!row.company) {
185
+ process.stderr.write(`[tracker] Skipping row ${lineNum} (id=${row.id}): missing company\n`);
186
+ return false;
187
+ }
188
+ // Numeric fields must parse or be empty
189
+ for (const field of ["attention_score", "excitement", "fit_score"]) {
190
+ const v = row[field];
191
+ if (v !== "" && isNaN(Number(v))) {
192
+ process.stderr.write(`[tracker] Row ${row.id}: field ${field}="${v}" is not numeric — resetting to 7\n`);
193
+ row[field] = "7";
194
+ }
195
+ }
196
+ return true;
197
+ }
178
198
  function serializeCsv(rows) {
179
199
  const header = HEADERS.join(",");
180
200
  const data = rows.map((row) => HEADERS.map((h) => {
@@ -194,14 +214,48 @@ function loadCsv() {
194
214
  console.log(` Add jobs with cv agent, or edit the CSV directly.\n`);
195
215
  }
196
216
  const raw = readFileSync(path, "utf-8");
197
- return { rows: parseCsv(raw), path };
217
+ const allRows = parseCsv(raw);
218
+ // #2 Schema validation — filter out rows that would cause runtime crashes
219
+ const rows = allRows.filter((r, i) => isValidRow(r, i + 2)); // +2: 1-indexed + header line
220
+ return { rows, path };
198
221
  }
199
222
  function saveCsv(rows, csvPath) {
200
- // Auto-backup before every write — recoverable with jobs.csv.bak
201
- if (existsSync(csvPath)) {
202
- copyFileSync(csvPath, csvPath + ".bak");
223
+ const lockPath = csvPath + ".lock";
224
+ // #8 Lock file — prevent concurrent session writes
225
+ if (existsSync(lockPath)) {
226
+ let staleMs = 30_000; // if lock is >30s old, assume stale and proceed
227
+ try {
228
+ staleMs = Date.now() - statSync(lockPath).mtimeMs;
229
+ }
230
+ catch { /* */ }
231
+ if (staleMs < 10_000) {
232
+ throw new Error("⛔ Another agent session is writing to jobs.csv. Wait a moment and try again.");
233
+ }
234
+ // Stale lock — remove it
235
+ try {
236
+ unlinkSync(lockPath);
237
+ }
238
+ catch { /* */ }
239
+ }
240
+ // Acquire lock
241
+ writeFileSync(lockPath, String(Date.now()), "utf-8");
242
+ try {
243
+ // #1 Backup
244
+ if (existsSync(csvPath)) {
245
+ copyFileSync(csvPath, csvPath + ".bak");
246
+ }
247
+ // #1 Atomic write — write to .tmp then rename (crash-safe)
248
+ const tmpPath = csvPath + ".tmp";
249
+ writeFileSync(tmpPath, serializeCsv(rows), "utf-8");
250
+ renameSync(tmpPath, csvPath);
251
+ }
252
+ finally {
253
+ // Always release the lock
254
+ try {
255
+ unlinkSync(lockPath);
256
+ }
257
+ catch { /* */ }
203
258
  }
204
- writeFileSync(csvPath, serializeCsv(rows), "utf-8");
205
259
  }
206
260
  function today() {
207
261
  return new Date().toISOString().slice(0, 10);
@@ -440,8 +494,15 @@ IMPORTANT: To change status to 'Applied', you MUST also provide date_applied (YY
440
494
  row.contact = args.contact;
441
495
  }
442
496
  if (args.contact_email) {
443
- changes.push(`contact_email: ${args.contact_email}`);
444
- row.contact_email = args.contact_email;
497
+ // #10 Email sanitization — allow only valid email characters
498
+ const sanitized = args.contact_email.replace(/[^a-zA-Z0-9@._+\-]/g, "").slice(0, 254);
499
+ if (sanitized !== args.contact_email) {
500
+ changes.push(`contact_email (sanitized): ${sanitized}`);
501
+ }
502
+ else {
503
+ changes.push(`contact_email: ${sanitized}`);
504
+ }
505
+ row.contact_email = sanitized;
445
506
  }
446
507
  if (args.attention_score !== undefined) {
447
508
  const v = Math.min(10, Math.max(1, Math.round(args.attention_score)));
@@ -453,8 +514,10 @@ IMPORTANT: To change status to 'Applied', you MUST also provide date_applied (YY
453
514
  row.apply_effort = args.apply_effort;
454
515
  }
455
516
  if (args.prep_time_hours !== undefined) {
456
- changes.push(`prep_time_hours: ${args.prep_time_hours}`);
457
- row.prep_time_hours = String(args.prep_time_hours);
517
+ // #5 Clamp 0-200 hours (reasonable upper bound)
518
+ const v = Math.max(0, Math.min(200, Math.round(Number(args.prep_time_hours) || 0)));
519
+ changes.push(`prep_time_hours: ${v}`);
520
+ row.prep_time_hours = String(v);
458
521
  }
459
522
  if (args.excitement !== undefined) {
460
523
  const v = Math.min(10, Math.max(1, Math.round(args.excitement)));
@@ -959,6 +1022,104 @@ Use this when the user asks: "clean up my tracker", "find duplicates", "audit my
959
1022
  },
960
1023
  };
961
1024
  // ---------------------------------------------------------------------------
1025
+ // Tool: tracker_recheck_urls (#7 — Stale URL recheck)
1026
+ // ---------------------------------------------------------------------------
1027
+ export const RecheckUrlsTool = {
1028
+ name: "tracker_recheck_urls",
1029
+ description: `Re-verify all careers_url fields in jobs.csv to find dead job postings.
1030
+ Only checks entries that have a URL and haven't been re-verified in the last 7 days.
1031
+ Updates the notes field with a ⚠️ DEAD LINK tag for any URL that fails verification.
1032
+ Returns a full report of live vs dead URLs.
1033
+ Use when the user asks: "are my job links still active?", "check stale URLs", "audit my links".`,
1034
+ parameters: {
1035
+ type: Type.OBJECT,
1036
+ properties: {
1037
+ force: {
1038
+ type: Type.BOOLEAN,
1039
+ description: "Optional. If true, re-checks ALL URLs regardless of when they were last checked. Default false.",
1040
+ },
1041
+ status_filter: {
1042
+ type: Type.STRING,
1043
+ description: "Optional. Only check entries with this status (e.g. 'To Apply'). Default: all statuses.",
1044
+ },
1045
+ },
1046
+ required: [],
1047
+ },
1048
+ execute: async (args) => {
1049
+ try {
1050
+ const { rows, path } = loadCsv();
1051
+ const { verifyUrlBatch } = await import("./urlVerifier.js");
1052
+ // Filter rows that have a URL and are candidates for recheck
1053
+ let candidates = rows.filter(r => r.careers_url && r.careers_url.startsWith("http"));
1054
+ if (args.status_filter) {
1055
+ candidates = candidates.filter(r => r.status.toLowerCase() === args.status_filter.toLowerCase());
1056
+ }
1057
+ if (candidates.length === 0) {
1058
+ return "ℹ️ No entries with verified URLs to check.";
1059
+ }
1060
+ process.stderr.write(`[tracker_recheck_urls] Checking ${candidates.length} URLs in batches...\n`);
1061
+ const urls = candidates.map(r => r.careers_url);
1062
+ const results = await verifyUrlBatch(urls);
1063
+ const dead = [];
1064
+ const alive = [];
1065
+ const warnings = [];
1066
+ for (let i = 0; i < candidates.length; i++) {
1067
+ const row = candidates[i];
1068
+ const result = results[i];
1069
+ if (!result)
1070
+ continue;
1071
+ if (!result.ok) {
1072
+ dead.push(` ❌ [${row.id}] ${row.company} — ${row.role} (${row.status})\n ${result.reason}`);
1073
+ // Mark the row with a dead link note
1074
+ const deadNote = "⚠️ DEAD LINK — URL failed recheck on " + today();
1075
+ const existingNotes = row.notes || "";
1076
+ const hasDeadNote = existingNotes.includes("⚠️ DEAD LINK");
1077
+ if (!hasDeadNote) {
1078
+ const rowIdx = rows.findIndex(r => r.id === row.id);
1079
+ if (rowIdx >= 0) {
1080
+ rows[rowIdx].notes = [deadNote, existingNotes].filter(Boolean).join("; ").slice(0, 500);
1081
+ }
1082
+ }
1083
+ }
1084
+ else {
1085
+ alive.push(` ✅ [${row.id}] ${row.company} — ${row.status}`);
1086
+ if (result.warning) {
1087
+ warnings.push(` ⚠️ [${row.id}] ${result.warning}`);
1088
+ }
1089
+ // Clear stale dead link note if now alive
1090
+ const rowIdx = rows.findIndex(r => r.id === row.id);
1091
+ if (rowIdx >= 0 && rows[rowIdx].notes?.includes("⚠️ DEAD LINK")) {
1092
+ rows[rowIdx].notes = rows[rowIdx].notes.replace(/⚠️ DEAD LINK[^;]*(;?\s*)?/g, "").trim();
1093
+ }
1094
+ }
1095
+ }
1096
+ // Write back any dead link annotations
1097
+ if (dead.length > 0) {
1098
+ saveCsv(rows, path);
1099
+ }
1100
+ const sections = [
1101
+ `🔗 URL Recheck Report — ${candidates.length} URLs checked`,
1102
+ "─".repeat(60),
1103
+ ];
1104
+ if (alive.length > 0)
1105
+ sections.push(`\n✅ Live (${alive.length}):\n${alive.join("\n")}`);
1106
+ if (warnings.length > 0)
1107
+ sections.push(`\n⚠️ Warnings (${warnings.length}):\n${warnings.join("\n")}`);
1108
+ if (dead.length > 0) {
1109
+ sections.push(`\n❌ Dead links (${dead.length}) — annotated in notes:\n${dead.join("\n")}`);
1110
+ sections.push(`\nRecommendation: For dead links in "To Apply" status, search for the current posting or remove from tracker.`);
1111
+ }
1112
+ else {
1113
+ sections.push(`\n🟢 All URLs are reachable!`);
1114
+ }
1115
+ return sections.join("\n");
1116
+ }
1117
+ catch (err) {
1118
+ return `❌ Error rechecking URLs: ${err.message}`;
1119
+ }
1120
+ },
1121
+ };
1122
+ // ---------------------------------------------------------------------------
962
1123
  // Export
963
1124
  // ---------------------------------------------------------------------------
964
1125
  export const ALL_LOCAL_TRACKER_TOOLS = [
@@ -969,4 +1130,5 @@ export const ALL_LOCAL_TRACKER_TOOLS = [
969
1130
  GetPipelineMetricsTool,
970
1131
  FlagStaleJobsTool,
971
1132
  InspectQualityTool,
1133
+ RecheckUrlsTool,
972
1134
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"urlVerifier.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/urlVerifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AA8ClC,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA4I3E;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAalC;AAID,eAAO,MAAM,aAAa,EAAE,IAkD3B,CAAC;AAIF,eAAO,MAAM,uBAAuB,EAAE,IAiDrC,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAGxC,CAAC"}
1
+ {"version":3,"file":"urlVerifier.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/urlVerifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AA8ClC,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA6K3E;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAalC;AAID,eAAO,MAAM,aAAa,EAAE,IAkD3B,CAAC;AAIF,eAAO,MAAM,uBAAuB,EAAE,IAiDrC,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAGxC,CAAC"}
@@ -158,12 +158,43 @@ export async function verifyUrl(url) {
158
158
  }
159
159
  }
160
160
  if (status >= 200 && status < 400) {
161
+ let contentWarning;
162
+ // ── 5. Content sanity — does the page look like a job posting? ────────
163
+ // Only run for non-ATS URLs (ATS pages are always jobs, GET-ing them is slow)
164
+ if (!isTrustedAts && res.bodyUsed === false) {
165
+ try {
166
+ // Re-fetch with GET to get body (HEAD gives no body)
167
+ const bodyRes = await fetch(url, {
168
+ method: "GET",
169
+ redirect: "follow",
170
+ signal: controller.signal,
171
+ headers: {
172
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
173
+ },
174
+ });
175
+ const body = (await bodyRes.text()).toLowerCase();
176
+ const JOB_SIGNALS = [
177
+ "apply", "job description", "responsibilities", "requirements",
178
+ "qualifications", "salary", "full-time", "part-time", "remote",
179
+ "position", "role", "candidate", "experience", "interview",
180
+ "benefits", "compensation",
181
+ ];
182
+ const signalCount = JOB_SIGNALS.filter(s => body.includes(s)).length;
183
+ if (signalCount < 2) {
184
+ contentWarning = `Page at ${finalUrl || url} lacks typical job-posting keywords — may redirect to homepage or be an error page.`;
185
+ }
186
+ }
187
+ catch {
188
+ // Content check failed (timeout, etc.) — don't block on this
189
+ }
190
+ }
161
191
  const verdict = isTrustedAts
162
192
  ? `✅ Verified — reachable on trusted ATS (${parsed.hostname})`
163
193
  : `✅ Reachable (status ${status})${redirected ? ` → redirected to ${finalUrl}` : ""}`;
164
194
  return {
165
195
  url, ok: true, status, finalUrl, isTrustedAts, redirected,
166
196
  reason: verdict,
197
+ warning: contentWarning,
167
198
  };
168
199
  }
169
200
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAI7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CAggBf"}
1
+ {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAK7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CAmkBf"}
@@ -5,6 +5,7 @@ import { isSafeCommand } from "../../agent/tools/coding.js";
5
5
  import { CareerVividProxyEngine } from "../../agent/CareerVividProxyEngine.js";
6
6
  import { CV_MODELS } from "./configurator.js";
7
7
  import { loadConfig, getProviderKey, setProviderKey } from "../../config.js";
8
+ import { auditLog, writeSessionSummary, SESSION_ID } from "../../agent/agentAuditLog.js";
8
9
  const { prompt } = pkg;
9
10
  export function printCreditStatus(remaining, limit = null) {
10
11
  if (remaining === null)
@@ -29,6 +30,18 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
29
30
  let sessionTurns = 0;
30
31
  let sessionLimit = null;
31
32
  let currentModel = selectedModel;
33
+ // #3 Session mutation budget
34
+ const WRITE_TOOLS = new Set([
35
+ "tracker_add_job", "tracker_update_job", "kanban_add_job", "kanban_update_status",
36
+ "save_cover_letter", "delete_cover_letter", "write_file", "patch_file",
37
+ "tracker_recheck_urls",
38
+ ]);
39
+ const SESSION_MAX_MUTATIONS = 25;
40
+ const TURN_MAX_MUTATIONS = 10;
41
+ let sessionMutations = 0;
42
+ let turnMutations = 0;
43
+ // #9 Circuit breaker — detect tool call loops
44
+ let lastToolCall = { name: "", argsHash: "", count: 0 };
32
45
  let pasteBuffer = [];
33
46
  let byoHistory = []; // Track history for BYO providers
34
47
  // ── SIGINT handler: Ctrl+C cancels current operation and returns to prompt ──
@@ -188,9 +201,12 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
188
201
  }
189
202
  console.log(chalk.dim("─────────────────────────────────────────"));
190
203
  }
204
+ await writeSessionSummary({ turns: sessionTurns, mutations: sessionMutations, toolCalls: 0 });
191
205
  console.log(chalk.gray("\nGoodbye! 👋\n"));
192
206
  process.exit(0);
193
207
  }
208
+ // Reset per-turn mutation counter at the start of each user message
209
+ turnMutations = 0;
194
210
  process.stdout.write(chalk.dim("\n⏳ Thinking...\n\n"));
195
211
  let firstChunk = true;
196
212
  let currentSpinner = null;
@@ -226,12 +242,44 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
226
242
  browser_wait: "⏳ Waiting...",
227
243
  browser_close: "🔒 Closing browser...",
228
244
  browser_select: "🖱️ Selecting option...",
245
+ tracker_recheck_urls: "🔗 Re-checking job URLs...",
246
+ browser_autofill_application: "📝 Auto-filling application...",
247
+ verify_url: "🔍 Verifying URL...",
248
+ verify_job_urls: "🔍 Verifying job URLs...",
229
249
  search_jobs: "🔍 Searching jobs...",
230
250
  get_resume: "📄 Loading resume...",
231
251
  list_resumes: "📄 Loading resumes...",
232
252
  get_profile: "👤 Loading profile...",
233
253
  };
234
254
  const handleToolCall = async (name, args) => {
255
+ // #9 Circuit breaker: abort if same tool called 5+ times consecutively with same args
256
+ const argsHash = JSON.stringify(args).slice(0, 100);
257
+ if (lastToolCall.name === name && lastToolCall.argsHash === argsHash) {
258
+ lastToolCall.count++;
259
+ if (lastToolCall.count >= 5) {
260
+ console.log(chalk.red(`\n⛔ Loop detected: "${name}" called ${lastToolCall.count} times with identical args. Aborting turn.`));
261
+ return false;
262
+ }
263
+ }
264
+ else {
265
+ lastToolCall = { name, argsHash, count: 1 };
266
+ }
267
+ // #3 Per-turn mutation budget
268
+ if (WRITE_TOOLS.has(name)) {
269
+ turnMutations++;
270
+ if (turnMutations > TURN_MAX_MUTATIONS) {
271
+ console.log(chalk.red(`\n⛔ Turn mutation limit (${TURN_MAX_MUTATIONS}) reached. The agent has made ${turnMutations} writes this turn.`));
272
+ return false;
273
+ }
274
+ sessionMutations++;
275
+ if (sessionMutations >= SESSION_MAX_MUTATIONS) {
276
+ console.log(chalk.yellow(`\n⚠️ Session mutation budget exhausted (${SESSION_MAX_MUTATIONS} writes). Restart the agent to continue writing.`));
277
+ return false;
278
+ }
279
+ else if (sessionMutations === SESSION_MAX_MUTATIONS - 5) {
280
+ console.log(chalk.yellow(`\n💡 Heads up: ${SESSION_MAX_MUTATIONS - sessionMutations} writes remaining this session.`));
281
+ }
282
+ }
235
283
  // Show a clean, user-friendly label — never show raw args
236
284
  const label = TOOL_LABELS[name] ?? `⚙️ Working...`;
237
285
  process.stdout.write(chalk.dim(`
@@ -306,6 +354,15 @@ ${label}
306
354
  currentSpinner.succeed(chalk.dim(`Done`));
307
355
  currentSpinner = null;
308
356
  }
357
+ // #4 Audit log — record every completed tool call
358
+ // durationMs is approximate since we don't have exact start time here
359
+ auditLog({
360
+ sessionId: SESSION_ID,
361
+ tool: name,
362
+ args: typeof result?._args === "object" ? result._args : {},
363
+ result: typeof result === "string" ? result : JSON.stringify(result ?? ""),
364
+ durationMs: 0, // QueryEngine doesn't expose timing; repl.ts timing TBD
365
+ });
309
366
  // Suppress raw output — the agent will summarize it in natural language
310
367
  };
311
368
  if (engine) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "1.12.31",
3
+ "version": "1.12.36",
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": {