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.
- package/dist/agent/agentAuditLog.d.ts +43 -0
- package/dist/agent/agentAuditLog.d.ts.map +1 -0
- package/dist/agent/agentAuditLog.js +145 -0
- package/dist/agent/instructions.d.ts.map +1 -1
- package/dist/agent/instructions.js +3 -1
- package/dist/agent/tools/local-tracker.d.ts +1 -0
- package/dist/agent/tools/local-tracker.d.ts.map +1 -1
- package/dist/agent/tools/local-tracker.js +172 -10
- package/dist/agent/tools/urlVerifier.d.ts.map +1 -1
- package/dist/agent/tools/urlVerifier.js +31 -0
- package/dist/commands/agent/repl.d.ts.map +1 -1
- package/dist/commands/agent/repl.js +57 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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,
|
|
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;
|
|
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) {
|