ai-lens 0.8.84 → 0.8.85
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 +4 -0
- package/bin/ai-lens.js +22 -0
- package/cli/data-api.js +80 -0
- package/cli/data-format.js +78 -0
- package/cli/delete-sessions.js +207 -0
- package/cli/find-session.js +70 -0
- package/cli/import/claude-code.js +98 -19
- package/cli/import.js +3 -3
- package/cli/list-sessions.js +53 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5905663
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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.85 — 2026-06-09
|
|
6
|
+
- feat: manage your own data from the CLI. `ai-lens list-sessions` lists your sessions, `ai-lens find-session <query>` finds one by id / project / source, and `ai-lens delete-sessions` removes your own sessions — by id (the short id shown by `list-sessions` works) or by `--from`/`--to` date range. Delete is a dry-run by default and only acts with `--yes`; it can only ever touch your own data, and a mistyped or ambiguous id is reported rather than silently ignored.
|
|
7
|
+
- feat: `ai-lens import claude-code --from <date> --to <date>` imports a precise date window (events outside it are left out). Re-running an import over a window you've already imported is safe — it adds zero duplicates (every event has a stable id the server de-duplicates on), so you can re-seed a lost day without fear of doubling your history.
|
|
8
|
+
|
|
5
9
|
## 0.8.84 — 2026-06-08
|
|
6
10
|
- fix: `ai-lens init` no longer wipes your saved auth token when a re-authentication is started but not finished — a closed browser, a timeout, or a transient server hiccup that briefly mis-flags a valid token. Previously this left `authToken: null` in your config and silently stopped all event capture until someone noticed; now the existing token is kept until a new one is actually obtained. If a server that requires auth does end up without a token, init now prints a loud "capture is OFF" error instead of a quiet note.
|
|
7
11
|
|
package/bin/ai-lens.js
CHANGED
|
@@ -23,6 +23,21 @@ switch (command) {
|
|
|
23
23
|
await importCmd();
|
|
24
24
|
break;
|
|
25
25
|
}
|
|
26
|
+
case 'list-sessions': {
|
|
27
|
+
const { default: listSessions } = await import('../cli/list-sessions.js');
|
|
28
|
+
await listSessions();
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case 'find-session': {
|
|
32
|
+
const { default: findSession } = await import('../cli/find-session.js');
|
|
33
|
+
await findSession();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case 'delete-sessions': {
|
|
37
|
+
const { default: deleteSessions } = await import('../cli/delete-sessions.js');
|
|
38
|
+
await deleteSessions();
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
26
41
|
case 'version':
|
|
27
42
|
case '--version':
|
|
28
43
|
case '-v': {
|
|
@@ -54,8 +69,15 @@ switch (command) {
|
|
|
54
69
|
console.log(' import <source> Import local history (source: claude-code)');
|
|
55
70
|
console.log(' --days N Window in days (default 30; 0 = all history)');
|
|
56
71
|
console.log(' --since DATE Import from YYYY-MM-DD instead of --days');
|
|
72
|
+
console.log(' --from D --to D Import a bounded YYYY-MM-DD..YYYY-MM-DD window (idempotent re-import)');
|
|
57
73
|
console.log(' --dry-run Scan + count, write nothing');
|
|
58
74
|
console.log(' --projects LIST Only these project paths (comma-separated)');
|
|
75
|
+
console.log(' list-sessions List your own sessions');
|
|
76
|
+
console.log(' --days N --source S --limit N --json');
|
|
77
|
+
console.log(' find-session <q> Find your sessions by id / project / source substring');
|
|
78
|
+
console.log(' --days N --source S --limit N --json');
|
|
79
|
+
console.log(' delete-sessions Delete your own sessions (dry-run unless --yes)');
|
|
80
|
+
console.log(' <id…> | --from D --to D [--source S] [--days N] [--yes] [--json]');
|
|
59
81
|
console.log(' version Show package version and commit hash');
|
|
60
82
|
process.exit(command ? 1 : 0);
|
|
61
83
|
}
|
package/cli/data-api.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP helpers for the self-service data commands (list-sessions,
|
|
3
|
+
* find-session, delete-sessions). Thin wrappers over the SAME endpoints the
|
|
4
|
+
* dashboard uses — `GET /api/sessions` and `DELETE /api/dashboard/chains/:id` —
|
|
5
|
+
* so there is no bespoke server surface to keep in sync.
|
|
6
|
+
*
|
|
7
|
+
* Auth: these endpoints sit behind `requireAuth`, which accepts an
|
|
8
|
+
* `ailens_dev_` personal token (or an Auth0 JWT). The git-email header fallback
|
|
9
|
+
* used by the ingest path does NOT authenticate here, so a token is required in
|
|
10
|
+
* any Auth0-configured deployment; a token-less self-host (no AUTH0_DOMAIN) is
|
|
11
|
+
* resolved to its singleton developer server-side. A 401 ⇒ tell the user to init.
|
|
12
|
+
*/
|
|
13
|
+
import { getServerUrl, getAuthToken } from '../client/config.js';
|
|
14
|
+
|
|
15
|
+
const TIMEOUT_MS = 15_000;
|
|
16
|
+
|
|
17
|
+
export class ApiError extends Error {
|
|
18
|
+
constructor(message, status) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ApiError';
|
|
21
|
+
this.status = status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Auth headers for a data request — token when we have one, else nothing. */
|
|
26
|
+
export function authHeaders() {
|
|
27
|
+
const token = getAuthToken();
|
|
28
|
+
return token ? { 'X-Auth-Token': token } : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function apiFetch(path, opts = {}) {
|
|
32
|
+
const url = new URL(path, getServerUrl());
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch(url, {
|
|
36
|
+
...opts,
|
|
37
|
+
headers: { ...authHeaders(), ...(opts.headers || {}) },
|
|
38
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new ApiError(`Cannot reach server at ${getServerUrl()} (${err.code || err.message}).`, 0);
|
|
42
|
+
}
|
|
43
|
+
if (res.status === 401) {
|
|
44
|
+
throw new ApiError('Not authenticated. Run `npx ai-lens init` to sign in (or set AI_LENS_AUTH_TOKEN).', 401);
|
|
45
|
+
}
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List the caller's own sessions. Without the `team_members` scope the server
|
|
51
|
+
* forces `developer_id = self`, so this is always self-scoped for a normal token.
|
|
52
|
+
* Returns the raw session objects (session_id, source, project_path, started_at,
|
|
53
|
+
* last_event_at, duration_minutes, event_count, used_plan_mode, …).
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchSessions({ days, source, limit } = {}) {
|
|
56
|
+
const params = new URLSearchParams();
|
|
57
|
+
if (Number.isFinite(days) && days > 0) params.set('days', String(days));
|
|
58
|
+
if (source) params.set('source', source);
|
|
59
|
+
if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit));
|
|
60
|
+
const qs = params.toString();
|
|
61
|
+
const res = await apiFetch(`/api/sessions${qs ? `?${qs}` : ''}`);
|
|
62
|
+
if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status} listing sessions.`, res.status);
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
return Array.isArray(data) ? data : (data.sessions || []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Delete one session by id. The server resolves the id to the activity CHAIN it
|
|
69
|
+
* belongs to and deletes the whole chain (events + analyses + chunks + costs),
|
|
70
|
+
* owner-scoped — only the caller's own data. 403/404 are returned, not thrown,
|
|
71
|
+
* so a batch can report-and-continue.
|
|
72
|
+
*/
|
|
73
|
+
export async function deleteSessionById(id) {
|
|
74
|
+
const res = await apiFetch(`/api/dashboard/chains/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
75
|
+
if (res.status === 403) return { ok: false, status: 403, reason: 'not yours' };
|
|
76
|
+
if (res.status === 404) return { ok: false, status: 404, reason: 'not found / already deleted' };
|
|
77
|
+
if (!res.ok) return { ok: false, status: res.status, reason: `HTTP ${res.status}` };
|
|
78
|
+
const body = await res.json().catch(() => ({}));
|
|
79
|
+
return { ok: true, status: res.status, body };
|
|
80
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared flag parsing + session formatting for the self-service data commands.
|
|
3
|
+
*/
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Shared ISO-date validator for `--from`/`--to` flags (lenient Date.parse). */
|
|
7
|
+
export const isValidDate = (s) => typeof s === 'string' && !Number.isNaN(Date.parse(s));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic argv parser shared by list/find/delete. Mirrors cli/import.js: a value
|
|
11
|
+
* flag whose next token is missing or is itself another flag does NOT consume it
|
|
12
|
+
* (records a missing-value error instead) — so `--source --yes` can't silently
|
|
13
|
+
* swallow `--yes`. An int flag whose value isn't a number is an error too, never
|
|
14
|
+
* a silently-dropped NaN. Non-flag tokens collect into `positional`.
|
|
15
|
+
*/
|
|
16
|
+
export function parseArgs(argv, { bools = {}, values = {}, ints = new Set() } = {}) {
|
|
17
|
+
const flags = {};
|
|
18
|
+
const positional = [];
|
|
19
|
+
const errors = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const arg = argv[i];
|
|
22
|
+
if (bools[arg]) { flags[bools[arg]] = true; continue; }
|
|
23
|
+
if (values[arg]) {
|
|
24
|
+
const next = argv[i + 1];
|
|
25
|
+
if (next == null || next.startsWith('--')) { errors.push(`Missing value for ${arg}.`); continue; }
|
|
26
|
+
i++;
|
|
27
|
+
const key = values[arg];
|
|
28
|
+
if (ints.has(key)) {
|
|
29
|
+
const n = parseInt(next, 10);
|
|
30
|
+
if (Number.isNaN(n)) { errors.push(`Invalid number for ${arg}: "${next}".`); continue; }
|
|
31
|
+
flags[key] = n;
|
|
32
|
+
} else {
|
|
33
|
+
flags[key] = next;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (arg.startsWith('--')) { errors.push(`Unknown flag "${arg}".`); continue; }
|
|
38
|
+
positional.push(arg);
|
|
39
|
+
}
|
|
40
|
+
return { flags, positional, errors };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function shortId(id) {
|
|
44
|
+
return String(id || '').slice(0, 8);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ISO/Date/number → `YYYY-MM-DD HH:MM`. Tolerates the JSON-serialized shapes. */
|
|
48
|
+
export function fmtDate(value) {
|
|
49
|
+
if (!value) return '—';
|
|
50
|
+
const s = typeof value === 'string' ? value : new Date(value).toISOString();
|
|
51
|
+
return Number.isNaN(Date.parse(s)) ? '—' : s.slice(0, 16).replace('T', ' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtProject(p) {
|
|
55
|
+
if (!p) return '—';
|
|
56
|
+
return basename(p) || p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
60
|
+
const padL = (s, n) => String(s).padStart(n);
|
|
61
|
+
|
|
62
|
+
// Single source of truth for column widths so the header and the rows can't drift.
|
|
63
|
+
const COL = { id: 8, source: 11, project: 24, started: 16, dur: 6, events: 6 };
|
|
64
|
+
|
|
65
|
+
export const SESSION_HEADER =
|
|
66
|
+
`${pad('id', COL.id)} ${pad('source', COL.source)} ${pad('project', COL.project)} ${pad('started', COL.started)} ${padL('dur', COL.dur)} ${padL('events', COL.events)}`;
|
|
67
|
+
|
|
68
|
+
/** One aligned line per session for the list/find tables. */
|
|
69
|
+
export function formatSessionLine(s) {
|
|
70
|
+
const id = pad(shortId(s.session_id), COL.id);
|
|
71
|
+
const src = pad(String(s.source || '?').slice(0, COL.source), COL.source);
|
|
72
|
+
const proj = pad(fmtProject(s.project_path).slice(0, COL.project), COL.project);
|
|
73
|
+
const started = pad(fmtDate(s.started_at), COL.started);
|
|
74
|
+
const dur = padL(`${Math.round(Number(s.duration_minutes) || 0)}m`, COL.dur);
|
|
75
|
+
const ev = padL(`${Number(s.event_count) || 0}`, COL.events);
|
|
76
|
+
const plan = s.used_plan_mode ? ' plan' : '';
|
|
77
|
+
return `${id} ${src} ${proj} ${started} ${dur} ${ev}${plan}`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai-lens delete-sessions` — delete your OWN sessions, by id or by date range.
|
|
3
|
+
*
|
|
4
|
+
* ai-lens delete-sessions <id> <id> … # explicit ids (full or short)
|
|
5
|
+
* ai-lens delete-sessions --from D --to D [--source S] # a window
|
|
6
|
+
*
|
|
7
|
+
* DRY-RUN BY DEFAULT: prints what would be deleted and stops. Pass --yes to
|
|
8
|
+
* actually delete. Both modes resolve the matching sessions client-side (same
|
|
9
|
+
* `GET /api/sessions` the dashboard uses), then delete EACH via the existing
|
|
10
|
+
* owner-scoped `DELETE /api/dashboard/chains/:id` — no new server endpoint.
|
|
11
|
+
*
|
|
12
|
+
* Ids are resolved against your session list, so a SHORT id (as printed by
|
|
13
|
+
* `ai-lens list-sessions`) works — exact match or unique prefix. A prefix that
|
|
14
|
+
* matches several sessions is rejected; one that matches none is reported, not
|
|
15
|
+
* silently treated as "already deleted".
|
|
16
|
+
*
|
|
17
|
+
* Blast radius: the server resolves each id to the activity CHAIN it belongs to
|
|
18
|
+
* and deletes the whole chain (events + analyses + chunks + costs). Two ids in
|
|
19
|
+
* the same chain ⇒ the second delete is a harmless "already deleted".
|
|
20
|
+
*/
|
|
21
|
+
import { initLogger, heading, info, success, warn, error, detail, blank } from './logger.js';
|
|
22
|
+
import { getVersionInfo } from './hooks.js';
|
|
23
|
+
import { fetchSessions, deleteSessionById, ApiError } from './data-api.js';
|
|
24
|
+
import { parseArgs, shortId, formatSessionLine, SESSION_HEADER, isValidDate } from './data-format.js';
|
|
25
|
+
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
|
+
// Wide window for resolving ids against the session list — covers practically all
|
|
28
|
+
// history so a short id from `list-sessions` resolves regardless of its age.
|
|
29
|
+
const RESOLVE_WINDOW_DAYS = 3650;
|
|
30
|
+
// Matches the server's MAX_LIST_LIMIT; hitting it means older sessions were dropped.
|
|
31
|
+
const RESOLVE_LIMIT = 5000;
|
|
32
|
+
// A non-matching token this long is taken as a full id outside the list window
|
|
33
|
+
// (still attempted); a shorter non-match is treated as a typo and reported.
|
|
34
|
+
const LITERAL_ID_MIN_LEN = 12;
|
|
35
|
+
|
|
36
|
+
export function parseDeleteArgs(argv) {
|
|
37
|
+
return parseArgs(argv, {
|
|
38
|
+
bools: { '--yes': 'yes', '--json': 'json' },
|
|
39
|
+
values: { '--from': 'from', '--to': 'to', '--source': 'source', '--days': 'days' },
|
|
40
|
+
ints: new Set(['days']),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Keep sessions whose START falls in the window. Lower bound is `--from` (00:00)
|
|
46
|
+
* or, when only `--days N` is given, a rolling `now - N days`. Upper bound is
|
|
47
|
+
* `--to` (inclusive of that whole day). Any bound may be absent.
|
|
48
|
+
*/
|
|
49
|
+
export function inRange(session, { from, to, days }, now = Date.now()) {
|
|
50
|
+
const t = Date.parse(session.started_at);
|
|
51
|
+
if (Number.isNaN(t)) return false;
|
|
52
|
+
const lowerMs = from
|
|
53
|
+
? Date.parse(from)
|
|
54
|
+
: (Number.isFinite(days) && days > 0 ? now - days * DAY_MS : null);
|
|
55
|
+
if (lowerMs != null && t < lowerMs) return false;
|
|
56
|
+
if (to && t >= Date.parse(to) + DAY_MS) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an id token against the visible sessions: exact match or unique prefix.
|
|
62
|
+
* Returns { kind: 'resolved', session } | { kind: 'ambiguous', candidates }
|
|
63
|
+
* | { kind: 'literal' } (long, no match — try as-is) | { kind: 'none' }.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveId(token, sessions) {
|
|
66
|
+
const exact = sessions.find((s) => s.session_id === token);
|
|
67
|
+
if (exact) return { kind: 'resolved', session: exact };
|
|
68
|
+
const pre = sessions.filter((s) => String(s.session_id).startsWith(token));
|
|
69
|
+
if (pre.length === 1) return { kind: 'resolved', session: pre[0] };
|
|
70
|
+
if (pre.length > 1) return { kind: 'ambiguous', candidates: pre };
|
|
71
|
+
if (token.length >= LITERAL_ID_MIN_LEN) return { kind: 'literal' };
|
|
72
|
+
return { kind: 'none' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default async function deleteSessions() {
|
|
76
|
+
const { flags, positional, errors } = parseDeleteArgs(process.argv.slice(3));
|
|
77
|
+
const { version, commit } = getVersionInfo();
|
|
78
|
+
initLogger(`v${version} (${commit})`);
|
|
79
|
+
|
|
80
|
+
const usage = 'Usage: ai-lens delete-sessions <id…> | --from YYYY-MM-DD --to YYYY-MM-DD [--source S] [--days N] (add --yes to confirm)';
|
|
81
|
+
const rangeMode = flags.from != null || flags.to != null || flags.days != null;
|
|
82
|
+
|
|
83
|
+
if (errors.length) {
|
|
84
|
+
errors.forEach((e) => error(e));
|
|
85
|
+
info(usage);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (flags.from && !isValidDate(flags.from)) { error(`Invalid --from "${flags.from}". Use YYYY-MM-DD.`); process.exitCode = 1; return; }
|
|
90
|
+
if (flags.to && !isValidDate(flags.to)) { error(`Invalid --to "${flags.to}". Use YYYY-MM-DD.`); process.exitCode = 1; return; }
|
|
91
|
+
if (flags.from && flags.to && Date.parse(flags.from) > Date.parse(flags.to)) {
|
|
92
|
+
error(`--from "${flags.from}" is after --to "${flags.to}".`); process.exitCode = 1; return;
|
|
93
|
+
}
|
|
94
|
+
if (!positional.length && !rangeMode) {
|
|
95
|
+
error('Nothing to delete: pass session id(s) or a --from/--to range.');
|
|
96
|
+
info(usage);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One session fetch serves both id-resolution and range selection.
|
|
102
|
+
let sessions;
|
|
103
|
+
try {
|
|
104
|
+
sessions = await fetchSessions({ days: RESOLVE_WINDOW_DAYS, source: flags.source, limit: RESOLVE_LIMIT });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
error(err instanceof ApiError ? err.message : `Failed to load sessions: ${err.message}`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// The list endpoint caps at RESOLVE_LIMIT rows (newest first). If we hit the cap,
|
|
111
|
+
// older sessions are invisible here — warn so a range that "matches nothing" or a
|
|
112
|
+
// short id that "doesn't resolve" isn't mistaken for "no such data".
|
|
113
|
+
if (sessions.length >= RESOLVE_LIMIT) {
|
|
114
|
+
warn(`Only the ${RESOLVE_LIMIT} most-recent sessions were loaded — older ones may not resolve. Use the full id, or a tighter --source.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targets = [];
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const addTarget = (id, session) => {
|
|
120
|
+
if (!id || seen.has(id)) return;
|
|
121
|
+
seen.add(id);
|
|
122
|
+
targets.push({ id, session });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Resolve explicit id tokens. Ambiguous prefixes abort the whole command
|
|
126
|
+
// (deleting the wrong chain is unrecoverable); unknown short tokens are skipped.
|
|
127
|
+
const ambiguous = [];
|
|
128
|
+
const notFound = [];
|
|
129
|
+
for (const token of positional) {
|
|
130
|
+
const r = resolveId(token, sessions);
|
|
131
|
+
if (r.kind === 'resolved') addTarget(r.session.session_id, r.session);
|
|
132
|
+
else if (r.kind === 'literal') addTarget(token, null);
|
|
133
|
+
else if (r.kind === 'ambiguous') ambiguous.push({ token, n: r.candidates.length });
|
|
134
|
+
else notFound.push(token);
|
|
135
|
+
}
|
|
136
|
+
if (ambiguous.length) {
|
|
137
|
+
for (const a of ambiguous) error(`"${a.token}" matches ${a.n} sessions — use a longer prefix or the full id.`);
|
|
138
|
+
info('Nothing deleted.');
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const t of notFound) warn(`No session matches "${t}" — skipping.`);
|
|
143
|
+
|
|
144
|
+
if (rangeMode) {
|
|
145
|
+
for (const s of sessions.filter((s) => inRange(s, flags))) addTarget(s.session_id, s);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (targets.length === 0) {
|
|
149
|
+
info('No sessions matched — nothing to delete.');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Preview.
|
|
154
|
+
heading(`${flags.yes ? 'Deleting' : 'Would delete'} ${targets.length} session(s)`);
|
|
155
|
+
warn('Each id removes the WHOLE activity chain it belongs to (all grouped sessions + their analyses).');
|
|
156
|
+
if (rangeMode) {
|
|
157
|
+
const lo = flags.from || (flags.days ? `last ${flags.days}d` : '…');
|
|
158
|
+
detail(`Range selects sessions that STARTED in ${lo}…${flags.to || 'now'} (a session that began earlier and ran into the window is not matched).`);
|
|
159
|
+
}
|
|
160
|
+
const withMeta = targets.filter((t) => t.session);
|
|
161
|
+
if (withMeta.length) {
|
|
162
|
+
info(SESSION_HEADER);
|
|
163
|
+
for (const t of withMeta) info(formatSessionLine(t.session));
|
|
164
|
+
}
|
|
165
|
+
const idOnly = targets.filter((t) => !t.session).map((t) => t.id);
|
|
166
|
+
if (idOnly.length) info(`Plus ${idOnly.length} full id(s) given directly: ${idOnly.map(shortId).join(', ')}`);
|
|
167
|
+
blank();
|
|
168
|
+
|
|
169
|
+
if (!flags.yes) {
|
|
170
|
+
info('Dry run — nothing deleted. Re-run with --yes to confirm.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Execute.
|
|
175
|
+
let deletedChains = 0, deletedEvents = 0, skipped = 0, failed = 0;
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const { id } of targets) {
|
|
178
|
+
let r;
|
|
179
|
+
try {
|
|
180
|
+
r = await deleteSessionById(id);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// 401 (auth) or network — fatal for the whole batch.
|
|
183
|
+
error(err instanceof ApiError ? err.message : `Delete failed: ${err.message}`);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (r.ok) {
|
|
188
|
+
deletedChains++;
|
|
189
|
+
deletedEvents += Number(r.body?.deleted_events) || 0;
|
|
190
|
+
} else if (r.status === 404) {
|
|
191
|
+
skipped++;
|
|
192
|
+
} else {
|
|
193
|
+
failed++;
|
|
194
|
+
warn(` ${shortId(id)}: ${r.reason}`);
|
|
195
|
+
}
|
|
196
|
+
results.push({ id, ...r });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (flags.json) {
|
|
200
|
+
console.log(JSON.stringify({ deletedChains, deletedEvents, skipped, failed, results }, null, 2));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
blank();
|
|
204
|
+
success(`Deleted ${deletedChains} chain(s), ${deletedEvents} event(s).`);
|
|
205
|
+
if (skipped) detail(`${skipped} already gone (same-chain or previously deleted).`);
|
|
206
|
+
if (failed) warn(`${failed} could not be deleted (not yours or server error).`);
|
|
207
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai-lens find-session <query>` — find your own sessions by a substring of the
|
|
3
|
+
* session id, project path, or source. Reuses the existing `GET /api/sessions`
|
|
4
|
+
* API (same data the dashboard lists), then filters client-side.
|
|
5
|
+
*
|
|
6
|
+
* The matcher (`matchSession`) is deliberately isolated so it can later be
|
|
7
|
+
* repointed at a richer/changed endpoint (e.g. content search) without touching
|
|
8
|
+
* the command shell.
|
|
9
|
+
*/
|
|
10
|
+
import { initLogger, heading, info, error, detail, blank } from './logger.js';
|
|
11
|
+
import { getVersionInfo } from './hooks.js';
|
|
12
|
+
import { fetchSessions, ApiError } from './data-api.js';
|
|
13
|
+
import { parseArgs, SESSION_HEADER, formatSessionLine } from './data-format.js';
|
|
14
|
+
|
|
15
|
+
/** True when `query` (case-insensitive) appears in the session's id/project/source. */
|
|
16
|
+
export function matchSession(session, query) {
|
|
17
|
+
if (!query) return true;
|
|
18
|
+
const q = query.toLowerCase();
|
|
19
|
+
return [session.session_id, session.project_path, session.source]
|
|
20
|
+
.some((field) => String(field || '').toLowerCase().includes(q));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFindArgs(argv) {
|
|
24
|
+
return parseArgs(argv, {
|
|
25
|
+
bools: { '--json': 'json' },
|
|
26
|
+
values: { '--days': 'days', '--source': 'source', '--limit': 'limit' },
|
|
27
|
+
ints: new Set(['days', 'limit']),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function findSession() {
|
|
32
|
+
const { flags, positional, errors } = parseFindArgs(process.argv.slice(3));
|
|
33
|
+
const { version, commit } = getVersionInfo();
|
|
34
|
+
initLogger(`v${version} (${commit})`);
|
|
35
|
+
|
|
36
|
+
const query = positional.join(' ').trim();
|
|
37
|
+
if (errors.length || !query) {
|
|
38
|
+
errors.forEach((e) => error(e));
|
|
39
|
+
if (!query) error('Missing search query.');
|
|
40
|
+
info('Usage: ai-lens find-session <query> [--days N] [--source S] [--limit N] [--json]');
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let sessions;
|
|
46
|
+
try {
|
|
47
|
+
sessions = await fetchSessions({ days: flags.days, source: flags.source, limit: flags.limit });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
error(err instanceof ApiError ? err.message : `Failed to search sessions: ${err.message}`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const matches = sessions.filter((s) => matchSession(s, query));
|
|
55
|
+
|
|
56
|
+
if (flags.json) {
|
|
57
|
+
console.log(JSON.stringify(matches, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
heading(`Sessions matching "${query}"`);
|
|
62
|
+
if (matches.length === 0) {
|
|
63
|
+
info(`No match in ${sessions.length} session(s). Widen with --days / --source.`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
info(SESSION_HEADER);
|
|
67
|
+
for (const s of matches) info(formatSessionLine(s));
|
|
68
|
+
blank();
|
|
69
|
+
detail(`${matches.length} of ${sessions.length} session(s) matched. Full id with --json.`);
|
|
70
|
+
}
|
|
@@ -5,7 +5,19 @@
|
|
|
5
5
|
* (cli/import/transcript-map.js), enriches with developer + git identity,
|
|
6
6
|
* redacts + spools via the live client pipeline (writeToSpool), and lets the
|
|
7
7
|
* existing sender ship them to POST /api/events. Historical timestamps are kept
|
|
8
|
-
* verbatim
|
|
8
|
+
* verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Re-import is idempotent: every event carries a DETERMINISTIC event_id
|
|
11
|
+
* (computeEventId below) and the server inserts with ON CONFLICT DO NOTHING
|
|
12
|
+
* against the `events.event_id` UNIQUE constraint, so re-running over an
|
|
13
|
+
* already-ingested window inserts zero duplicate rows. This is the robust
|
|
14
|
+
* guarantee — independent of timestamp proximity and of payload size. (The
|
|
15
|
+
* server's 5-second content-hash window is a separate, secondary layer; because
|
|
16
|
+
* import keeps timestamps verbatim it happens to catch re-imports too, but it
|
|
17
|
+
* does nothing when `raw` is tiny — only the event_id UNIQUE constraint does.) A
|
|
18
|
+
* per-file fingerprint ledger additionally short-circuits unchanged transcripts
|
|
19
|
+
* on the fast path; a bounded `--from/--to` re-import bypasses the ledger and
|
|
20
|
+
* leans entirely on the event_id dedup.
|
|
9
21
|
*/
|
|
10
22
|
import { createReadStream, existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync, realpathSync } from 'node:fs';
|
|
11
23
|
import { join, basename } from 'node:path';
|
|
@@ -46,26 +58,63 @@ const DRAIN_BATCH = 4000;
|
|
|
46
58
|
// terminal Stop/SubagentStop marker is safe to emit (matches the 5h chain-gap).
|
|
47
59
|
const TERMINAL_DORMANT_MS = 5 * 60 * 60 * 1000;
|
|
48
60
|
|
|
61
|
+
const isValidDate = (s) => typeof s === 'string' && !Number.isNaN(Date.parse(s));
|
|
62
|
+
|
|
49
63
|
/** Validate flags. Returns an error string, or null when ok. */
|
|
50
|
-
export function validateFlags({ days, since }) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
export function validateFlags({ days, since, from, to }) {
|
|
65
|
+
// --from/--to define an explicit [from, to] window (lower = from, upper = to).
|
|
66
|
+
if (from != null || to != null) {
|
|
67
|
+
if (since != null) return 'Use either --since or --from/--to, not both.';
|
|
68
|
+
if (from != null && !isValidDate(from)) return `Invalid --from "${from}". Use YYYY-MM-DD.`;
|
|
69
|
+
if (to != null && !isValidDate(to)) return `Invalid --to "${to}". Use YYYY-MM-DD.`;
|
|
70
|
+
if (from != null && to != null && Date.parse(from) > Date.parse(to)) {
|
|
71
|
+
return `--from "${from}" is after --to "${to}".`;
|
|
54
72
|
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (since != null) {
|
|
76
|
+
if (!isValidDate(since)) return `Invalid --since "${since}". Use YYYY-MM-DD.`;
|
|
55
77
|
} else if (!Number.isInteger(days) || days < 0) {
|
|
56
78
|
return `Invalid --days "${days}". Use a non-negative integer (0 = all history).`;
|
|
57
79
|
}
|
|
58
80
|
return null;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the lower-bound cutoff ISO string from flags. `--from` and `--since`
|
|
85
|
+
* are equivalent lower bounds; `--days 0` / no window ⇒ epoch (all history).
|
|
86
|
+
*/
|
|
87
|
+
export function resolveCutoff({ days, since, from }, now = new Date()) {
|
|
88
|
+
const lower = from || since;
|
|
89
|
+
if (lower) return new Date(Date.parse(lower)).toISOString();
|
|
64
90
|
if (days === 0) return new Date(0).toISOString();
|
|
65
91
|
const d = Number.isInteger(days) && days >= 0 ? days : 30;
|
|
66
92
|
return new Date(now.getTime() - d * DAY_MS).toISOString();
|
|
67
93
|
}
|
|
68
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the INCLUSIVE lower bound from `--from` (or null when open-ended).
|
|
97
|
+
* Unlike the file-level `cutoff` (which only decides whether to read a file),
|
|
98
|
+
* this clips PER EVENT so an explicit `--from/--to` window means exactly the
|
|
99
|
+
* events in [from 00:00, to+1day) — a file touched inside the window no longer
|
|
100
|
+
* drags in its older events. Only `--from`/`--to` engage this precise clipping;
|
|
101
|
+
* `--days`/`--since` keep their looser file-level semantics.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveLowerBound({ from }) {
|
|
104
|
+
if (!from || !isValidDate(from)) return null;
|
|
105
|
+
return new Date(Date.parse(from)).toISOString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the EXCLUSIVE upper bound from `--to` (or null when open-ended).
|
|
110
|
+
* `--to 2026-06-07` includes the whole of June 7 ⇒ exclusive bound is the next
|
|
111
|
+
* midnight (2026-06-08T00:00Z).
|
|
112
|
+
*/
|
|
113
|
+
export function resolveUpperBound({ to }) {
|
|
114
|
+
if (!to || !isValidDate(to)) return null;
|
|
115
|
+
return new Date(Date.parse(to) + DAY_MS).toISOString();
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
/** Expand a leading ~ and resolve to a canonical project (git) root. */
|
|
70
119
|
function normalizeProjectArg(p) {
|
|
71
120
|
let s = (p || '').trim();
|
|
@@ -214,7 +263,7 @@ async function drainSpool({ timeoutMs = 120_000 } = {}) {
|
|
|
214
263
|
|
|
215
264
|
export default async function importClaudeCode(flags) {
|
|
216
265
|
const {
|
|
217
|
-
days = 30, since = null, dryRun = false, projects = null,
|
|
266
|
+
days = 30, since = null, from = null, to = null, dryRun = false, projects = null,
|
|
218
267
|
noRedact = false, analysisMaxAgeDays: amAgeFlag = 30,
|
|
219
268
|
} = flags;
|
|
220
269
|
const analysisMaxAgeDays = Number.isInteger(amAgeFlag) && amAgeFlag >= 0 ? amAgeFlag : 30;
|
|
@@ -232,18 +281,36 @@ export default async function importClaudeCode(flags) {
|
|
|
232
281
|
}
|
|
233
282
|
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.');
|
|
234
283
|
|
|
235
|
-
const flagErr = validateFlags({ days, since });
|
|
284
|
+
const flagErr = validateFlags({ days, since, from, to });
|
|
236
285
|
if (flagErr) { error(flagErr); process.exitCode = 1; return; }
|
|
237
286
|
|
|
238
|
-
const
|
|
287
|
+
const lower = resolveLowerBound({ from }); // inclusive per-event lower, or null
|
|
288
|
+
const upper = resolveUpperBound({ to }); // exclusive per-event upper, or null
|
|
289
|
+
const lowerMs = lower != null ? Date.parse(lower) : null; // precomputed for the per-event filter
|
|
290
|
+
const upperMs = upper != null ? Date.parse(upper) : null;
|
|
291
|
+
// `--from`/`--to` is an explicit, precisely-clipped window. It re-reads files
|
|
292
|
+
// (the ledger marks a file "covered up to now", which a bounded window must not
|
|
293
|
+
// trust) and records NO coverage — re-import idempotency comes from the
|
|
294
|
+
// event_id UNIQUE dedup instead. `--days`/`--since` keep the fast ledger path.
|
|
295
|
+
const rangeMode = lower != null || upper != null;
|
|
296
|
+
// File-level cutoff (which transcripts to read). In range mode WITHOUT a lower
|
|
297
|
+
// bound (`--to` only) read everything and let the per-event upper clip decide —
|
|
298
|
+
// otherwise the default 30d cutoff would skip older files and import nothing.
|
|
299
|
+
const cutoff = (rangeMode && lower == null)
|
|
300
|
+
? new Date(0).toISOString()
|
|
301
|
+
: resolveCutoff({ days, since, from });
|
|
239
302
|
const projectFilter = projects
|
|
240
303
|
? projects.split(',').map(normalizeProjectArg).filter(Boolean)
|
|
241
304
|
: null;
|
|
242
305
|
ensureDataDir();
|
|
243
306
|
const ledger = loadLedger();
|
|
307
|
+
// Human-readable window label for the progress lines.
|
|
308
|
+
const windowLabel = (from || to)
|
|
309
|
+
? `${from || 'start'}…${to || 'now'}`
|
|
310
|
+
: (since || (days === 0 ? 'all' : days + 'd'));
|
|
244
311
|
if (!dryRun) info(`Server: ${getServerUrl()}`);
|
|
245
|
-
info(dryRun ? `Scanning (dry-run, window: ${
|
|
246
|
-
: `Importing window: ${
|
|
312
|
+
info(dryRun ? `Scanning (dry-run, window: ${windowLabel})…`
|
|
313
|
+
: `Importing window: ${windowLabel} (cutoff ${cutoff.slice(0, 10)}${upper ? `, until ${to}` : ''})`);
|
|
247
314
|
|
|
248
315
|
const allFiles = [...new Set(walkJsonl(PROJECTS_DIR))];
|
|
249
316
|
let filesIncluded = 0, filesSkipped = 0, eventCount = 0, sessionCount = 0, liveSkipped = 0;
|
|
@@ -276,7 +343,7 @@ export default async function importClaudeCode(flags) {
|
|
|
276
343
|
try { st = statSync(filePath); } catch { continue; }
|
|
277
344
|
const fp = { mtimeMs: Math.trunc(st.mtimeMs), size: st.size };
|
|
278
345
|
if (st.mtime.toISOString() < cutoff) { filesSkipped++; continue; } // last write before window
|
|
279
|
-
if (ledgerCovers(ledger[filePath], cutoff, fp)) { filesSkipped++; continue; }
|
|
346
|
+
if (!rangeMode && ledgerCovers(ledger[filePath], cutoff, fp)) { filesSkipped++; continue; }
|
|
280
347
|
candidates.push({ filePath, fp });
|
|
281
348
|
}
|
|
282
349
|
|
|
@@ -308,11 +375,22 @@ export default async function importClaudeCode(flags) {
|
|
|
308
375
|
const emitTerminal = (nowMs - fp.mtimeMs) >= TERMINAL_DORMANT_MS;
|
|
309
376
|
const mapped = mapTranscript(lines, { sessionId, projectPath, fileId, isSubagentFile, agentId, agentSlug: null, emitTerminal });
|
|
310
377
|
if (mapped.length === 0) { filesSkipped++; continue; }
|
|
311
|
-
|
|
378
|
+
// De-overlap with live coverage, then clip to the explicit [from, to] window.
|
|
379
|
+
const events = sliceEvents(mapped, coverage).filter((ev) => {
|
|
380
|
+
if (lowerMs == null && upperMs == null) return true;
|
|
381
|
+
const t = Date.parse(ev.timestamp);
|
|
382
|
+
if (lowerMs != null && t < lowerMs) return false;
|
|
383
|
+
if (upperMs != null && t >= upperMs) return false;
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
312
386
|
if (events.length === 0) {
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
387
|
+
// Nothing left for this file: either live already owns the session, or every
|
|
388
|
+
// event fell outside the [from, to] window. Only the former is "live skipped";
|
|
389
|
+
// only a full (unbounded) import may mark the file covered in the ledger.
|
|
390
|
+
if (!rangeMode) {
|
|
391
|
+
liveSkipped++;
|
|
392
|
+
if (!dryRun) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
|
|
393
|
+
}
|
|
316
394
|
continue;
|
|
317
395
|
}
|
|
318
396
|
|
|
@@ -351,7 +429,8 @@ export default async function importClaudeCode(flags) {
|
|
|
351
429
|
if (!isSubagentFile) (lastTs >= new Date(nowMs - analysisMaxAgeDays * 86400_000).toISOString() ? withinWindow++ : older++);
|
|
352
430
|
if (!lastDate || lastTs > lastDate) lastDate = lastTs;
|
|
353
431
|
// Defer the ledger commit until this file's events have actually shipped (flush()).
|
|
354
|
-
|
|
432
|
+
// A bounded `--from/--to` re-import never records coverage (it didn't read to "now").
|
|
433
|
+
if (!dryRun && !rangeMode) pendingCommit.push({ filePath, entry: { covered_cutoff: cutoff, complete_all: days === 0, fingerprint: fp } });
|
|
355
434
|
if (filesIncluded % 25 === 0) info(` …${filesIncluded} files, ${eventCount} events — last ${(lastDate || '').slice(0, 10)}`);
|
|
356
435
|
}
|
|
357
436
|
|
package/cli/import.js
CHANGED
|
@@ -6,7 +6,7 @@ import { initLogger, error, info } from './logger.js';
|
|
|
6
6
|
import { getVersionInfo } from './hooks.js';
|
|
7
7
|
|
|
8
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' };
|
|
9
|
+
const VALUE_FLAGS = { '--days': 'days', '--since': 'since', '--from': 'from', '--to': 'to', '--projects': 'projects', '--analysis-max-age-days': 'analysisMaxAgeDays' };
|
|
10
10
|
const INT_KEYS = new Set(['days', 'analysisMaxAgeDays']);
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -17,7 +17,7 @@ const INT_KEYS = new Set(['days', 'analysisMaxAgeDays']);
|
|
|
17
17
|
* REAL import instead of a preview.)
|
|
18
18
|
*/
|
|
19
19
|
export function parseFlags(argv) {
|
|
20
|
-
const flags = { days: 30, since: null, dryRun: false, projects: null, noRedact: false, analysisMaxAgeDays: 30 };
|
|
20
|
+
const flags = { days: 30, since: null, from: null, to: null, dryRun: false, projects: null, noRedact: false, analysisMaxAgeDays: 30 };
|
|
21
21
|
const errors = [];
|
|
22
22
|
for (let i = 0; i < argv.length; i++) {
|
|
23
23
|
const arg = argv[i];
|
|
@@ -44,7 +44,7 @@ export default async function importCmd() {
|
|
|
44
44
|
|
|
45
45
|
if (errors.length) {
|
|
46
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]');
|
|
47
|
+
info('Usage: ai-lens import claude-code [--days N | --since YYYY-MM-DD | --from YYYY-MM-DD --to YYYY-MM-DD] [--projects A,B] [--dry-run] [--no-redact]');
|
|
48
48
|
process.exitCode = 1;
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai-lens list-sessions` — list your own sessions (thin client over
|
|
3
|
+
* `GET /api/sessions`). Filters: --days, --source, --limit. --json for raw output.
|
|
4
|
+
*/
|
|
5
|
+
import { initLogger, heading, info, error, detail, blank } from './logger.js';
|
|
6
|
+
import { getVersionInfo } from './hooks.js';
|
|
7
|
+
import { fetchSessions, ApiError } from './data-api.js';
|
|
8
|
+
import { parseArgs, SESSION_HEADER, formatSessionLine } from './data-format.js';
|
|
9
|
+
|
|
10
|
+
export function parseListFlags(argv) {
|
|
11
|
+
return parseArgs(argv, {
|
|
12
|
+
bools: { '--json': 'json' },
|
|
13
|
+
values: { '--days': 'days', '--source': 'source', '--limit': 'limit' },
|
|
14
|
+
ints: new Set(['days', 'limit']),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function listSessions() {
|
|
19
|
+
const { flags, errors } = parseListFlags(process.argv.slice(3));
|
|
20
|
+
const { version, commit } = getVersionInfo();
|
|
21
|
+
initLogger(`v${version} (${commit})`);
|
|
22
|
+
|
|
23
|
+
if (errors.length) {
|
|
24
|
+
errors.forEach((e) => error(e));
|
|
25
|
+
info('Usage: ai-lens list-sessions [--days N] [--source claude_code|cursor|codex] [--limit N] [--json]');
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let sessions;
|
|
31
|
+
try {
|
|
32
|
+
sessions = await fetchSessions({ days: flags.days, source: flags.source, limit: flags.limit });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
error(err instanceof ApiError ? err.message : `Failed to list sessions: ${err.message}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (flags.json) {
|
|
40
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
heading(`Your sessions${flags.source ? ` (${flags.source})` : ''}${flags.days ? `, last ${flags.days}d` : ''}`);
|
|
45
|
+
if (sessions.length === 0) {
|
|
46
|
+
info('No sessions found for this window.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
info(SESSION_HEADER);
|
|
50
|
+
for (const s of sessions) info(formatSessionLine(s));
|
|
51
|
+
blank();
|
|
52
|
+
detail(`${sessions.length} session(s). Full id with --json. Remove with: ai-lens delete-sessions <id>`);
|
|
53
|
+
}
|