ai-lens 0.8.73 → 0.8.80

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 CHANGED
@@ -1 +1 @@
1
- 45d094e
1
+ a6ad00e
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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.80 — 2026-06-04
6
+ - 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.
7
+
8
+ ## 0.8.79 — 2026-06-04
9
+ - 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.
10
+
11
+ ## 0.8.78 — 2026-06-04
12
+ - 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.
13
+
14
+ ## 0.8.77 — 2026-06-04
15
+ - 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.
16
+
17
+ ## 0.8.76 — 2026-06-04
18
+ - 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).
19
+
20
+ ## 0.8.75 — 2026-06-04
21
+ - 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).
22
+
23
+ ## 0.8.74 — 2026-06-04
24
+ - 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.
25
+ - 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.
26
+
5
27
  ## 0.8.73 — 2026-05-29
6
28
  - fix: spooling an event now retries the atomic `rename` in `~/.ai-lens/pending/` a few times with a short backoff before giving up. On Windows, antivirus/Defender or the search indexer transiently locks the temp file and the rename failed with EPERM, silently dropping that event (observed in prod via the 0.8.70 error-code instrumentation). POSIX is unaffected (its rename is atomic and never hits this).
7
29
 
package/README.md CHANGED
@@ -308,6 +308,8 @@ npm run dev:dashboard # Dashboard dev server
308
308
 
309
309
  Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
310
310
 
311
+ Architecture decisions are recorded under [`docs/adr/`](docs/adr/) — e.g. [ADR 0001 — Canonical metric predicates](docs/adr/0001-canonical-metric-predicates.md).
312
+
311
313
  ## Deployment
312
314
 
313
315
  GitLab CI (`.gitlab-ci.yml`) on push to `main`:
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
  }
