ai-lens 0.8.74 → 0.8.81
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/.commithash +1 -1
- package/CHANGELOG.md +23 -0
- package/bin/ai-lens.js +10 -0
- package/cli/import/claude-code.js +363 -0
- package/cli/import/transcript-map.js +215 -0
- package/cli/import.js +66 -0
- package/cli/status.js +126 -30
- package/client/capture.js +5 -5
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0533332
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
|
|
4
4
|
|
|
5
|
+
## 0.8.81 — 2026-06-05
|
|
6
|
+
- improve: `ai-lens status` now tells a stale error from an active one — a past send failure no longer shows red once sending has recovered, and the time and code of the last error are shown (e.g. "recovered — last error 1d ago (ECONNRESET)").
|
|
7
|
+
- improve: `ai-lens status` retries the server health check a few times before reporting "unreachable", so a one-off network blip no longer flags the server as down.
|
|
8
|
+
- improve: `ai-lens status --report` now includes your config and hook files (Cursor / Claude Code / Codex, both global and project scope), with the auth token masked, so setup issues can be diagnosed remotely without sending your local status file.
|
|
9
|
+
|
|
10
|
+
## 0.8.80 — 2026-06-04
|
|
11
|
+
- fix: `ai-lens import claude-code` now caps long prompts (so a huge pasted prompt is no longer dropped by the server), captures prompts from messages that mix text with images/documents (shown as a safe `[N attachment(s)]` placeholder — never the image data), and avoids a stale session "end" time on transcripts that are still being written.
|
|
12
|
+
|
|
13
|
+
## 0.8.79 — 2026-06-04
|
|
14
|
+
- fix: `ai-lens import claude-code` now de-overlaps with live capture using the server instead of a local cache. On a machine already running AI Lens it imports only the part of each session that predates live capture — so it no longer over-skips your history (the old guard wrongly skipped almost everything) or double-counts what live already recorded. Requires the matching server update.
|
|
15
|
+
|
|
16
|
+
## 0.8.78 — 2026-06-04
|
|
17
|
+
- fix: large `ai-lens import claude-code` runs (e.g. `--days 0` or a busy 3–6 months) no longer silently drop events. The importer now ships in batches and only marks a session imported after its events have actually reached the server, so nothing is lost and an interrupted run safely resumes.
|
|
18
|
+
|
|
19
|
+
## 0.8.77 — 2026-06-04
|
|
20
|
+
- fix: `ai-lens import` no longer silently turns a preview into a real import when a value flag is missing (e.g. `--projects --dry-run`). A flag with a missing value now prints a clear error and stops, and `--dry-run` is always honored.
|
|
21
|
+
|
|
22
|
+
## 0.8.76 — 2026-06-04
|
|
23
|
+
- fix: `ai-lens import claude-code` no longer double-counts when AI Lens is already running live — imported token usage dedups against live-captured calls, and sessions already captured live are skipped. Also: clearer errors for bad `--days`/`--since`, exact `--projects` path matching, and a few mapping fixes (tool results without a matching call, subagent activity).
|
|
24
|
+
|
|
25
|
+
## 0.8.75 — 2026-06-04
|
|
26
|
+
- feat: `ai-lens import claude-code` imports your local Claude Code history (`~/.claude/projects`) so your dashboard shows months of real activity minutes after install — sessions, AI-hours, models, MCP/skills, plan-mode and subagent usage. Defaults to the last 30 days; use `--days N` (`--days 0` for everything), `--since YYYY-MM-DD`, `--projects`, or `--dry-run` to preview. Re-running is safe (idempotent).
|
|
27
|
+
|
|
5
28
|
## 0.8.74 — 2026-06-04
|
|
6
29
|
- fix: committed Claude Code project hooks now resolve `capture.js` via `$CLAUDE_PROJECT_DIR`, so they fire from any working directory — no more `MODULE_NOT_FOUND` after the agent `cd`s into a subdirectory.
|
|
7
30
|
- fix: `ai-lens status` and `ai-lens init --project-hooks` recognize the `$CLAUDE_PROJECT_DIR` hook form as current instead of flagging it outdated or overwriting it.
|
package/bin/ai-lens.js
CHANGED
|
@@ -18,6 +18,11 @@ switch (command) {
|
|
|
18
18
|
await status({ report: process.argv.includes('--report') });
|
|
19
19
|
break;
|
|
20
20
|
}
|
|
21
|
+
case 'import': {
|
|
22
|
+
const { default: importCmd } = await import('../cli/import.js');
|
|
23
|
+
await importCmd();
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
21
26
|
case 'version':
|
|
22
27
|
case '--version':
|
|
23
28
|
case '-v': {
|
|
@@ -46,6 +51,11 @@ switch (command) {
|
|
|
46
51
|
console.log(' --use-repo-path Run capture.js from this package; skip copy to ~/.ai-lens/client/');
|
|
47
52
|
console.log(' remove Remove AI Lens hooks and client files');
|
|
48
53
|
console.log(' status Run diagnostics and generate a status report');
|
|
54
|
+
console.log(' import <source> Import local history (source: claude-code)');
|
|
55
|
+
console.log(' --days N Window in days (default 30; 0 = all history)');
|
|
56
|
+
console.log(' --since DATE Import from YYYY-MM-DD instead of --days');
|
|
57
|
+
console.log(' --dry-run Scan + count, write nothing');
|
|
58
|
+
console.log(' --projects LIST Only these project paths (comma-separated)');
|
|
49
59
|
console.log(' version Show package version and commit hash');
|
|
50
60
|
process.exit(command ? 1 : 0);
|
|
51
61
|
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai-lens import claude-code` engine.
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.claude/projects/**\/*.jsonl, maps each transcript to unified events
|
|
5
|
+
* (cli/import/transcript-map.js), enriches with developer + git identity,
|
|
6
|
+
* redacts + spools via the live client pipeline (writeToSpool), and lets the
|
|
7
|
+
* existing sender ship them to POST /api/events. Historical timestamps are kept
|
|
8
|
+
* verbatim; re-import is idempotent via the server content-hash dedup.
|
|
9
|
+
*/
|
|
10
|
+
import { createReadStream, existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { join, basename } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { createInterface } from 'node:readline';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { writeToSpool, canonicalizeProjectPath, deterministicEventId } from '../../client/capture.js';
|
|
19
|
+
import { PENDING_DIR, SENDING_DIR, DATA_DIR, ensureDataDir, getGitIdentity, getGitMetadata, getServerUrl, getAuthToken } from '../../client/config.js';
|
|
20
|
+
import { mapTranscript } from './transcript-map.js';
|
|
21
|
+
import { info, success, warn, error, heading, detail, blank } from '../logger.js';
|
|
22
|
+
|
|
23
|
+
const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
24
|
+
const LEDGER_PATH = join(DATA_DIR, 'import-state', 'claude-code.json');
|
|
25
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
26
|
+
|
|
27
|
+
// TokenUsage gets a LIVE-compatible event_id (sha of the assistant line uuid,
|
|
28
|
+
// exactly as client/capture.js:1363) so an imported call dedups against the same
|
|
29
|
+
// call captured live. Everything else uses an import-scoped deterministic id.
|
|
30
|
+
export function computeEventId(ev) {
|
|
31
|
+
if (ev.type === 'TokenUsage' && ev.raw && ev.raw.source_uuid) {
|
|
32
|
+
return deterministicEventId(`claude_code:tokenusage:${ev.raw.source_uuid}`);
|
|
33
|
+
}
|
|
34
|
+
return deterministicEventId(`claude_code:import:v1:${ev._seed}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86400_000;
|
|
38
|
+
// Drain the spool to the server every DRAIN_BATCH spooled events. MUST stay well
|
|
39
|
+
// under sender.js MAX_QUEUE_SIZE (10_000) — once pending exceeds that, the sender
|
|
40
|
+
// DROPS the oldest overflow files (data loss). Draining in batches also lets us
|
|
41
|
+
// commit the ledger only AFTER a batch's events have actually shipped, so a failed
|
|
42
|
+
// send never leaves files marked covered. (sender.js can't be imported for the
|
|
43
|
+
// constant — it self-invokes main() on import.)
|
|
44
|
+
const DRAIN_BATCH = 4000;
|
|
45
|
+
// A transcript untouched for ≥ this long is treated as a finished session, so its
|
|
46
|
+
// terminal Stop/SubagentStop marker is safe to emit (matches the 5h chain-gap).
|
|
47
|
+
const TERMINAL_DORMANT_MS = 5 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
/** Validate flags. Returns an error string, or null when ok. */
|
|
50
|
+
export function validateFlags({ days, since }) {
|
|
51
|
+
if (since != null) {
|
|
52
|
+
if (typeof since !== 'string' || Number.isNaN(Date.parse(since))) {
|
|
53
|
+
return `Invalid --since "${since}". Use YYYY-MM-DD.`;
|
|
54
|
+
}
|
|
55
|
+
} else if (!Number.isInteger(days) || days < 0) {
|
|
56
|
+
return `Invalid --days "${days}". Use a non-negative integer (0 = all history).`;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve the cutoff ISO string from flags. `--days 0` / no window ⇒ epoch (all). */
|
|
62
|
+
export function resolveCutoff({ days, since }, now = new Date()) {
|
|
63
|
+
if (since) return new Date(Date.parse(since)).toISOString();
|
|
64
|
+
if (days === 0) return new Date(0).toISOString();
|
|
65
|
+
const d = Number.isInteger(days) && days >= 0 ? days : 30;
|
|
66
|
+
return new Date(now.getTime() - d * DAY_MS).toISOString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Expand a leading ~ and resolve to a canonical project (git) root. */
|
|
70
|
+
function normalizeProjectArg(p) {
|
|
71
|
+
let s = (p || '').trim();
|
|
72
|
+
if (!s) return null;
|
|
73
|
+
if (s === '~' || s.startsWith('~/')) s = join(homedir(), s.slice(1));
|
|
74
|
+
let real = s;
|
|
75
|
+
try { real = realpathSync(s); } catch { /* path may not exist on disk — keep as given */ }
|
|
76
|
+
return canonicalizeProjectPath(real) || real;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Path-boundary match (so /repo does NOT match /repo2). */
|
|
80
|
+
export function projectMatches(projectPath, filters) {
|
|
81
|
+
if (!filters) return true;
|
|
82
|
+
if (!projectPath) return false;
|
|
83
|
+
return filters.some((pf) => projectPath === pf || projectPath.startsWith(pf + '/'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the server, for these session_ids, the earliest LIVE (non-import) event
|
|
88
|
+
* timestamp it already has → { sessionId: minIso }. Best-effort: on any failure
|
|
89
|
+
* (old server without the route, network) returns {} so we import everything
|
|
90
|
+
* (TokenUsage still dedups on its live-compatible id; nothing is lost).
|
|
91
|
+
*/
|
|
92
|
+
export async function fetchCoverage(sessionIds, { fetchImpl = globalThis.fetch } = {}) {
|
|
93
|
+
const out = {};
|
|
94
|
+
if (!sessionIds.length || !fetchImpl) return out;
|
|
95
|
+
const base = getServerUrl();
|
|
96
|
+
const token = getAuthToken();
|
|
97
|
+
const CHUNK = 400;
|
|
98
|
+
for (let i = 0; i < sessionIds.length; i += CHUNK) {
|
|
99
|
+
const batch = sessionIds.slice(i, i + CHUNK);
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetchImpl(new URL('/api/events/coverage', base), {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Auth-Token': token } : {}) },
|
|
104
|
+
body: JSON.stringify({ session_ids: batch }),
|
|
105
|
+
});
|
|
106
|
+
if (res.ok) Object.assign(out, await res.json());
|
|
107
|
+
} catch { /* best-effort */ }
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drop events at/after the live-coverage boundary for their session, so import
|
|
114
|
+
* only adds the pre-live backlog. No boundary ⇒ keep all. (Pure, for tests.)
|
|
115
|
+
*/
|
|
116
|
+
export function sliceEvents(events, coverage) {
|
|
117
|
+
return events.filter((ev) => {
|
|
118
|
+
const boundary = coverage[ev.session_id];
|
|
119
|
+
if (!boundary) return true;
|
|
120
|
+
// Compare instants, not strings — server timestamps may lack the ms ('…00Z'
|
|
121
|
+
// vs '…00.000Z'), which would mis-sort a boundary-equal event lexically.
|
|
122
|
+
return Date.parse(ev.timestamp) < Date.parse(boundary);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function walkJsonl(dir) {
|
|
127
|
+
const out = [];
|
|
128
|
+
let entries;
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
const p = join(dir, e.name);
|
|
132
|
+
if (e.isDirectory()) out.push(...walkJsonl(p));
|
|
133
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadLedger() {
|
|
139
|
+
try { return JSON.parse(readFileSync(LEDGER_PATH, 'utf-8')); } catch { return {}; }
|
|
140
|
+
}
|
|
141
|
+
function saveLedger(ledger) {
|
|
142
|
+
mkdirSync(join(DATA_DIR, 'import-state'), { recursive: true });
|
|
143
|
+
const tmp = LEDGER_PATH + '.tmp';
|
|
144
|
+
writeFileSync(tmp, JSON.stringify(ledger));
|
|
145
|
+
renameSync(tmp, LEDGER_PATH);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A file is already covered if a prior run imported a wider-or-equal window AND the file is unchanged. */
|
|
149
|
+
export function ledgerCovers(entry, cutoff, fp) {
|
|
150
|
+
if (!entry || !entry.fingerprint) return false;
|
|
151
|
+
if (entry.fingerprint.mtimeMs !== fp.mtimeMs || entry.fingerprint.size !== fp.size) return false;
|
|
152
|
+
return entry.complete_all || (entry.covered_cutoff && entry.covered_cutoff <= cutoff);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read one transcript file fully (line-by-line, never JSON.parse(whole_file)). */
|
|
156
|
+
async function readTranscript(filePath) {
|
|
157
|
+
const lines = [];
|
|
158
|
+
let lastTs = null;
|
|
159
|
+
const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity });
|
|
160
|
+
for await (const raw of rl) {
|
|
161
|
+
if (!raw) continue;
|
|
162
|
+
let obj;
|
|
163
|
+
try { obj = JSON.parse(raw); } catch { continue; } // tolerant: skip bad line
|
|
164
|
+
lines.push(obj);
|
|
165
|
+
if ((obj.type === 'user' || obj.type === 'assistant') && typeof obj.timestamp === 'string' && obj.timestamp > (lastTs || '')) {
|
|
166
|
+
lastTs = obj.timestamp;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { lines, lastTs };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function firstCwd(lines) {
|
|
173
|
+
for (const l of lines) {
|
|
174
|
+
if ((l.type === 'user' || l.type === 'assistant') && l.cwd) return l.cwd;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
function firstSessionId(lines, fallback) {
|
|
179
|
+
for (const l of lines) if (l.sessionId) return l.sessionId;
|
|
180
|
+
return fallback;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function drainSpool({ timeoutMs = 120_000 } = {}) {
|
|
184
|
+
const senderPath = join(__dirname, '..', '..', 'client', 'sender.js');
|
|
185
|
+
const pendingCount = () => { try { return readdirSync(PENDING_DIR).filter((f) => f.endsWith('.json')).length; } catch { return 0; } };
|
|
186
|
+
const sendingCount = () => { try { return readdirSync(SENDING_DIR).filter((f) => f.endsWith('.json')).length; } catch { return 0; } };
|
|
187
|
+
const deadline = Date.now() + timeoutMs;
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
if (pendingCount() === 0 && sendingCount() === 0) return true;
|
|
190
|
+
await new Promise((resolve) => {
|
|
191
|
+
const child = spawn(process.execPath, [senderPath], { stdio: 'ignore' });
|
|
192
|
+
child.on('exit', resolve);
|
|
193
|
+
child.on('error', resolve);
|
|
194
|
+
});
|
|
195
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
196
|
+
}
|
|
197
|
+
return pendingCount() === 0 && sendingCount() === 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default async function importClaudeCode(flags) {
|
|
201
|
+
const {
|
|
202
|
+
days = 30, since = null, dryRun = false, projects = null,
|
|
203
|
+
noRedact = false, analysisMaxAgeDays: amAgeFlag = 30,
|
|
204
|
+
} = flags;
|
|
205
|
+
const analysisMaxAgeDays = Number.isInteger(amAgeFlag) && amAgeFlag >= 0 ? amAgeFlag : 30;
|
|
206
|
+
|
|
207
|
+
heading('Import — Claude Code history');
|
|
208
|
+
if (!existsSync(PROJECTS_DIR)) {
|
|
209
|
+
warn(`No Claude Code history found at ${PROJECTS_DIR}`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!dryRun && !getAuthToken()) {
|
|
213
|
+
error('No auth token. Run `npx ai-lens init` first (or set AI_LENS_AUTH_TOKEN).');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (noRedact) warn('⚠ --no-redact: secrets in raw transcripts are NOT redacted client-side and persist locally in the spool if a send fails. Use only for debugging.');
|
|
217
|
+
|
|
218
|
+
const flagErr = validateFlags({ days, since });
|
|
219
|
+
if (flagErr) { error(flagErr); process.exitCode = 1; return; }
|
|
220
|
+
|
|
221
|
+
const cutoff = resolveCutoff({ days, since });
|
|
222
|
+
const projectFilter = projects
|
|
223
|
+
? projects.split(',').map(normalizeProjectArg).filter(Boolean)
|
|
224
|
+
: null;
|
|
225
|
+
ensureDataDir();
|
|
226
|
+
const ledger = loadLedger();
|
|
227
|
+
if (!dryRun) info(`Server: ${getServerUrl()}`);
|
|
228
|
+
info(dryRun ? `Scanning (dry-run, window: ${since || (days === 0 ? 'all' : days + 'd')})…`
|
|
229
|
+
: `Importing window: ${since || (days === 0 ? 'all' : days + 'd')} (cutoff ${cutoff.slice(0, 10)})`);
|
|
230
|
+
|
|
231
|
+
const allFiles = [...new Set(walkJsonl(PROJECTS_DIR))];
|
|
232
|
+
let filesIncluded = 0, filesSkipped = 0, eventCount = 0, sessionCount = 0, liveSkipped = 0;
|
|
233
|
+
let withinWindow = 0, older = 0;
|
|
234
|
+
let lastDate = null;
|
|
235
|
+
const nowMs = Date.now();
|
|
236
|
+
|
|
237
|
+
// Batched shipping: spool events, and every DRAIN_BATCH events drain the spool
|
|
238
|
+
// to the server, committing the ledger only for files whose events have shipped.
|
|
239
|
+
let inFlight = 0; // events spooled since the last successful drain
|
|
240
|
+
const pendingCommit = []; // {filePath, entry} for fully-written, not-yet-committed files
|
|
241
|
+
let drainFailed = false;
|
|
242
|
+
const flush = async () => {
|
|
243
|
+
if (dryRun) { inFlight = 0; return true; }
|
|
244
|
+
const ok = await drainSpool();
|
|
245
|
+
if (!ok) { drainFailed = true; return false; }
|
|
246
|
+
inFlight = 0;
|
|
247
|
+
for (const c of pendingCommit) ledger[c.filePath] = c.entry;
|
|
248
|
+
if (pendingCommit.length) saveLedger(ledger);
|
|
249
|
+
pendingCommit.length = 0;
|
|
250
|
+
return true;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Pass 1 — cheap shortlist (mtime + ledger), no file reads. A transcript's
|
|
254
|
+
// filename basename IS the session UUID, so we can gather session ids for the
|
|
255
|
+
// coverage query without reading anything.
|
|
256
|
+
const candidates = [];
|
|
257
|
+
for (const filePath of allFiles) {
|
|
258
|
+
let st;
|
|
259
|
+
try { st = statSync(filePath); } catch { continue; }
|
|
260
|
+
const fp = { mtimeMs: Math.trunc(st.mtimeMs), size: st.size };
|
|
261
|
+
if (st.mtime.toISOString() < cutoff) { filesSkipped++; continue; } // last write before window
|
|
262
|
+
if (ledgerCovers(ledger[filePath], cutoff, fp)) { filesSkipped++; continue; }
|
|
263
|
+
candidates.push({ filePath, fp });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Ask the server which of these sessions it already has LIVE (non-import) events
|
|
267
|
+
// for, so we import only the pre-live backlog — replacing the old, unsafe
|
|
268
|
+
// session-paths "delivery" guess (a local cache written before delivery).
|
|
269
|
+
const coverage = await fetchCoverage([...new Set(
|
|
270
|
+
candidates.filter((c) => !/\/subagents\/agent-/.test(c.filePath)).map((c) => basename(c.filePath, '.jsonl')),
|
|
271
|
+
)]);
|
|
272
|
+
|
|
273
|
+
// Pass 2 — read, map, de-overlap, ship.
|
|
274
|
+
for (const { filePath, fp } of candidates) {
|
|
275
|
+
if (drainFailed) break;
|
|
276
|
+
const { lines, lastTs } = await readTranscript(filePath);
|
|
277
|
+
if (!lastTs || lastTs < cutoff) { filesSkipped++; continue; }
|
|
278
|
+
|
|
279
|
+
const cwd = firstCwd(lines);
|
|
280
|
+
const projectPath = canonicalizeProjectPath(cwd) || cwd || null;
|
|
281
|
+
if (!projectMatches(projectPath, projectFilter)) { filesSkipped++; continue; }
|
|
282
|
+
|
|
283
|
+
const sessionId = firstSessionId(lines, basename(filePath, '.jsonl'));
|
|
284
|
+
const fileId = createHash('sha1').update(filePath).digest('hex').slice(0, 12);
|
|
285
|
+
const isSubagentFile = /\/subagents\/agent-/.test(filePath);
|
|
286
|
+
const agentId = isSubagentFile ? basename(filePath, '.jsonl') : null;
|
|
287
|
+
|
|
288
|
+
// Only emit terminal markers (Stop/SubagentStop) for dormant files — an
|
|
289
|
+
// actively-appended transcript would otherwise get a stale, file-stable Stop
|
|
290
|
+
// event_id that the server can't update on re-import (see transcript-map).
|
|
291
|
+
const emitTerminal = (nowMs - fp.mtimeMs) >= TERMINAL_DORMANT_MS;
|
|
292
|
+
const mapped = mapTranscript(lines, { sessionId, projectPath, fileId, isSubagentFile, agentId, agentSlug: null, emitTerminal });
|
|
293
|
+
if (mapped.length === 0) { filesSkipped++; continue; }
|
|
294
|
+
const events = sliceEvents(mapped, coverage); // import only pre-live events
|
|
295
|
+
if (events.length === 0) {
|
|
296
|
+
// Whole session is at/after the live boundary — live already owns it.
|
|
297
|
+
liveSkipped++;
|
|
298
|
+
if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const ident = getGitIdentity(cwd);
|
|
303
|
+
const gitMeta = getGitMetadata(projectPath);
|
|
304
|
+
|
|
305
|
+
for (const ev of events) {
|
|
306
|
+
const unified = {
|
|
307
|
+
event_id: computeEventId(ev),
|
|
308
|
+
source: 'claude_code',
|
|
309
|
+
session_id: ev.session_id,
|
|
310
|
+
type: ev.type,
|
|
311
|
+
project_path: ev.project_path,
|
|
312
|
+
timestamp: ev.timestamp,
|
|
313
|
+
data: { ...ev.data, _import: true }, // marks import rows so /coverage ignores them
|
|
314
|
+
raw: ev.raw,
|
|
315
|
+
developer_email: ident.email || null,
|
|
316
|
+
developer_name: ident.name || null,
|
|
317
|
+
git_remote: gitMeta.git_remote || null,
|
|
318
|
+
git_branch: gitMeta.git_branch || null,
|
|
319
|
+
git_commit: gitMeta.git_commit || null,
|
|
320
|
+
};
|
|
321
|
+
if (!dryRun) {
|
|
322
|
+
if (noRedact) writeRaw(unified); else writeToSpool(unified);
|
|
323
|
+
inFlight++;
|
|
324
|
+
// Drain BEFORE pending can approach the sender's overflow cap. Checked per
|
|
325
|
+
// event so a single huge transcript can't blow past it mid-file.
|
|
326
|
+
if (inFlight >= DRAIN_BATCH) { if (!(await flush())) break; }
|
|
327
|
+
}
|
|
328
|
+
eventCount++;
|
|
329
|
+
}
|
|
330
|
+
if (drainFailed) break;
|
|
331
|
+
|
|
332
|
+
filesIncluded++;
|
|
333
|
+
sessionCount++;
|
|
334
|
+
if (!isSubagentFile) (lastTs >= new Date(nowMs - analysisMaxAgeDays * 86400_000).toISOString() ? withinWindow++ : older++);
|
|
335
|
+
if (!lastDate || lastTs > lastDate) lastDate = lastTs;
|
|
336
|
+
// Defer the ledger commit until this file's events have actually shipped (flush()).
|
|
337
|
+
if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
|
|
338
|
+
if (filesIncluded % 25 === 0) info(` …${filesIncluded} files, ${eventCount} events — last ${(lastDate || '').slice(0, 10)}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
blank();
|
|
342
|
+
if (liveSkipped > 0) info(`Skipped ${liveSkipped} session(s) already captured live (no duplication).`);
|
|
343
|
+
if (dryRun) {
|
|
344
|
+
success(`Dry-run: ${filesIncluded} session file(s), ${eventCount} event(s) would import (${filesSkipped} skipped). Nothing written.`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
info(`Spooled ${eventCount} event(s) from ${filesIncluded} session(s). Shipping…`);
|
|
349
|
+
const drained = await flush(); // ship the final partial batch + commit remaining ledger
|
|
350
|
+
if (drained) success(`Imported ${sessionCount} session(s), ${eventCount} event(s).`);
|
|
351
|
+
else warn(`Some events couldn't be shipped — their sessions were NOT marked imported and will retry on the next run. Check \`ai-lens status\`.`);
|
|
352
|
+
|
|
353
|
+
detail(`Within ${analysisMaxAgeDays}d: ${withinWindow} session(s) eligible for auto-analysis. Older: ${older} (skipped by max-age ${analysisMaxAgeDays}, available on-demand).`);
|
|
354
|
+
detail('Open /me to see your imported history.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** --no-redact path: write the event to pending/ WITHOUT client redaction. */
|
|
358
|
+
function writeRaw(unified) {
|
|
359
|
+
const filename = `${unified.event_id}.json`;
|
|
360
|
+
const tmp = join(PENDING_DIR, filename + '.tmp.' + process.pid);
|
|
361
|
+
writeFileSync(tmp, JSON.stringify(unified));
|
|
362
|
+
renameSync(tmp, join(PENDING_DIR, filename));
|
|
363
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, stateful mapper: Claude Code transcript lines (one parsed JSONL record
|
|
3
|
+
* each, in file order) → unified events matching the LIVE capture.js taxonomy
|
|
4
|
+
* for source='claude_code'. No I/O — fully unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* Emits the SAME event types the hook pipeline produces, so imported history is
|
|
7
|
+
* indistinguishable to the dashboard/analyzer: SessionStart, UserPromptSubmit,
|
|
8
|
+
* PostToolUse/PostToolUseFailure, PlanModeStart/PlanModeEnd, TokenUsage, Stop,
|
|
9
|
+
* and SubagentStart/SubagentStop for sidechain (subagent) activity.
|
|
10
|
+
*
|
|
11
|
+
* Each returned event carries a stable `_seed` (NOT a final event_id); the
|
|
12
|
+
* engine prefixes `claude_code:import:v1:` and hashes it. Synthetic markers
|
|
13
|
+
* (SessionStart/Stop/Subagent*) seed off the stable FILE identity so their id
|
|
14
|
+
* doesn't shift when the import window widens.
|
|
15
|
+
*/
|
|
16
|
+
import { truncateToolInput, truncateToolResult, truncate, TRUNCATION_LIMITS } from '../../client/capture.js';
|
|
17
|
+
import { buildTokenUsageRaw } from '../../client/token-usage.js';
|
|
18
|
+
|
|
19
|
+
const PLAN_MODE_TOOLS = { EnterPlanMode: 'PlanModeStart', ExitPlanMode: 'PlanModeEnd' };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a prompt string from a user message's content (string, or an array of
|
|
23
|
+
* text/image/document blocks). Text is concatenated and capped to the live
|
|
24
|
+
* prompt limit; attachments become a safe count placeholder — never base64.
|
|
25
|
+
*/
|
|
26
|
+
function promptFromUserContent(content) {
|
|
27
|
+
if (typeof content === 'string') return truncate(content, TRUNCATION_LIMITS.userPrompt);
|
|
28
|
+
const texts = [];
|
|
29
|
+
let attachments = 0;
|
|
30
|
+
for (const b of asArray(content)) {
|
|
31
|
+
if (b?.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
32
|
+
else if (b?.type === 'image' || b?.type === 'document') attachments++;
|
|
33
|
+
}
|
|
34
|
+
let prompt = texts.join('\n');
|
|
35
|
+
if (attachments > 0) prompt += `${prompt ? '\n' : ''}[${attachments} attachment(s)]`;
|
|
36
|
+
return prompt ? truncate(prompt, TRUNCATION_LIMITS.userPrompt) : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A transcript line is "content" only if it's a user/assistant turn with a timestamp. */
|
|
40
|
+
function isContent(line) {
|
|
41
|
+
return !!line && (line.type === 'user' || line.type === 'assistant') && typeof line.timestamp === 'string';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asArray(content) {
|
|
45
|
+
return Array.isArray(content) ? content : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mcpServerOf(toolName) {
|
|
49
|
+
return typeof toolName === 'string' && toolName.startsWith('mcp__') ? (toolName.split('__')[1] || null) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object[]} lines Parsed JSONL records, in file order.
|
|
54
|
+
* @param {object} ctx { sessionId, projectPath, fileId, isSubagentFile?, agentId?, agentSlug? }
|
|
55
|
+
* @returns {object[]} events: { session_id, type, project_path, timestamp, data, raw, _seed }
|
|
56
|
+
*/
|
|
57
|
+
export function mapTranscript(lines, ctx) {
|
|
58
|
+
const { sessionId, projectPath, fileId, isSubagentFile = false, agentId = null, agentSlug = null, emitTerminal = true } = ctx;
|
|
59
|
+
const events = [];
|
|
60
|
+
const content = (lines || []).filter(isContent);
|
|
61
|
+
if (content.length === 0) return events;
|
|
62
|
+
|
|
63
|
+
const first = content[0];
|
|
64
|
+
const last = content[content.length - 1];
|
|
65
|
+
const mk = (line, type, data, raw, seed) => ({
|
|
66
|
+
session_id: sessionId,
|
|
67
|
+
type,
|
|
68
|
+
project_path: projectPath,
|
|
69
|
+
timestamp: line.timestamp,
|
|
70
|
+
data: data || {},
|
|
71
|
+
raw: raw || {},
|
|
72
|
+
_seed: seed,
|
|
73
|
+
});
|
|
74
|
+
const tokenEvent = (line, seed) => {
|
|
75
|
+
const u = line.message?.usage;
|
|
76
|
+
const model = line.message?.model || null;
|
|
77
|
+
if (!u && !model) return null;
|
|
78
|
+
// source_uuid (the assistant line uuid) mirrors live capture.js exactly, so the
|
|
79
|
+
// engine derives a live-compatible event_id and the server dedups an imported
|
|
80
|
+
// TokenUsage against the same call captured live (no token/usage double-count).
|
|
81
|
+
const raw = buildTokenUsageRaw({ source_uuid: line.uuid || null }, u || null, model);
|
|
82
|
+
const data = {
|
|
83
|
+
...(model ? { model } : {}),
|
|
84
|
+
...(typeof u?.input_tokens === 'number' ? { input_tokens: u.input_tokens } : {}),
|
|
85
|
+
...(typeof u?.output_tokens === 'number' ? { output_tokens: u.output_tokens } : {}),
|
|
86
|
+
};
|
|
87
|
+
return mk(line, 'TokenUsage', data, raw, seed);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ── Subagent transcript file: SubagentStart → TokenUsage* → SubagentStop, all
|
|
91
|
+
// under the PARENT session_id (line.sessionId). No SessionStart / prompts / tools
|
|
92
|
+
// (live attributes only token usage of a subagent to the parent). ──────────────
|
|
93
|
+
if (isSubagentFile) {
|
|
94
|
+
const subData = { agent_id: agentId, agent_type: agentSlug, subagent_type: agentSlug };
|
|
95
|
+
events.push(mk(first, 'SubagentStart', subData, {}, `${sessionId}:substart:${fileId}`));
|
|
96
|
+
content.forEach((line, i) => {
|
|
97
|
+
if (line.type === 'assistant') {
|
|
98
|
+
const ev = tokenEvent(line, `${sessionId}:${line.uuid || fileId + ':' + i}:TokenUsage`);
|
|
99
|
+
if (ev) events.push(ev);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
if (emitTerminal) events.push(mk(last, 'SubagentStop', { agent_id: agentId, agent_type: agentSlug }, {}, `${sessionId}:substop:${fileId}`));
|
|
103
|
+
return events;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Main transcript ────────────────────────────────────────────────────────
|
|
107
|
+
events.push(mk(first, 'SessionStart', { cwd: first.cwd || null }, {}, `${sessionId}:sessionstart:${fileId}`));
|
|
108
|
+
|
|
109
|
+
const toolBuf = new Map(); // tool_use_id -> { tool, input }
|
|
110
|
+
let inSidechain = false;
|
|
111
|
+
let subSeq = 0;
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < content.length; i++) {
|
|
114
|
+
const line = content[i];
|
|
115
|
+
const sidechain = !!line.isSidechain;
|
|
116
|
+
const lineKey = line.uuid || `${fileId}:${i}`;
|
|
117
|
+
|
|
118
|
+
// Sidechain transitions → SubagentStart/Stop in the parent session.
|
|
119
|
+
if (sidechain && !inSidechain) {
|
|
120
|
+
inSidechain = true;
|
|
121
|
+
subSeq++;
|
|
122
|
+
const slug = line.agentSlug || line.slug || null;
|
|
123
|
+
events.push(mk(line, 'SubagentStart',
|
|
124
|
+
{ agent_id: line.agentId || `sub-${subSeq}`, agent_type: slug, subagent_type: slug },
|
|
125
|
+
{}, `${sessionId}:substart:${fileId}:${subSeq}`));
|
|
126
|
+
} else if (!sidechain && inSidechain) {
|
|
127
|
+
inSidechain = false;
|
|
128
|
+
const prev = content[i - 1];
|
|
129
|
+
events.push(mk(prev, 'SubagentStop',
|
|
130
|
+
{ agent_id: prev.agentId || `sub-${subSeq}`, agent_type: prev.agentSlug || prev.slug || null },
|
|
131
|
+
{}, `${sessionId}:substop:${fileId}:${subSeq}`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Inside a sidechain run, live only surfaces the subagent's TOKEN usage in
|
|
135
|
+
// the parent — skip its prompts/tools (avoids inflating parent prompt/tool counts).
|
|
136
|
+
if (inSidechain) {
|
|
137
|
+
if (line.type === 'assistant') {
|
|
138
|
+
const ev = tokenEvent(line, `${sessionId}:${lineKey}:TokenUsage`);
|
|
139
|
+
if (ev) events.push(ev);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (line.type === 'user') {
|
|
145
|
+
const c = line.message?.content;
|
|
146
|
+
// A user turn is a real prompt (string, OR an array with text/image/document
|
|
147
|
+
// blocks) and/or tool_result continuations. Emit the prompt (capped to the
|
|
148
|
+
// live limit, attachments as a placeholder — never base64) AND pair any
|
|
149
|
+
// tool_results.
|
|
150
|
+
const prompt = promptFromUserContent(c);
|
|
151
|
+
if (prompt) {
|
|
152
|
+
events.push(mk(line, 'UserPromptSubmit', { prompt }, {}, `${sessionId}:${lineKey}:UserPromptSubmit`));
|
|
153
|
+
}
|
|
154
|
+
if (typeof c !== 'string') {
|
|
155
|
+
asArray(c).forEach((block, bi) => {
|
|
156
|
+
if (block?.type !== 'tool_result') return;
|
|
157
|
+
const buf = toolBuf.get(block.tool_use_id);
|
|
158
|
+
// No matching tool_use buffered (e.g. it belonged to a skipped sidechain
|
|
159
|
+
// turn) — drop it rather than emit a phantom unknown/null PostToolUse.
|
|
160
|
+
if (!buf) return;
|
|
161
|
+
toolBuf.delete(block.tool_use_id);
|
|
162
|
+
const tool = buf.tool;
|
|
163
|
+
const isErr = block.is_error === true;
|
|
164
|
+
const data = { tool, input: truncateToolInput(buf.input, tool) };
|
|
165
|
+
const mcp = mcpServerOf(tool);
|
|
166
|
+
if (mcp) data.mcp_server = mcp;
|
|
167
|
+
if (tool === 'Skill' && buf?.input?.skill) data.skill_name = buf.input.skill;
|
|
168
|
+
const resultText = truncateToolResult(block.content, tool);
|
|
169
|
+
if (isErr) data.error = resultText; else data.result = resultText;
|
|
170
|
+
events.push(mk(line, isErr ? 'PostToolUseFailure' : 'PostToolUse', data, {},
|
|
171
|
+
`${sessionId}:${lineKey}:tr:${block.tool_use_id || bi}`));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
} else if (line.type === 'assistant') {
|
|
175
|
+
asArray(line.message?.content).forEach((block, bi) => {
|
|
176
|
+
if (block?.type !== 'tool_use') return;
|
|
177
|
+
const name = block.name || 'unknown';
|
|
178
|
+
// Plan-mode tools are promoted immediately — never buffered.
|
|
179
|
+
if (PLAN_MODE_TOOLS[name]) {
|
|
180
|
+
events.push(mk(line, PLAN_MODE_TOOLS[name],
|
|
181
|
+
{ tool: name, input: truncateToolInput(block.input, name) }, {},
|
|
182
|
+
`${sessionId}:${lineKey}:${name}:${bi}`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
toolBuf.set(block.id, { tool: name, input: block.input });
|
|
186
|
+
});
|
|
187
|
+
const ev = tokenEvent(line, `${sessionId}:${lineKey}:TokenUsage`);
|
|
188
|
+
if (ev) events.push(ev);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Unpaired tool_use (no tool_result seen) → best-effort PostToolUse, no result.
|
|
193
|
+
for (const [id, buf] of toolBuf) {
|
|
194
|
+
const data = { tool: buf.tool, input: truncateToolInput(buf.input, buf.tool) };
|
|
195
|
+
const mcp = mcpServerOf(buf.tool);
|
|
196
|
+
if (mcp) data.mcp_server = mcp;
|
|
197
|
+
if (buf.tool === 'Skill' && buf.input?.skill) data.skill_name = buf.input.skill;
|
|
198
|
+
events.push(mk(last, 'PostToolUse', data, {}, `${sessionId}:unpaired:${fileId}:${id}`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Terminal markers (Stop / final SubagentStop) describe the LAST line, which is
|
|
202
|
+
// mutable: an appended transcript shifts the true end, but their event_id is
|
|
203
|
+
// file-stable so the server's insertIgnore would keep the stale one. Stop and
|
|
204
|
+
// SubagentStop are NOT excluded from gap/AI-hours metrics, so a stale end would
|
|
205
|
+
// skew duration. Only emit them once the file is dormant (engine sets
|
|
206
|
+
// emitTerminal=false while a file is still active); SessionStart is the first
|
|
207
|
+
// (immutable) line and is always safe.
|
|
208
|
+
if (emitTerminal && inSidechain) {
|
|
209
|
+
events.push(mk(last, 'SubagentStop',
|
|
210
|
+
{ agent_id: last.agentId || `sub-${subSeq}`, agent_type: last.agentSlug || last.slug || null },
|
|
211
|
+
{}, `${sessionId}:substop:${fileId}:${subSeq}`));
|
|
212
|
+
}
|
|
213
|
+
if (emitTerminal) events.push(mk(last, 'Stop', {}, {}, `${sessionId}:stop:${fileId}`));
|
|
214
|
+
return events;
|
|
215
|
+
}
|
package/cli/import.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai-lens import <source>` — import local AI-tool history into AI Lens.
|
|
3
|
+
* Currently supports `claude-code`; Cursor/Codex/Gemini are separate follow-ups.
|
|
4
|
+
*/
|
|
5
|
+
import { initLogger, error, info } from './logger.js';
|
|
6
|
+
import { getVersionInfo } from './hooks.js';
|
|
7
|
+
|
|
8
|
+
const BOOL_FLAGS = { '--dry-run': 'dryRun', '--no-redact': 'noRedact' };
|
|
9
|
+
const VALUE_FLAGS = { '--days': 'days', '--since': 'since', '--projects': 'projects', '--analysis-max-age-days': 'analysisMaxAgeDays' };
|
|
10
|
+
const INT_KEYS = new Set(['days', 'analysisMaxAgeDays']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse import flags. Returns { flags, errors }. CRITICAL: a value flag whose
|
|
14
|
+
* next token is missing or is itself another flag does NOT consume that token —
|
|
15
|
+
* it records a missing-value error and lets the loop process the following flag.
|
|
16
|
+
* (Otherwise `--projects --dry-run` would swallow `--dry-run` and silently run a
|
|
17
|
+
* REAL import instead of a preview.)
|
|
18
|
+
*/
|
|
19
|
+
export function parseFlags(argv) {
|
|
20
|
+
const flags = { days: 30, since: null, dryRun: false, projects: null, noRedact: false, analysisMaxAgeDays: 30 };
|
|
21
|
+
const errors = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
if (BOOL_FLAGS[arg]) { flags[BOOL_FLAGS[arg]] = true; continue; }
|
|
25
|
+
if (VALUE_FLAGS[arg]) {
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next == null || next.startsWith('--')) { errors.push(`Missing value for ${arg}.`); continue; }
|
|
28
|
+
i++; // consume the value only now that we know it isn't another flag
|
|
29
|
+
const key = VALUE_FLAGS[arg];
|
|
30
|
+
flags[key] = INT_KEYS.has(key) ? parseInt(next, 10) : next;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
errors.push(`Unknown flag "${arg}".`);
|
|
34
|
+
}
|
|
35
|
+
return { flags, errors };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default async function importCmd() {
|
|
39
|
+
const source = process.argv[3];
|
|
40
|
+
const { flags, errors } = parseFlags(process.argv.slice(4));
|
|
41
|
+
|
|
42
|
+
const { version, commit } = getVersionInfo();
|
|
43
|
+
initLogger(`v${version} (${commit})`);
|
|
44
|
+
|
|
45
|
+
if (errors.length) {
|
|
46
|
+
errors.forEach((e) => error(e));
|
|
47
|
+
info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD] [--projects A,B] [--dry-run] [--no-redact]');
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
switch (source) {
|
|
53
|
+
case 'claude-code': {
|
|
54
|
+
const { default: importClaudeCode } = await import('./import/claude-code.js');
|
|
55
|
+
await importClaudeCode(flags);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case undefined:
|
|
59
|
+
error('Usage: ai-lens import <source> (sources: claude-code)');
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
error(`Unknown import source "${source}". Supported: claude-code.`);
|
|
63
|
+
info('Cursor / Codex / Gemini imports are coming separately.');
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/cli/status.js
CHANGED
|
@@ -619,40 +619,69 @@ function checkSenderLog() {
|
|
|
619
619
|
return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
// Aggregate across all log entries for observability
|
|
622
|
+
// Aggregate across all log entries for observability. Counts are all-time, but
|
|
623
|
+
// we also track recency (last error ts + a 24h window) so a single old failure
|
|
624
|
+
// doesn't flag the check red forever — see `recovered` below.
|
|
623
625
|
let sentOk = 0; // total events successfully delivered to server
|
|
624
626
|
let failedCount = 0; // total failed send attempts (connection/auth errors)
|
|
627
|
+
let recentFailed = 0; // failed sends within the last 24h
|
|
625
628
|
let rollbackCount = 0; // total rollbacks (partial + full) — events re-queued for retry
|
|
626
629
|
let lastSend = null;
|
|
630
|
+
let lastSendMs = 0;
|
|
631
|
+
let lastError = null; // ts of the most recent failure/error
|
|
632
|
+
let lastErrorMs = 0;
|
|
633
|
+
let lastErrorCode = null; // connection/error code of that last failure
|
|
627
634
|
let hasErrors = false;
|
|
635
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
636
|
+
const now = Date.now();
|
|
628
637
|
|
|
629
638
|
for (const line of lines) {
|
|
630
639
|
try {
|
|
631
640
|
const entry = JSON.parse(line);
|
|
641
|
+
const tsMs = entry.ts ? new Date(entry.ts).getTime() : NaN;
|
|
632
642
|
if (entry.msg === 'sent') {
|
|
633
643
|
lastSend = entry.ts;
|
|
644
|
+
if (!isNaN(tsMs) && tsMs > lastSendMs) lastSendMs = tsMs;
|
|
634
645
|
sentOk += parseInt(entry.events, 10) || 0;
|
|
635
646
|
}
|
|
636
647
|
if (entry.msg === 'failed' || entry.msg === 'error' || entry.msg === 'auth-failed') {
|
|
637
648
|
hasErrors = true;
|
|
638
649
|
if (entry.msg === 'failed') failedCount++;
|
|
650
|
+
if (!isNaN(tsMs)) {
|
|
651
|
+
if (tsMs > lastErrorMs) {
|
|
652
|
+
lastErrorMs = tsMs;
|
|
653
|
+
lastError = entry.ts;
|
|
654
|
+
lastErrorCode = entry.cause?.code || entry.code || entry.error || null;
|
|
655
|
+
}
|
|
656
|
+
if (entry.msg === 'failed' && now - tsMs <= DAY_MS) recentFailed++;
|
|
657
|
+
}
|
|
639
658
|
}
|
|
640
659
|
if (entry.msg === 'rollback' || entry.msg === 'partial-rollback') rollbackCount++;
|
|
641
660
|
} catch { /* non-JSON line */ }
|
|
642
661
|
}
|
|
643
662
|
|
|
663
|
+
// Recovered = the last successful send is newer than the last error, so the
|
|
664
|
+
// failures are historical noise rather than an active problem. Only treat
|
|
665
|
+
// errors as active (red) when nothing has succeeded since the last one.
|
|
666
|
+
const recovered = lastErrorMs > 0 && lastSendMs > lastErrorMs;
|
|
667
|
+
const activeErrors = hasErrors && !recovered;
|
|
668
|
+
|
|
644
669
|
const stats = [];
|
|
645
670
|
if (sentOk > 0) stats.push(`${sentOk} events sent`);
|
|
646
|
-
if (failedCount > 0) stats.push(`${failedCount} failed`);
|
|
671
|
+
if (failedCount > 0) stats.push(`${failedCount} failed${recentFailed > 0 ? `, ${recentFailed} in 24h` : ''}`);
|
|
647
672
|
if (rollbackCount > 0) stats.push(`${rollbackCount} rollbacks`);
|
|
648
673
|
|
|
674
|
+
const errNote = lastError
|
|
675
|
+
? `last error ${relativeTime(lastError)}${lastErrorCode ? ` (${lastErrorCode})` : ''}`
|
|
676
|
+
: 'has errors';
|
|
677
|
+
|
|
649
678
|
let summary;
|
|
650
679
|
if (lastSend) {
|
|
651
680
|
summary = `last send ${relativeTime(lastSend)}`;
|
|
652
681
|
if (stats.length > 0) summary += ` (${stats.join(', ')})`;
|
|
653
|
-
if (hasErrors) summary +=
|
|
682
|
+
if (hasErrors) summary += recovered ? `, recovered — ${errNote}` : `, ${errNote}`;
|
|
654
683
|
} else if (hasErrors) {
|
|
655
|
-
summary =
|
|
684
|
+
summary = `errors in log — ${errNote}`;
|
|
656
685
|
if (stats.length > 0) summary += ` (${stats.join(', ')})`;
|
|
657
686
|
} else {
|
|
658
687
|
summary = `${lines.length} entries`;
|
|
@@ -660,10 +689,11 @@ function checkSenderLog() {
|
|
|
660
689
|
}
|
|
661
690
|
|
|
662
691
|
const last20 = lines.slice(-20);
|
|
692
|
+
const state = activeErrors ? 'ACTIVE ERRORS' : recovered ? 'recovered' : 'healthy';
|
|
663
693
|
return {
|
|
664
|
-
ok: !
|
|
694
|
+
ok: !activeErrors,
|
|
665
695
|
summary,
|
|
666
|
-
detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount}, rollbacks=${rollbackCount}\nLast 20 entries:\n${last20.join('\n')}`,
|
|
696
|
+
detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount} (${recentFailed} in 24h), rollbacks=${rollbackCount}\nLast send: ${lastSend || '(never)'}\nLast error: ${lastError || '(none)'}${lastErrorCode ? ` (${lastErrorCode})` : ''}\nState: ${state}\nLast 20 entries:\n${last20.join('\n')}`,
|
|
667
697
|
};
|
|
668
698
|
}
|
|
669
699
|
|
|
@@ -816,31 +846,46 @@ async function checkServer(serverUrl) {
|
|
|
816
846
|
}
|
|
817
847
|
|
|
818
848
|
const url = `${serverUrl}/api/health`;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
849
|
+
// A single probe times out on a transient network blip (VPN reconnect, brief
|
|
850
|
+
// packet loss) and flags the server red even when it's actually fine. Retry a
|
|
851
|
+
// few times with a short backoff before the verdict; an HTTP response (even an
|
|
852
|
+
// error status) short-circuits — the server answered, no point retrying.
|
|
853
|
+
const ATTEMPTS = 3;
|
|
854
|
+
const PER_ATTEMPT_TIMEOUT = 5000;
|
|
855
|
+
const codes = [];
|
|
856
|
+
const attemptLog = [];
|
|
857
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
|
858
|
+
const start = Date.now();
|
|
859
|
+
try {
|
|
860
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(PER_ATTEMPT_TIMEOUT) });
|
|
861
|
+
const latency = Date.now() - start;
|
|
862
|
+
const body = await res.text();
|
|
863
|
+
if (res.ok) {
|
|
864
|
+
const note = attempt > 1 ? `, ${attempt}/${ATTEMPTS} attempts` : '';
|
|
865
|
+
return {
|
|
866
|
+
ok: true,
|
|
867
|
+
summary: `reachable (${latency}ms${note})`,
|
|
868
|
+
detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nAttempt: ${attempt}/${ATTEMPTS}\nResponse: ${body}`,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
825
871
|
return {
|
|
826
|
-
ok:
|
|
827
|
-
summary: `
|
|
872
|
+
ok: false,
|
|
873
|
+
summary: `HTTP ${res.status} (${latency}ms)`,
|
|
828
874
|
detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nResponse: ${body}`,
|
|
829
875
|
};
|
|
876
|
+
} catch (err) {
|
|
877
|
+
const latency = Date.now() - start;
|
|
878
|
+
const code = err.code || err.cause?.code || err.message;
|
|
879
|
+
codes.push(code);
|
|
880
|
+
attemptLog.push(`#${attempt}: ${code} (${latency}ms)`);
|
|
881
|
+
if (attempt < ATTEMPTS) await new Promise(r => setTimeout(r, 1000));
|
|
830
882
|
}
|
|
831
|
-
return {
|
|
832
|
-
ok: false,
|
|
833
|
-
summary: `HTTP ${res.status} (${latency}ms)`,
|
|
834
|
-
detail: `URL: ${url}\nStatus: ${res.status}\nLatency: ${latency}ms\nResponse: ${body}`,
|
|
835
|
-
};
|
|
836
|
-
} catch (err) {
|
|
837
|
-
const latency = Date.now() - start;
|
|
838
|
-
return {
|
|
839
|
-
ok: false,
|
|
840
|
-
summary: `unreachable (${err.code || err.cause?.code || err.message})`,
|
|
841
|
-
detail: `URL: ${url}\nError: ${err.message}\nLatency: ${latency}ms`,
|
|
842
|
-
};
|
|
843
883
|
}
|
|
884
|
+
return {
|
|
885
|
+
ok: false,
|
|
886
|
+
summary: `unreachable (${codes[codes.length - 1]}, ${ATTEMPTS} attempts)`,
|
|
887
|
+
detail: `URL: ${url}\nUnreachable after ${ATTEMPTS} attempts:\n${attemptLog.join('\n')}`,
|
|
888
|
+
};
|
|
844
889
|
}
|
|
845
890
|
|
|
846
891
|
async function checkToken(serverUrl, authToken) {
|
|
@@ -1097,7 +1142,50 @@ function buildReport(results, timestamp, warnings = [], allTools = TOOL_CONFIGS)
|
|
|
1097
1142
|
// Report mode: POST structured status to server
|
|
1098
1143
|
// ---------------------------------------------------------------------------
|
|
1099
1144
|
|
|
1100
|
-
|
|
1145
|
+
// AI Lens config (~/.ai-lens/config.json) with the auth token masked, or null if
|
|
1146
|
+
// absent/unreadable. Mirrors the masked dump buildReport writes to the local file.
|
|
1147
|
+
function maskedAiLensConfig() {
|
|
1148
|
+
try {
|
|
1149
|
+
const raw = readFileSync(join(homedir(), '.ai-lens', 'config.json'), 'utf-8');
|
|
1150
|
+
const config = JSON.parse(raw);
|
|
1151
|
+
if (config.authToken) config.authToken = maskToken(config.authToken);
|
|
1152
|
+
return config;
|
|
1153
|
+
} catch {
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Hook-config file contents for every tool (Cursor/Claude Code/Codex), both
|
|
1159
|
+
// global and project scope, so a single remote report is enough to diagnose hook
|
|
1160
|
+
// wiring (actual command form, scope, $CLAUDE_PROJECT_DIR vs %VAR%, missing file)
|
|
1161
|
+
// without asking the developer for their local ~/ai-lens-status.txt.
|
|
1162
|
+
function collectHookConfigs(allTools) {
|
|
1163
|
+
const entries = [];
|
|
1164
|
+
const readHooks = (tool, scope, path) => {
|
|
1165
|
+
let hooks = null, error = null;
|
|
1166
|
+
try {
|
|
1167
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
1168
|
+
hooks = parsed.hooks ?? null;
|
|
1169
|
+
if (hooks === null) error = 'no hooks section';
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
error = err.code === 'ENOENT' ? 'not found' : err.message;
|
|
1172
|
+
}
|
|
1173
|
+
entries.push({ tool: tool.name, scope, path, hooks, error });
|
|
1174
|
+
};
|
|
1175
|
+
for (const tool of allTools) {
|
|
1176
|
+
const scope = TOOL_CONFIGS.includes(tool) ? 'global' : 'project';
|
|
1177
|
+
readHooks(tool, scope, tool.configPath);
|
|
1178
|
+
// Claude Code keeps a separate settings.local.json (personal/project override)
|
|
1179
|
+
// that can carry or disable hooks — capture it when present.
|
|
1180
|
+
if (tool.name.startsWith('Claude Code') && tool.dirPath) {
|
|
1181
|
+
const localPath = join(tool.dirPath, 'settings.local.json');
|
|
1182
|
+
if (existsSync(localPath)) readHooks(tool, `${scope}-local`, localPath);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return entries;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken, allTools = TOOL_CONFIGS) {
|
|
1101
1189
|
if (!serverUrl || !authToken) return;
|
|
1102
1190
|
|
|
1103
1191
|
const payload = {
|
|
@@ -1108,6 +1196,12 @@ async function sendStatusReport(results, warnings, clientVersion, clientCommit,
|
|
|
1108
1196
|
os: `${process.platform} ${osRelease()} ${osArch()}`,
|
|
1109
1197
|
checks: results.map(({ label, ok, summary, detail }) => ({ label, ok, summary, detail })),
|
|
1110
1198
|
warnings: warnings.map(({ msg, action }) => ({ msg, action })),
|
|
1199
|
+
// Config + hook dumps (token masked) — the local-file-only sections, now also
|
|
1200
|
+
// sent so remote triage doesn't need the developer's ~/ai-lens-status.txt.
|
|
1201
|
+
configs: {
|
|
1202
|
+
ai_lens: maskedAiLensConfig(),
|
|
1203
|
+
hooks: collectHookConfigs(allTools),
|
|
1204
|
+
},
|
|
1111
1205
|
};
|
|
1112
1206
|
|
|
1113
1207
|
try {
|
|
@@ -1332,16 +1426,18 @@ export default async function status({ report = false } = {}) {
|
|
|
1332
1426
|
}
|
|
1333
1427
|
}
|
|
1334
1428
|
|
|
1429
|
+
// Global TOOL_CONFIGS (always listed, even if not installed) + project tools.
|
|
1430
|
+
// Used by both report modes: the server JSON config dump and the local text file.
|
|
1431
|
+
const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
|
|
1432
|
+
|
|
1335
1433
|
if (report) {
|
|
1336
1434
|
// --report mode: same on-screen output as normal status, but POST the
|
|
1337
1435
|
// structured JSON to the server instead of writing the local text file.
|
|
1338
|
-
await sendStatusReport(results, warnings, version, commit, serverUrl, authToken);
|
|
1436
|
+
await sendStatusReport(results, warnings, version, commit, serverUrl, authToken, allToolsForReport);
|
|
1339
1437
|
blank();
|
|
1340
1438
|
} else {
|
|
1341
1439
|
// Normal mode: write text report file
|
|
1342
1440
|
const timestamp = new Date().toISOString();
|
|
1343
|
-
// Merge global TOOL_CONFIGS (always listed, even if not installed) with project tools
|
|
1344
|
-
const allToolsForReport = [...TOOL_CONFIGS, ...toolsWithProject.filter(t => !TOOL_CONFIGS.includes(t))];
|
|
1345
1441
|
const reportText = buildReport(results, timestamp, warnings, allToolsForReport);
|
|
1346
1442
|
try {
|
|
1347
1443
|
writeFileSync(REPORT_PATH, reportText);
|
package/client/capture.js
CHANGED
|
@@ -93,7 +93,7 @@ export function resolveIdentity(gitIdentity, event, hasAuthToken) {
|
|
|
93
93
|
// Truncation (reused from ai-session-lens prompts.js approach)
|
|
94
94
|
// =============================================================================
|
|
95
95
|
|
|
96
|
-
const TRUNCATION_LIMITS = {
|
|
96
|
+
export const TRUNCATION_LIMITS = {
|
|
97
97
|
toolInput: { command: 500, old_string: 200, new_string: 200, default: 200 },
|
|
98
98
|
toolResult: { Read: 200, Bash: 300, Grep: 200, Edit: 100, Write: 100, Glob: 100, default: 200 },
|
|
99
99
|
userPrompt: 1000,
|
|
@@ -101,7 +101,7 @@ const TRUNCATION_LIMITS = {
|
|
|
101
101
|
agentThought: 500,
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
function truncate(text, maxLen) {
|
|
104
|
+
export function truncate(text, maxLen) {
|
|
105
105
|
if (typeof text !== 'string' || text.length <= maxLen) return text;
|
|
106
106
|
if (maxLen <= 0) return `[...truncated, ${text.length} chars total]`;
|
|
107
107
|
// Avoid splitting UTF-16 surrogate pairs at the boundary.
|
|
@@ -114,7 +114,7 @@ function truncate(text, maxLen) {
|
|
|
114
114
|
return text.slice(0, end) + ` [...truncated, ${text.length} chars total]`;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function truncateToolInput(input, toolName, depth = 0) {
|
|
117
|
+
export function truncateToolInput(input, toolName, depth = 0) {
|
|
118
118
|
if (!input || typeof input !== 'object') return input;
|
|
119
119
|
// Depth limit prevents stack overflow on pathological input while still
|
|
120
120
|
// truncating strings at any realistic nesting depth. A low limit (e.g. 5)
|
|
@@ -140,7 +140,7 @@ function truncateToolInput(input, toolName, depth = 0) {
|
|
|
140
140
|
return result;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function truncateToolResult(result, toolName) {
|
|
143
|
+
export function truncateToolResult(result, toolName) {
|
|
144
144
|
if (typeof result === 'string') {
|
|
145
145
|
const limit = TRUNCATION_LIMITS.toolResult[toolName] || TRUNCATION_LIMITS.toolResult.default;
|
|
146
146
|
return truncate(result, limit);
|
|
@@ -599,7 +599,7 @@ function findGitRoot(filePath) {
|
|
|
599
599
|
*
|
|
600
600
|
* Returns `dir` unchanged when no `.git` is found anywhere up the tree.
|
|
601
601
|
*/
|
|
602
|
-
function canonicalizeProjectPath(dir) {
|
|
602
|
+
export function canonicalizeProjectPath(dir) {
|
|
603
603
|
if (!dir || typeof dir !== 'string') return dir;
|
|
604
604
|
return findGitRootFromDir(dir) || dir;
|
|
605
605
|
}
|