ai-lens 0.8.109 → 0.8.111
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 +8 -0
- package/README.md +6 -0
- package/cli/init.js +13 -0
- package/cli/logger.js +16 -7
- package/cli/status.js +136 -13
- package/client/capture.js +49 -12
- package/client/mojibake-fix.js +192 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
c982dc2
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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.111 — 2026-06-23
|
|
6
|
+
- fix(status): case-fold project-filter-mismatch buckets on Windows
|
|
7
|
+
- fix(client): tolerate Cursor Windows mojibake paths in project_filter
|
|
8
|
+
- fix(status): project hooks "absent" and benign capture drops are info, not errors
|
|
9
|
+
|
|
10
|
+
## 0.8.110 — 2026-06-23
|
|
11
|
+
- feat: `init` now sends a status report to the server right after it finishes, so the server has your current config (including which `projects` are tracked) and confirmation that hooks fire — no need to run `ai-lens status` by hand. The diagnostic runs quietly: just one summary line on screen.
|
|
12
|
+
|
|
5
13
|
## 0.8.109 — 2026-06-22
|
|
6
14
|
- fix: `init --yes` no longer captures every project or silently imports your local Claude history. It now scopes capture to the git root of the current folder (still `--projects all` for everything), and imports past history only when you pass `--import`.
|
|
7
15
|
|
package/README.md
CHANGED
|
@@ -222,6 +222,12 @@ When MCP is enabled during `npx ai-lens init`, these tools become available insi
|
|
|
222
222
|
| `get_chain_analysis` | AI-generated chain analysis: tasks, problems, tool errors, unanswered questions |
|
|
223
223
|
| `request_analysis` | Manually trigger analysis for a specific session chain |
|
|
224
224
|
| `get_token_usage` | Token usage statistics grouped by model (input/output/cache tokens) |
|
|
225
|
+
| `get_cost` | Dual-track cost breakdown (billed / estimated / subscription, do not sum) + by-model for a period; scope company/team/developer/bot |
|
|
226
|
+
| `get_cost_ranking` | Ranked cost by team or by people for a period |
|
|
227
|
+
| `get_cost_attribution` | Attribution health of factual Cursor spend — unattributed money and the people behind it |
|
|
228
|
+
| `get_cost_trend` | Company dual-track cost time-series over the last N buckets |
|
|
229
|
+
| `get_analysis_spend` | AI-Lens's own LLM (analysis pipeline) spend — daily series, total, delta, by source |
|
|
230
|
+
| `manage_subscription_fee` | Manage the manual per-developer × tool monthly seat fee (the subscription lane) |
|
|
225
231
|
| `knowhow_search` | Search the team knowledge base built from session analyses |
|
|
226
232
|
| `knowhow_update` | Add or update a knowledge base entry |
|
|
227
233
|
| `export_developer_tips` | Export personalized tips as a Markdown document |
|
package/cli/init.js
CHANGED
|
@@ -1436,6 +1436,19 @@ export default async function init() {
|
|
|
1436
1436
|
// so the dashboard isn't empty on first open.
|
|
1437
1437
|
await maybeOfferImportHistory(flags);
|
|
1438
1438
|
|
|
1439
|
+
// Push a full status snapshot to the server right after every init — so it always has
|
|
1440
|
+
// this machine's FRESH config (crucially the `projects` filter, which the ingest path
|
|
1441
|
+
// never carries) plus post-install health (hooks actually fire). Runs quietly: the full
|
|
1442
|
+
// diagnostic is muted on screen (only one summary line) but still POSTed and written to
|
|
1443
|
+
// init.log. Best-effort: a token is required (the report needs auth), and any failure is
|
|
1444
|
+
// swallowed so it can never block or fail init.
|
|
1445
|
+
if (finalConfig.authToken && finalConfig.serverUrl) {
|
|
1446
|
+
try {
|
|
1447
|
+
const { default: status } = await import('./status.js');
|
|
1448
|
+
await status({ report: true, quiet: true });
|
|
1449
|
+
} catch { /* best-effort — never break init on a status/report failure */ }
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1439
1452
|
detail(`Log: ${getLogPath()}`);
|
|
1440
1453
|
}
|
|
1441
1454
|
|
package/cli/logger.js
CHANGED
|
@@ -7,6 +7,14 @@ const LOG_FILE = join(DATA_DIR, 'init.log');
|
|
|
7
7
|
|
|
8
8
|
let logFile = null;
|
|
9
9
|
|
|
10
|
+
// Screen-quiet mode: suppress stdout (console.log) while STILL writing to init.log.
|
|
11
|
+
// Used by init's end-of-run auto status report — the full diagnostic snapshot is sent
|
|
12
|
+
// to the server and logged to ~/.ai-lens/init.log, but the screen shows only the single
|
|
13
|
+
// summary line the caller prints (after flipping this back off). Toggled in a finally so
|
|
14
|
+
// a throw never leaves the terminal muted.
|
|
15
|
+
let screenQuiet = false;
|
|
16
|
+
export function setScreenQuiet(v) { screenQuiet = Boolean(v); }
|
|
17
|
+
|
|
10
18
|
export function initLogger(versionStr) {
|
|
11
19
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
12
20
|
logFile = LOG_FILE;
|
|
@@ -53,6 +61,7 @@ let progressLastPaint = 0;
|
|
|
53
61
|
|
|
54
62
|
/** Show/update a transient one-line progress indicator (TTY only, throttled). */
|
|
55
63
|
export function progress(text) {
|
|
64
|
+
if (screenQuiet) return;
|
|
56
65
|
if (!useColor) return; // useColor === stdout.isTTY
|
|
57
66
|
const now = Date.now();
|
|
58
67
|
if (progressActive && now - progressLastPaint < PROGRESS_MIN_REPAINT_MS) return;
|
|
@@ -71,43 +80,43 @@ export function progressDone() {
|
|
|
71
80
|
|
|
72
81
|
export function info(msg) {
|
|
73
82
|
progressDone();
|
|
74
|
-
console.log(msg);
|
|
83
|
+
if (!screenQuiet) console.log(msg);
|
|
75
84
|
write('INFO', msg);
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export function success(msg) {
|
|
79
88
|
progressDone();
|
|
80
|
-
console.log(`${GREEN}${msg}${RESET}`);
|
|
89
|
+
if (!screenQuiet) console.log(`${GREEN}${msg}${RESET}`);
|
|
81
90
|
write('INFO', msg);
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
export function warn(msg) {
|
|
85
94
|
progressDone();
|
|
86
|
-
console.log(`${YELLOW}${msg}${RESET}`);
|
|
95
|
+
if (!screenQuiet) console.log(`${YELLOW}${msg}${RESET}`);
|
|
87
96
|
write('WARN', msg);
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export function error(msg) {
|
|
91
100
|
progressDone();
|
|
92
|
-
console.log(`${RED}${msg}${RESET}`);
|
|
101
|
+
if (!screenQuiet) console.log(`${RED}${msg}${RESET}`);
|
|
93
102
|
write('ERROR', msg);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
export function heading(msg) {
|
|
97
106
|
progressDone();
|
|
98
|
-
console.log(`\n${BOLD}${CYAN}${msg}${RESET}`);
|
|
107
|
+
if (!screenQuiet) console.log(`\n${BOLD}${CYAN}${msg}${RESET}`);
|
|
99
108
|
write('INFO', msg);
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
export function detail(msg) {
|
|
103
112
|
progressDone();
|
|
104
|
-
console.log(`${DIM} ${msg}${RESET}`);
|
|
113
|
+
if (!screenQuiet) console.log(`${DIM} ${msg}${RESET}`);
|
|
105
114
|
write('INFO', msg);
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
export function blank() {
|
|
109
118
|
progressDone();
|
|
110
|
-
console.log();
|
|
119
|
+
if (!screenQuiet) console.log();
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
export function getLogPath() {
|
package/cli/status.js
CHANGED
|
@@ -9,8 +9,10 @@ import { TLS_TRUST_CODES, tlsCodeOf, tlsVerdictSummary, issuerName } from '../cl
|
|
|
9
9
|
|
|
10
10
|
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
|
|
11
11
|
import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
|
|
12
|
+
import { recoverMojibake } from '../client/mojibake-fix.js';
|
|
13
|
+
import { isProjectMonitored } from '../client/capture.js';
|
|
12
14
|
import { isLockStale } from '../client/sender.js';
|
|
13
|
-
import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
|
|
15
|
+
import { initLogger, info, success, warn, error, heading, blank, detail, setScreenQuiet } from './logger.js';
|
|
14
16
|
import { scanNestedProjects, summarizeNestedProjects } from './scan.js';
|
|
15
17
|
|
|
16
18
|
const INIT_LOG_PATH = join(DATA_DIR, 'init.log');
|
|
@@ -610,7 +612,7 @@ function checkConfig() {
|
|
|
610
612
|
};
|
|
611
613
|
}
|
|
612
614
|
|
|
613
|
-
function checkHooks(tool) {
|
|
615
|
+
export function checkHooks(tool) {
|
|
614
616
|
const analysis = analyzeToolHooks(tool);
|
|
615
617
|
const statusMap = {
|
|
616
618
|
fresh: { ok: null, label: 'no config file' },
|
|
@@ -633,6 +635,21 @@ function checkHooks(tool) {
|
|
|
633
635
|
}
|
|
634
636
|
} catch { /* file doesn't exist or isn't valid */ }
|
|
635
637
|
|
|
638
|
+
// Project-scope hooks are opt-in (init --project-hooks). A project config that
|
|
639
|
+
// simply carries no AI Lens hooks ("absent") is informational, NOT a failure —
|
|
640
|
+
// it's the default for almost every repo with a committed .claude/.cursor config,
|
|
641
|
+
// and capture is handled by the global hooks. A genuine no-capture state (nothing
|
|
642
|
+
// installed in any scope) is still surfaced by the "Hook mode: none" check and the
|
|
643
|
+
// global tool lines, so this never hides a real problem.
|
|
644
|
+
const isProjectScope = tool.dirPath && dirname(tool.dirPath) !== homedir();
|
|
645
|
+
if (analysis.status === 'absent' && isProjectScope) {
|
|
646
|
+
return {
|
|
647
|
+
ok: null,
|
|
648
|
+
summary: 'no project hooks (opt-in)',
|
|
649
|
+
detail: `${detail}\n\nNo AI Lens hooks in this project's config — informational only. Project hooks are opt-in (npx -y ai-lens init --project-hooks); capture runs via the global hooks when installed.`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
636
653
|
// Check if hooks are globally disabled (e.g. disableAllHooks in settings.local.json)
|
|
637
654
|
const disabled = checkHooksDisabled(tool);
|
|
638
655
|
if (disabled.length > 0) {
|
|
@@ -852,14 +869,14 @@ function checkSenderLog() {
|
|
|
852
869
|
};
|
|
853
870
|
}
|
|
854
871
|
|
|
855
|
-
function checkCaptureLog() {
|
|
856
|
-
if (!existsSync(
|
|
872
|
+
export function checkCaptureLog(logPath = CAPTURE_LOG_PATH) {
|
|
873
|
+
if (!existsSync(logPath)) {
|
|
857
874
|
return { ok: true, summary: 'no drops logged', detail: 'Capture log does not exist (no events dropped)' };
|
|
858
875
|
}
|
|
859
876
|
|
|
860
877
|
let lines;
|
|
861
878
|
try {
|
|
862
|
-
lines = readFileSync(
|
|
879
|
+
lines = readFileSync(logPath, 'utf-8').split(/\r?\n/).filter(Boolean);
|
|
863
880
|
} catch (err) {
|
|
864
881
|
return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
|
|
865
882
|
}
|
|
@@ -869,10 +886,19 @@ function checkCaptureLog() {
|
|
|
869
886
|
// code distribution per category so it reaches the server via client_reports
|
|
870
887
|
// — the count alone can't tell EMFILE from EACCES from ENOENT. The raw
|
|
871
888
|
// error.message strings stay local (may contain paths); only the code travels.
|
|
889
|
+
// Severity of capture-log entries:
|
|
890
|
+
// - `reason` entries are expected gate DROPS (project_filter / no_email /
|
|
891
|
+
// duplicate / …) — never a problem, they reflect your own config.
|
|
892
|
+
// - `msg` entries are capture-side errors. Split them: SOFT transient read
|
|
893
|
+
// errors (transcript-offset-*: a re-read/offset race, not a broken setup)
|
|
894
|
+
// surface as informational; HARD errors (queue/spawn/capture failures) mean
|
|
895
|
+
// capture is actually failing and stay a hard ✗.
|
|
896
|
+
const SOFT_ERROR_MSGS = new Set(['transcript-offset-error', 'transcript-offset-commit-failed']);
|
|
872
897
|
const counts = {};
|
|
873
898
|
const codesByCategory = {};
|
|
874
899
|
let lastTs = null;
|
|
875
|
-
let
|
|
900
|
+
let hasHardError = false;
|
|
901
|
+
let hasSoftError = false;
|
|
876
902
|
for (const line of lines) {
|
|
877
903
|
try {
|
|
878
904
|
const entry = JSON.parse(line);
|
|
@@ -883,7 +909,10 @@ function checkCaptureLog() {
|
|
|
883
909
|
codesByCategory[category][entry.code] = (codesByCategory[category][entry.code] || 0) + 1;
|
|
884
910
|
}
|
|
885
911
|
lastTs = entry.ts;
|
|
886
|
-
if (entry.msg)
|
|
912
|
+
if (entry.msg) {
|
|
913
|
+
if (SOFT_ERROR_MSGS.has(entry.msg)) hasSoftError = true;
|
|
914
|
+
else hasHardError = true;
|
|
915
|
+
}
|
|
887
916
|
} catch { /* non-JSON line */ }
|
|
888
917
|
}
|
|
889
918
|
|
|
@@ -906,13 +935,79 @@ function checkCaptureLog() {
|
|
|
906
935
|
const codeBlock = codeLines.length ? `\n\nError codes by category:\n${codeLines.join('\n')}` : '';
|
|
907
936
|
const last10 = lines.slice(-10);
|
|
908
937
|
|
|
938
|
+
// Hard error → ✗; only soft/transient errors → informational '-'; pure drops → ✓.
|
|
909
939
|
return {
|
|
910
|
-
ok:
|
|
940
|
+
ok: hasHardError ? false : (hasSoftError ? null : true),
|
|
911
941
|
summary,
|
|
912
|
-
detail: `Log: ${
|
|
942
|
+
detail: `Log: ${logPath}\nTotal: ${total}${codeBlock}\n\nLast 10 entries:\n${last10.join('\n')}`,
|
|
913
943
|
};
|
|
914
944
|
}
|
|
915
945
|
|
|
946
|
+
// Render a captured (Cursor URI-style, forward-slash) path as a native path for
|
|
947
|
+
// the fix hint — `/c:/a/b` → `C:\a\b` on Windows; unchanged elsewhere.
|
|
948
|
+
function toDisplayPath(p) {
|
|
949
|
+
if (process.platform !== 'win32' || typeof p !== 'string') return p;
|
|
950
|
+
let n = p.replace(/^\/([a-zA-Z]:)/, '$1').replace(/\//g, '\\');
|
|
951
|
+
return n.replace(/^([a-z]):/, (_m, d) => d.toUpperCase() + ':');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Surface project_filter drops where the user's ACTUAL working project isn't in
|
|
955
|
+
// the `projects` filter — real events silently dropped. The plain "Capture drops"
|
|
956
|
+
// counter hides this. Recovers Cursor 3.2.x+ Windows mojibake paths before the
|
|
957
|
+
// coverage check so they aren't flagged once the capture-side fix matches them.
|
|
958
|
+
// Guards against false positives: only entries logged under the CURRENT live
|
|
959
|
+
// filter (drops the status self-test's own synthetic drops + stale-config
|
|
960
|
+
// entries), excludes the self-test session/paths, and needs a meaningful count.
|
|
961
|
+
const PROJECT_FILTER_MISMATCH_MIN = 5;
|
|
962
|
+
export function checkProjectFilterMismatch(logPath = CAPTURE_LOG_PATH, monitored = getMonitoredProjects()) {
|
|
963
|
+
if (!monitored) return { ok: true, summary: 'no filter configured (capturing all)', detail: 'projects filter is unset — nothing to mismatch.' };
|
|
964
|
+
if (!existsSync(logPath)) return { ok: true, summary: 'no drops logged', detail: 'Capture log does not exist.' };
|
|
965
|
+
|
|
966
|
+
let lines;
|
|
967
|
+
try { lines = readFileSync(logPath, 'utf-8').split(/\r?\n/).filter(Boolean); }
|
|
968
|
+
catch (err) { return { ok: true, summary: 'log unreadable', detail: `Error: ${err.message}` }; }
|
|
969
|
+
|
|
970
|
+
const liveSig = JSON.stringify([...monitored].sort());
|
|
971
|
+
const byPath = new Map(); // case-normalized key -> { display, count }
|
|
972
|
+
for (const line of lines) {
|
|
973
|
+
let e;
|
|
974
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
975
|
+
if (e.reason !== 'project_filter' || typeof e.project_path !== 'string' || !e.project_path) continue;
|
|
976
|
+
// Skip the status self-test's own synthetic drops.
|
|
977
|
+
if (typeof e.session_id === 'string' && e.session_id.startsWith('status-check-')) continue;
|
|
978
|
+
if (e.project_path.includes('.ai-lens-status-check')) continue;
|
|
979
|
+
// Only entries logged under the CURRENT filter — drops stale-config and the
|
|
980
|
+
// self-test's `AI_LENS_PROJECTS=…status-check-nonexistent` runs.
|
|
981
|
+
const sig = Array.isArray(e.monitored) ? JSON.stringify([...e.monitored].sort()) : null;
|
|
982
|
+
if (sig !== liveSig) continue;
|
|
983
|
+
// If the live filter already covers it (raw OR mojibake-recovered), the
|
|
984
|
+
// capture-side fix will keep it — not a mismatch.
|
|
985
|
+
if (isProjectMonitored(e.project_path, [], monitored)) continue;
|
|
986
|
+
const display = toDisplayPath(recoverMojibake(e.project_path));
|
|
987
|
+
// Key case-insensitively on Windows so the same project logged with
|
|
988
|
+
// differing case doesn't split across buckets and dodge the threshold.
|
|
989
|
+
const key = process.platform === 'win32' ? display.toLowerCase() : display;
|
|
990
|
+
const cur = byPath.get(key);
|
|
991
|
+
if (cur) cur.count++;
|
|
992
|
+
else byPath.set(key, { display, count: 1 });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const offenders = [...byPath.values()].filter(o => o.count >= PROJECT_FILTER_MISMATCH_MIN).sort((a, b) => b.count - a.count);
|
|
996
|
+
if (offenders.length === 0) return { ok: true, summary: 'filter matches captured paths', detail: 'No project_filter drops outside the configured filter.' };
|
|
997
|
+
|
|
998
|
+
const { display: topPath, count: topCount } = offenders[0];
|
|
999
|
+
const filterStr = monitored.join(', ');
|
|
1000
|
+
const detail =
|
|
1001
|
+
`⚠️ Фильтр projects не покрывает твой рабочий путь — события отбрасываются (project_filter).\n` +
|
|
1002
|
+
` Рабочий путь: ${topPath}\n` +
|
|
1003
|
+
` В фильтре: ${filterStr}\n` +
|
|
1004
|
+
` Почини: org-разработчику — /sync + /setup; иначе добавь путь в фильтр:\n` +
|
|
1005
|
+
` npx -y ai-lens@latest init --yes --projects "${topPath}"\n` +
|
|
1006
|
+
` (если этот проект исключён намеренно — игнорируй)` +
|
|
1007
|
+
(offenders.length > 1 ? `\n Ещё путей вне фильтра: ${offenders.length - 1}` : '');
|
|
1008
|
+
return { ok: null, summary: `⚠ ${topCount} событий отброшено — рабочий путь не в фильтре projects`, detail };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
916
1011
|
function checkRealActivity() {
|
|
917
1012
|
try {
|
|
918
1013
|
if (!existsSync(SESSION_PATHS_DIR)) {
|
|
@@ -1414,7 +1509,7 @@ function collectHookConfigs(allTools) {
|
|
|
1414
1509
|
}
|
|
1415
1510
|
|
|
1416
1511
|
async function sendStatusReport(results, warnings, clientVersion, clientCommit, serverUrl, authToken, allTools = TOOL_CONFIGS) {
|
|
1417
|
-
if (!serverUrl || !authToken) return;
|
|
1512
|
+
if (!serverUrl || !authToken) return { ok: false, reason: 'no-auth' };
|
|
1418
1513
|
|
|
1419
1514
|
const payload = {
|
|
1420
1515
|
timestamp: new Date().toISOString(),
|
|
@@ -1446,8 +1541,10 @@ async function sendStatusReport(results, warnings, clientVersion, clientCommit,
|
|
|
1446
1541
|
if (res.ok) {
|
|
1447
1542
|
try { writeFileSync(LAST_STATUS_REPORT_PATH, new Date().toISOString()); } catch {}
|
|
1448
1543
|
}
|
|
1449
|
-
|
|
1544
|
+
return { ok: res.ok, status: res.status };
|
|
1545
|
+
} catch (err) {
|
|
1450
1546
|
// Silent — report is best-effort
|
|
1547
|
+
return { ok: false, reason: err.message };
|
|
1451
1548
|
}
|
|
1452
1549
|
}
|
|
1453
1550
|
|
|
@@ -1455,7 +1552,15 @@ async function sendStatusReport(results, warnings, clientVersion, clientCommit,
|
|
|
1455
1552
|
// Main
|
|
1456
1553
|
// ---------------------------------------------------------------------------
|
|
1457
1554
|
|
|
1458
|
-
export default async function status({ report = false } = {}) {
|
|
1555
|
+
export default async function status({ report = false, quiet = false } = {}) {
|
|
1556
|
+
// quiet: run the full diagnostic + report, but suppress the on-screen dump — the
|
|
1557
|
+
// caller (init) prints a single summary line. The full snapshot still ships to the
|
|
1558
|
+
// server and is written to ~/.ai-lens/init.log. setScreenQuiet is flipped back in a
|
|
1559
|
+
// finally so a throw never leaves the terminal muted.
|
|
1560
|
+
if (quiet) setScreenQuiet(true);
|
|
1561
|
+
let reportOutcome = null;
|
|
1562
|
+
let failedChecks = 0;
|
|
1563
|
+
try {
|
|
1459
1564
|
const versionResult = checkVersion();
|
|
1460
1565
|
// Print to screen in both modes. --report additionally POSTs to the server
|
|
1461
1566
|
// (and skips the local text-file write + "Full report → path" line at the end).
|
|
@@ -1614,6 +1719,7 @@ export default async function status({ report = false } = {}) {
|
|
|
1614
1719
|
|
|
1615
1720
|
// 10. Capture drops
|
|
1616
1721
|
printLine('Capture drops', checkCaptureLog());
|
|
1722
|
+
printLine('Project filter', checkProjectFilterMismatch());
|
|
1617
1723
|
|
|
1618
1724
|
// 11. Real activity
|
|
1619
1725
|
const realActivityResult = checkRealActivity();
|
|
@@ -1672,7 +1778,7 @@ export default async function status({ report = false } = {}) {
|
|
|
1672
1778
|
if (report) {
|
|
1673
1779
|
// --report mode: same on-screen output as normal status, but POST the
|
|
1674
1780
|
// structured JSON to the server instead of writing the local text file.
|
|
1675
|
-
await sendStatusReport(results, warnings, version, commit, serverUrl, authToken, allToolsForReport);
|
|
1781
|
+
reportOutcome = await sendStatusReport(results, warnings, version, commit, serverUrl, authToken, allToolsForReport);
|
|
1676
1782
|
blank();
|
|
1677
1783
|
} else {
|
|
1678
1784
|
// Normal mode: write text report file
|
|
@@ -1688,4 +1794,21 @@ export default async function status({ report = false } = {}) {
|
|
|
1688
1794
|
}
|
|
1689
1795
|
blank();
|
|
1690
1796
|
}
|
|
1797
|
+
failedChecks = results.filter(r => r && r.ok === false).length;
|
|
1798
|
+
} finally {
|
|
1799
|
+
if (quiet) setScreenQuiet(false);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Quiet mode (init's end-of-run auto report): the full diagnostic above was muted —
|
|
1803
|
+
// print exactly one summary line now that the screen is un-muted.
|
|
1804
|
+
if (quiet) {
|
|
1805
|
+
if (reportOutcome?.ok) {
|
|
1806
|
+
const tail = failedChecks > 0
|
|
1807
|
+
? ` (${failedChecks} проверок с замечаниями — запусти ai-lens status)`
|
|
1808
|
+
: '';
|
|
1809
|
+
success(` 📡 Диагностика и конфиг отправлены на сервер${tail}`);
|
|
1810
|
+
} else {
|
|
1811
|
+
detail('📡 Не удалось отправить диагностику на сервер (не критично)');
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1691
1814
|
}
|
package/client/capture.js
CHANGED
|
@@ -41,6 +41,16 @@ try {
|
|
|
41
41
|
redactObject = mod.redactObject ?? ((o) => o);
|
|
42
42
|
} catch { /* redact.js not installed yet — skip redaction, server will handle it */ }
|
|
43
43
|
|
|
44
|
+
// Soft import — mojibake-fix.js may not exist on older client installs.
|
|
45
|
+
// recoverMojibake repairs Cursor 3.2.x+ Windows mojibake (CP1251/CP1252 double
|
|
46
|
+
// UTF-8) for the project_filter MATCH decision only; the raw payload is never
|
|
47
|
+
// mutated (the server stays the canonical recoverer on ingest).
|
|
48
|
+
let recoverMojibake = (s) => s;
|
|
49
|
+
try {
|
|
50
|
+
const mod = await import('./mojibake-fix.js');
|
|
51
|
+
recoverMojibake = mod.recoverMojibake ?? ((s) => s);
|
|
52
|
+
} catch { /* mojibake-fix.js not installed yet — skip recovery, server recovers on ingest */ }
|
|
53
|
+
|
|
44
54
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
55
|
|
|
46
56
|
/**
|
|
@@ -964,7 +974,7 @@ const CURSOR_TYPE_MAP = {
|
|
|
964
974
|
// to forward slashes before comparing. On Windows the filesystem is
|
|
965
975
|
// case-insensitive, so we also lowercase both sides to prevent mismatches
|
|
966
976
|
// when realpathSync returns a different case than the configured path.
|
|
967
|
-
function pathContains(parent, child) {
|
|
977
|
+
export function pathContains(parent, child) {
|
|
968
978
|
if (!parent || !child || typeof parent !== 'string' || typeof child !== 'string') return false;
|
|
969
979
|
const norm = s => {
|
|
970
980
|
let n = s.replace(/\\/g, '/').replace(/\/$/, '');
|
|
@@ -977,6 +987,36 @@ function pathContains(parent, child) {
|
|
|
977
987
|
return c === p || c.startsWith(p + '/');
|
|
978
988
|
}
|
|
979
989
|
|
|
990
|
+
// Decide whether an event's project is covered by the monitored filter.
|
|
991
|
+
// Tolerates the mojibake `project_path` Cursor 3.2.x+ emits on Windows
|
|
992
|
+
// (CP1251/CP1252 double-UTF-8): each candidate is matched BOTH as-is and via
|
|
993
|
+
// recoverMojibake(). The recovered form is used ONLY here for the match — the
|
|
994
|
+
// raw payload is shipped unchanged and the server recovers it on ingest.
|
|
995
|
+
// Returns true (= keep) when no filter is configured or no project_path is
|
|
996
|
+
// present, preserving the prior gate's "can't filter → pass through" behavior.
|
|
997
|
+
export function isProjectMonitored(projectPath, workspaceRoots, monitored) {
|
|
998
|
+
if (!monitored) return true;
|
|
999
|
+
if (!projectPath) return true;
|
|
1000
|
+
const candidates = [];
|
|
1001
|
+
const add = (v) => {
|
|
1002
|
+
if (typeof v !== 'string' || !v) return;
|
|
1003
|
+
candidates.push(v);
|
|
1004
|
+
const fixed = recoverMojibake(v);
|
|
1005
|
+
if (fixed !== v) candidates.push(fixed);
|
|
1006
|
+
};
|
|
1007
|
+
let resolved = projectPath;
|
|
1008
|
+
try { resolved = realpathSync(projectPath); } catch {}
|
|
1009
|
+
add(resolved);
|
|
1010
|
+
add(projectPath); // raw (realpathSync fails on a mojibake path, so keep the original too)
|
|
1011
|
+
for (const r of (Array.isArray(workspaceRoots) ? workspaceRoots : [])) {
|
|
1012
|
+
let rr = r;
|
|
1013
|
+
try { rr = realpathSync(r); } catch {}
|
|
1014
|
+
add(rr);
|
|
1015
|
+
add(r);
|
|
1016
|
+
}
|
|
1017
|
+
return candidates.some(c => monitored.some(p => pathContains(p, c)));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
980
1020
|
function pickWorkspaceRoot(roots) {
|
|
981
1021
|
if (!Array.isArray(roots) || roots.length === 0) return null;
|
|
982
1022
|
const valid = roots.filter(r => typeof r === 'string' && r.length > 0);
|
|
@@ -987,7 +1027,8 @@ function pickWorkspaceRoot(roots) {
|
|
|
987
1027
|
const match = valid.find(root => {
|
|
988
1028
|
let resolved = root;
|
|
989
1029
|
try { resolved = realpathSync(root); } catch {}
|
|
990
|
-
|
|
1030
|
+
// Match raw OR mojibake-recovered (Cursor Windows non-ASCII paths).
|
|
1031
|
+
return monitored.some(p => pathContains(p, resolved) || pathContains(p, recoverMojibake(resolved)));
|
|
991
1032
|
});
|
|
992
1033
|
if (match) return match;
|
|
993
1034
|
}
|
|
@@ -1527,16 +1568,12 @@ async function main() {
|
|
|
1527
1568
|
// If the primary is filtered out, drop the entire batch (the per-call events
|
|
1528
1569
|
// share the same project_path).
|
|
1529
1570
|
const monitored = getMonitoredProjects();
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
if (!resolvedRoots.some(root => monitored.some(p => pathContains(p, root)))) {
|
|
1537
|
-
logDrop('project_filter', { type: primary.type, source: primary.source, session_id: primary.session_id, project_path: primary.project_path, monitored });
|
|
1538
|
-
process.exit(0);
|
|
1539
|
-
}
|
|
1571
|
+
// isProjectMonitored matches the primary project_path AND any Cursor
|
|
1572
|
+
// workspace_roots, each as-is and mojibake-recovered (Cursor 3.2.x+ Windows
|
|
1573
|
+
// CP1251/CP1252 paths). It returns true when no filter / no project_path.
|
|
1574
|
+
if (!isProjectMonitored(primary.project_path, event.workspace_roots, monitored)) {
|
|
1575
|
+
logDrop('project_filter', { type: primary.type, source: primary.source, session_id: primary.session_id, project_path: primary.project_path, monitored });
|
|
1576
|
+
process.exit(0);
|
|
1540
1577
|
}
|
|
1541
1578
|
|
|
1542
1579
|
// Resolve identity: git first, then fall back to event payload (e.g. Cursor's user_email)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mojibake recovery for "double UTF-8" corruption from Cursor 3.2.x+ on Windows.
|
|
3
|
+
*
|
|
4
|
+
* CLIENT copy. This is a verbatim port of `server/utils/mojibake-fix.js` so the
|
|
5
|
+
* client-side project_filter can recover a mojibake `project_path` for the MATCH
|
|
6
|
+
* DECISION ONLY (the raw payload is shipped unchanged — the server stays the
|
|
7
|
+
* canonical recoverer on ingest). The client bundle (~/.ai-lens/client/) cannot
|
|
8
|
+
* import from server/, and the server image is esbuild-bundled without client/,
|
|
9
|
+
* so the two copies are kept identical by a parity test
|
|
10
|
+
* (test/client/mojibake-fix-parity.test.js) rather than a shared import.
|
|
11
|
+
*
|
|
12
|
+
* Two patterns observed in production:
|
|
13
|
+
* - Pattern B (CP1252 -> UTF-8): UTF-8 bytes (D0 95 = "Е") read as
|
|
14
|
+
* Windows-1252 ("Е") and re-encoded as UTF-8. English/Western
|
|
15
|
+
* Windows locales.
|
|
16
|
+
* - Pattern A (CP1251 -> UTF-8): same, but read as Windows-1251
|
|
17
|
+
* (D0 9A = "К" -> "Рљ"). Russian Windows locales.
|
|
18
|
+
*
|
|
19
|
+
* Recovery: encode each char back to its codepage byte, then strict-decode as
|
|
20
|
+
* UTF-8. Conservative -- only accepts the recovered string if it contains
|
|
21
|
+
* strictly more "plain" Cyrillic (a-z A-Z e E in Cyrillic) than the input.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// CP1251 byte -> Unicode code point. -1 = undefined byte.
|
|
25
|
+
const CP1251_FROM_BYTE = (() => {
|
|
26
|
+
const t = new Array(256);
|
|
27
|
+
for (let i = 0; i < 0x80; i++) t[i] = i;
|
|
28
|
+
const upper = [
|
|
29
|
+
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021,
|
|
30
|
+
0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
|
|
31
|
+
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
|
|
32
|
+
-1, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
|
|
33
|
+
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7,
|
|
34
|
+
0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
|
|
35
|
+
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7,
|
|
36
|
+
0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
|
37
|
+
];
|
|
38
|
+
for (let i = 0; i < upper.length; i++) t[0x80 + i] = upper[i];
|
|
39
|
+
for (let i = 0; i < 64; i++) t[0xC0 + i] = 0x0410 + i;
|
|
40
|
+
return t;
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
// CP1252 byte -> Unicode. 0x80-0x9F have a few unused slots; rest is identity.
|
|
44
|
+
const CP1252_FROM_BYTE = (() => {
|
|
45
|
+
const t = new Array(256);
|
|
46
|
+
for (let i = 0; i < 256; i++) t[i] = i;
|
|
47
|
+
const upper = [
|
|
48
|
+
0x20AC, -1, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
|
|
49
|
+
0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, -1, 0x017D, -1,
|
|
50
|
+
-1, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
|
|
51
|
+
0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, -1, 0x017E, 0x0178,
|
|
52
|
+
];
|
|
53
|
+
for (let i = 0; i < upper.length; i++) if (upper[i] !== -1) t[0x80 + i] = upper[i];
|
|
54
|
+
return t;
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
// Reverse maps: Unicode code point -> byte.
|
|
58
|
+
function buildReverse(forward) {
|
|
59
|
+
const m = new Map();
|
|
60
|
+
for (let b = 0; b < 256; b++) if (forward[b] !== -1) m.set(forward[b], b);
|
|
61
|
+
return m;
|
|
62
|
+
}
|
|
63
|
+
const CP1251_TO_BYTE = buildReverse(CP1251_FROM_BYTE);
|
|
64
|
+
const CP1252_TO_BYTE = buildReverse(CP1252_FROM_BYTE);
|
|
65
|
+
|
|
66
|
+
function encodeWith(str, reverseMap) {
|
|
67
|
+
const bytes = Buffer.alloc(str.length);
|
|
68
|
+
for (let i = 0; i < str.length; i++) {
|
|
69
|
+
const code = str.charCodeAt(i);
|
|
70
|
+
const b = reverseMap.get(code);
|
|
71
|
+
if (b === undefined) return null;
|
|
72
|
+
bytes[i] = b;
|
|
73
|
+
}
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Lossy variant: substitutes 0x3F ('?') for unmappable chars. Used by the
|
|
78
|
+
// loose-decode fallback when strict encode fails because Cursor inserted
|
|
79
|
+
// a stray non-codepage char (e.g., '–' EM-DASH from autocorrect).
|
|
80
|
+
function encodeWithLossy(str, reverseMap) {
|
|
81
|
+
const bytes = Buffer.alloc(str.length);
|
|
82
|
+
for (let i = 0; i < str.length; i++) {
|
|
83
|
+
const code = str.charCodeAt(i);
|
|
84
|
+
const b = reverseMap.get(code);
|
|
85
|
+
bytes[i] = b !== undefined ? b : 0x3F;
|
|
86
|
+
}
|
|
87
|
+
return bytes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function strictUtf8Decode(buf) {
|
|
91
|
+
try {
|
|
92
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function looseUtf8Decode(buf) {
|
|
99
|
+
// Non-fatal: invalid byte sequences become U+FFFD replacement chars.
|
|
100
|
+
return new TextDecoder('utf-8').decode(buf);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countReplacements(str) {
|
|
104
|
+
let n = 0;
|
|
105
|
+
for (let i = 0; i < str.length; i++) if (str.charCodeAt(i) === 0xFFFD) n++;
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const PLAIN_CYRILLIC_RE = /[А-яЁё]/g;
|
|
110
|
+
// Mojibeka marker chars that real Russian/English text never contains:
|
|
111
|
+
// - Latin1 supplement (À-ÿ) — Pattern B leaves these littered throughout.
|
|
112
|
+
// - Non-Russian Cyrillic supplement (Ѐ-Џ, ѐ-џ, Ґ-ӿ) — Pattern A's signature.
|
|
113
|
+
const NOISE_RE = /[À-ÿЀ-Џѐ-џҐ-ӿ]/g;
|
|
114
|
+
function countMatches(re, str) {
|
|
115
|
+
const m = str.match(re);
|
|
116
|
+
return m ? m.length : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Marker A: any latin1-supplement letter (À-ÿ) -- these almost never
|
|
120
|
+
// appear in our event data outside of Pattern-B mojibeka.
|
|
121
|
+
// Marker B: standard Russian Cyrillic (А-яЁё) immediately
|
|
122
|
+
// followed by a non-Russian Cyrillic supplement (Ѐ-Џѐ-џҐ-ӿ).
|
|
123
|
+
// Plain Russian text never produces such adjacency, but Pattern-A mojibeka
|
|
124
|
+
// does (e.g., "Рљ" = mojibeka of "К").
|
|
125
|
+
const MOJIBAKE_HINT_RE = /[À-ÿ]|[А-яЁё][Ѐ-Џѐ-џҐ-ӿ]/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detect double-UTF-8 mojibeka (CP1252 or CP1251 flavour) and recover the
|
|
129
|
+
* original Cyrillic text. Returns the input unchanged if no improvement.
|
|
130
|
+
*/
|
|
131
|
+
export function recoverMojibake(str) {
|
|
132
|
+
if (typeof str !== 'string' || str.length < 2) return str;
|
|
133
|
+
if (!MOJIBAKE_HINT_RE.test(str)) return str;
|
|
134
|
+
|
|
135
|
+
const baseNoise = countMatches(NOISE_RE, str);
|
|
136
|
+
if (baseNoise === 0) return str;
|
|
137
|
+
|
|
138
|
+
for (const reverseMap of [CP1252_TO_BYTE, CP1251_TO_BYTE]) {
|
|
139
|
+
const bytes = encodeWith(str, reverseMap);
|
|
140
|
+
if (!bytes) continue;
|
|
141
|
+
const decoded = strictUtf8Decode(bytes);
|
|
142
|
+
if (decoded == null) continue;
|
|
143
|
+
// Accept if the round-trip strictly drops noise marker chars AND yields
|
|
144
|
+
// some real Russian content. Pure-mojibeka strings collapse 50/50
|
|
145
|
+
// markers→letters; partially mojibeked strings still see a strict drop.
|
|
146
|
+
const newNoise = countMatches(NOISE_RE, decoded);
|
|
147
|
+
const newCyrillic = countMatches(PLAIN_CYRILLIC_RE, decoded);
|
|
148
|
+
if (newNoise < baseNoise && newCyrillic > 0) return decoded;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Loose-decode fallback. Triggers only when strict failed AND the input
|
|
152
|
+
// is densely mojibeked (>=5 marker chars). Cursor 3.2.x on Windows can
|
|
153
|
+
// drop the second byte of a 2-byte UTF-8 sequence (e.g., capslock + space
|
|
154
|
+
// shortcut), leaving "Р " instead of "Ри" — strict UTF-8 rejects, but
|
|
155
|
+
// non-fatal decode recovers the rest with a U+FFFD where the byte was lost.
|
|
156
|
+
// Tight thresholds keep clean Cyrillic, Ukrainian, and partially-corrupted
|
|
157
|
+
// text untouched: they all fail the >=10-cyrillic / <=5%-replacements
|
|
158
|
+
// / <=10%-residual-noise gate.
|
|
159
|
+
if (baseNoise >= 5) {
|
|
160
|
+
for (const reverseMap of [CP1252_TO_BYTE, CP1251_TO_BYTE]) {
|
|
161
|
+
const bytes = encodeWithLossy(str, reverseMap);
|
|
162
|
+
const decoded = looseUtf8Decode(bytes);
|
|
163
|
+
const newNoise = countMatches(NOISE_RE, decoded);
|
|
164
|
+
const newCyrillic = countMatches(PLAIN_CYRILLIC_RE, decoded);
|
|
165
|
+
const replacements = countReplacements(decoded);
|
|
166
|
+
if (newCyrillic >= 10
|
|
167
|
+
&& newNoise <= baseNoise * 0.1
|
|
168
|
+
&& replacements * 20 <= newCyrillic) {
|
|
169
|
+
return decoded;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return str;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Recursively walk an object and recover mojibeka in every leaf string.
|
|
179
|
+
* Mirrors `redactObject` (server/utils/redact.js): depth-guarded, returns a
|
|
180
|
+
* new object/array; primitives and non-objects pass through.
|
|
181
|
+
*/
|
|
182
|
+
export function walkAndFix(value, depth = 0) {
|
|
183
|
+
if (typeof value === 'string') return recoverMojibake(value);
|
|
184
|
+
if (depth >= 100) return value;
|
|
185
|
+
if (Array.isArray(value)) return value.map(v => walkAndFix(v, depth + 1));
|
|
186
|
+
if (value && typeof value === 'object') {
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const [k, v] of Object.entries(value)) out[k] = walkAndFix(v, depth + 1);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|