package/cli/hooks.js CHANGED
@@ -322,6 +322,8 @@ function resolveDefaultCtx() {
322
322
  * @param {boolean} [opts.useTilde] — write ~/.ai-lens/client/... (portable path for project-hooks)
323
323
  * @param {boolean} [opts.rawPath] — don't quote the target (Claude Code config wants raw)
324
324
  * @param {string} [opts.customPath] — absolute repo-mode path to capture.js (--use-repo-path)
325
+ * @param {string} [opts.projectDirRelPath] — capture.js path relative to the workspace root,
326
+ * emitted as a Claude-Code-only `node "$CLAUDE_PROJECT_DIR/<rel>"` command (committed project hooks)
325
327
  * @param {object} [opts.ctx] — { nodeResolution, platform, clientDir }; lazy-resolved if omitted
326
328
  */
327
329
  export function captureCommand(opts = {}) {
@@ -329,7 +331,7 @@ export function captureCommand(opts = {}) {
329
331
  if (typeof opts === 'boolean') {
330
332
  opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
331
333
  }
332
- const { useTilde = false, rawPath = false, customPath = null, shell = null } = opts;
334
+ const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null } = opts;
333
335
  const ctx = opts.ctx ?? resolveDefaultCtx();
334
336
  // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
335
337
  // defaults — keeps callers from having to repeat platform/clientDir everywhere.
@@ -341,6 +343,20 @@ export function captureCommand(opts = {}) {
341
343
  // Windows — the two need different escaping for paths with spaces.
342
344
  const isPS = shell === 'powershell';
343
345
 
346
+ // Claude Code committed project hooks: resolve capture.js via Claude Code's
347
+ // $CLAUDE_PROJECT_DIR (the workspace root, injected into the hook environment).
348
+ // Claude Code runs the command through a shell, so the var expands regardless of
349
+ // the agent's cwd — a path relative to cwd breaks the moment the agent cd's into a
350
+ // subdir/subrepo (MODULE_NOT_FOUND). Because this string is committed to the repo
351
+ // it must stay machine-agnostic: bare `node` + the env var, never an absolute node
352
+ // binary or /home/<user> path. Windows Claude Code shells via cmd.exe (%VAR%);
353
+ // POSIX shells use $VAR.
354
+ if (projectDirRelPath != null) {
355
+ const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
356
+ const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
357
+ return `node "${dir}/${rel}"`;
358
+ }
359
+
344
360
  // --use-repo-path: legacy <node> <capture.js> form.
345
361
  if (customPath != null) {
346
362
  if (!nodeResolution || !nodeResolution.path) {
@@ -618,6 +634,26 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
618
634
  return defs;
619
635
  }
620
636
 
637
+ /**
638
+ * Claude Code hook defs for committed PROJECT hooks (.claude/settings.json checked
639
+ * into the repo). Resolves capture.js via Claude Code's $CLAUDE_PROJECT_DIR so the
640
+ * hook fires from any cwd and the path stays machine-agnostic (no absolute node/path).
641
+ * This is the cwd-robust replacement for the `--use-repo-path --project-hooks` form,
642
+ * whose project-root-relative path broke when an agent cd'd into a subdir.
643
+ * @param {string} relPath - capture.js path relative to the workspace root
644
+ * (e.g. internal/analytics/ai-lens/client/capture.js). Must contain an `ai-lens/`
645
+ * segment so isAiLensCommand recognises it.
646
+ * @param {object} [ctx]
647
+ */
648
+ export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
649
+ const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
650
+ const defs = {};
651
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
652
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
653
+ }
654
+ return defs;
655
+ }
656
+
621
657
  /**
622
658
  * Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
623
659
  * @param {string} capturePath - Absolute path to client/capture.js.
@@ -879,19 +915,39 @@ export function isGuiSafeHookCommand(cmd) {
879
915
  return false;
880
916
  }
881
917
 
918
+ // A committed Claude Code PROJECT hook that resolves capture.js through Claude Code's
919
+ // $CLAUDE_PROJECT_DIR ($VAR on POSIX, %VAR% on Windows). This form intentionally uses
920
+ // bare `node` (it's checked into the repo and must work on every teammate's machine,
921
+ // so it can't bake an absolute node path), and Claude Code guarantees the var points
922
+ // at the workspace root regardless of cwd. The var is Claude-Code-specific and never
923
+ // emitted for Cursor/Codex, so matching it here can't mis-accept another tool's hook.
924
+ export function isClaudeProjectDirCommand(cmd) {
925
+ if (!isAiLensCommand(cmd).isAiLens) return false;
926
+ const n = (cmd || '').replace(/\\/g, '/');
927
+ return /\$CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/.test(n)
928
+ && /ai-lens\/client\/capture\.js/.test(n);
929
+ }
930
+
931
+ // A hook command init/status should leave alone: either GUI-safe (launcher /
932
+ // absolute-node capture.js) OR the committed Claude Code $CLAUDE_PROJECT_DIR form.
933
+ function isAcceptableHookCommand(cmd) {
934
+ return isGuiSafeHookCommand(cmd) || isClaudeProjectDirCommand(cmd);
935
+ }
936
+
882
937
  function isCurrentAiLensHook(entry, expected) {
883
- // "Current" = a GUI-safe install (launcher OR absolute-node capture.js). We do
884
- // NOT require an exact match against the expected command form both valid
885
- // install methods capture reliably, so neither should be reported outdated.
886
- // Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) are outdated.
938
+ // "Current" = a GUI-safe install (launcher OR absolute-node capture.js) OR a
939
+ // committed Claude Code $CLAUDE_PROJECT_DIR project hook. We do NOT require an exact
940
+ // match against the expected command form every valid install method captures
941
+ // reliably, so none should be reported outdated. Only PATH-dependent forms
942
+ // (bare `node`, `/usr/bin/env node`) WITHOUT $CLAUDE_PROJECT_DIR are outdated.
887
943
  // Flat format (Cursor): single command per entry.
888
944
  if (entry?.command != null) {
889
- return isGuiSafeHookCommand(entry.command);
945
+ return isAcceptableHookCommand(entry.command);
890
946
  }
891
947
  // Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
892
948
  if (Array.isArray(entry?.hooks)) {
893
949
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
894
- return entry.hooks.some(h => isGuiSafeHookCommand(h?.command || ''));
950
+ return entry.hooks.some(h => isAcceptableHookCommand(h?.command || ''));
895
951
  }
896
952
  return false;
897
953
  }
@@ -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/init.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  CAPTURE_PATH, REPO_CAPTURE_PATH, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig,
17
17
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan, enableCodexHookTrust,
18
18
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
19
- getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
19
+ getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
20
20
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
21
21
  checkHooksDisabled, enableHooks,
22
22
  findStableNodePath, isVersionPinnedNodePath, writeLauncher,
@@ -177,7 +177,14 @@ function getTrackedRoots(projects, fallbackRoot) {
177
177
  function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
178
178
  const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`, ctx);
179
179
  if (capturePathInHooks) {
180
- tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
180
+ // A relative path carrying the ai-lens/ segment is resolved via Claude Code's
181
+ // $CLAUDE_PROJECT_DIR (the nested project's own root) so the hook fires from any
182
+ // cwd. Absolute/tilde paths keep the legacy <node> <capture.js> form.
183
+ const rel = capturePathInHooks.replace(/\\/g, '/');
184
+ const isRelative = !rel.startsWith('/') && !rel.startsWith('~') && !/^[a-zA-Z]:\//.test(rel);
185
+ tool.hookDefs = (isRelative && /(?:^|\/)ai-lens\//.test(rel))
186
+ ? getClaudeCodeHookDefsWithProjectDir(rel, ctx)
187
+ : getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
181
188
  }
182
189
  return tool;
183
190
  }
@@ -458,6 +465,10 @@ export default async function init() {
458
465
  const repoPathAbs = resolve(REPO_CAPTURE_PATH);
459
466
  const home = homedir();
460
467
  let pathInHooks;
468
+ // When set, Claude Code project hooks resolve capture.js via $CLAUDE_PROJECT_DIR
469
+ // (cwd-robust + machine-agnostic) instead of a cwd-relative path that breaks the
470
+ // moment an agent cd's into a subdir. Holds the workspace-root-relative path.
471
+ let claudeProjectDirRel = null;
461
472
  if (flags.projectHooks) {
462
473
  const projectRoot = resolve(process.cwd());
463
474
  pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
@@ -472,7 +483,10 @@ export default async function init() {
472
483
  : repoPathAbs.replace(/\\/g, '/');
473
484
  info(' Project root is inside ai-lens itself — using portable path with ai-lens/ segment for hook recognition.');
474
485
  } else {
475
- info(' Project hooks will use relative path to capture.js (from project root).');
486
+ // Relative path carries the ai-lens/ segment Claude Code can resolve it via
487
+ // $CLAUDE_PROJECT_DIR. The plain relative path stays the form for Cursor/Codex.
488
+ claudeProjectDirRel = pathInHooks;
489
+ info(' Claude Code project hooks will resolve capture.js via $CLAUDE_PROJECT_DIR (cwd-independent).');
476
490
  }
477
491
  } else {
478
492
  pathInHooks = repoPathAbs.startsWith(home)
@@ -488,7 +502,11 @@ export default async function init() {
488
502
  ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
489
503
  : repoPathAbs.replace(/\\/g, '/');
490
504
  for (const tool of tools) {
491
- if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
505
+ if (tool.name.startsWith('Claude Code')) {
506
+ tool.hookDefs = claudeProjectDirRel
507
+ ? getClaudeCodeHookDefsWithProjectDir(claudeProjectDirRel, ctx)
508
+ : getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
509
+ }
492
510
  else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
493
511
  else if (tool.name.startsWith('Codex')) tool.hookDefs = getCodexHookDefsWithPath(codexPathInHooks, ctx);
494
512
  }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.73",
3
+ "version": "0.8.80",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {