@echomem/mcp 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/city/_live.html +37 -0
- package/dist/city/_serve.mjs +45 -0
- package/dist/city/card-data.json +15 -0
- package/dist/city/city-data.json +248 -0
- package/dist/city/echo-ai-city-only.html +1254 -0
- package/dist/city/echo-ai-city-only.template.html +1254 -0
- package/dist/city/echo-extraction-plate.html +330 -0
- package/dist/city/generate-echo-city-only.mjs +112 -0
- package/dist/city/vendor/OrbitControls.js +1417 -0
- package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +8139 -0
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +6 -0
- package/dist/forensics.js +41 -5
- package/dist/migrate.js +46 -4
- package/dist/setup-page.js +768 -266
- package/dist/setup.js +64 -4
- package/package.json +2 -2
package/dist/forensics.js
CHANGED
|
@@ -17,7 +17,26 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
20
21
|
import { eachLine, walk } from "./report.js";
|
|
22
|
+
/** Fast, safe per-repo commit count since a date. Metadata only (no diffs) so it stays cheap;
|
|
23
|
+
* `rev-list --count` is git-indexed. Any failure (not a repo, git missing, timeout) → 0. */
|
|
24
|
+
function gitCommitCount(cwd, sinceIso) {
|
|
25
|
+
if (!cwd)
|
|
26
|
+
return 0;
|
|
27
|
+
try {
|
|
28
|
+
const args = ["-C", cwd, "rev-list", "--count"];
|
|
29
|
+
if (sinceIso)
|
|
30
|
+
args.push(`--since=${sinceIso}`);
|
|
31
|
+
args.push("HEAD");
|
|
32
|
+
const out = execFileSync("git", args, { timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
33
|
+
const n = parseInt(out, 10);
|
|
34
|
+
return Number.isFinite(n) ? n : 0;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
21
40
|
// ---------------------------------------------------------------------------
|
|
22
41
|
// Constants (named on purpose — no magic numbers downstream)
|
|
23
42
|
// ---------------------------------------------------------------------------
|
|
@@ -85,7 +104,7 @@ function round(value, digits = 1) {
|
|
|
85
104
|
function tokensFromChars(chars = 0) {
|
|
86
105
|
return Math.round((chars || 0) / CHARS_PER_TOKEN);
|
|
87
106
|
}
|
|
88
|
-
function repoLabel(cwd) {
|
|
107
|
+
export function repoLabel(cwd) {
|
|
89
108
|
if (!cwd)
|
|
90
109
|
return "home";
|
|
91
110
|
const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
|
|
@@ -172,9 +191,11 @@ class Forensics {
|
|
|
172
191
|
const name = repoLabel(cwd);
|
|
173
192
|
let r = this.repos.get(name);
|
|
174
193
|
if (!r) {
|
|
175
|
-
r = { name, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [] };
|
|
194
|
+
r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [] };
|
|
176
195
|
this.repos.set(name, r);
|
|
177
196
|
}
|
|
197
|
+
if (cwd && !r.cwd)
|
|
198
|
+
r.cwd = cwd; // remember a real working dir for this repo (for the git pass)
|
|
178
199
|
return r;
|
|
179
200
|
}
|
|
180
201
|
fileRecord(p, repo) {
|
|
@@ -201,7 +222,7 @@ class Forensics {
|
|
|
201
222
|
this.allSessionIds.add(id);
|
|
202
223
|
}
|
|
203
224
|
/** assistant-turn token usage (already split into cold/cacheWrite/cacheRead/output). */
|
|
204
|
-
recordUsage(model, cwd, session, u) {
|
|
225
|
+
recordUsage(model, cwd, session, u, provider) {
|
|
205
226
|
const m = this.modelBucket(model);
|
|
206
227
|
m.messages += 1;
|
|
207
228
|
m.cold += u.cold;
|
|
@@ -217,6 +238,12 @@ class Forensics {
|
|
|
217
238
|
r.cacheWrite += u.cacheWrite;
|
|
218
239
|
r.cacheRead += u.cacheRead;
|
|
219
240
|
r.output += u.output;
|
|
241
|
+
// per-repo provider split → drives the city's per-cube colour (Codex green / Claude orange)
|
|
242
|
+
const turnTokens = u.cold + u.cacheWrite + u.cacheRead + u.output;
|
|
243
|
+
if (provider === "codex")
|
|
244
|
+
r.codexTokens += turnTokens;
|
|
245
|
+
else
|
|
246
|
+
r.claudeTokens += turnTokens;
|
|
220
247
|
}
|
|
221
248
|
}
|
|
222
249
|
recordEdit(target, cwd) {
|
|
@@ -369,9 +396,13 @@ class Forensics {
|
|
|
369
396
|
const allUserTimestamps = [];
|
|
370
397
|
const dayAttention = new Map();
|
|
371
398
|
const repoOut = [];
|
|
399
|
+
const sinceIso = this.minTs != null ? new Date(this.minTs).toISOString() : null;
|
|
400
|
+
let totalCommits = 0;
|
|
372
401
|
for (const repo of this.repos.values()) {
|
|
373
402
|
allUserTimestamps.push(...repo.userTimestamps);
|
|
374
403
|
const wb = this.workBlocks(repo.userTimestamps);
|
|
404
|
+
const commits = gitCommitCount(repo.cwd, sinceIso); // fast git pass: commits landed in this repo during the scan window
|
|
405
|
+
totalCommits += commits;
|
|
375
406
|
repoOut.push({
|
|
376
407
|
name: repo.name, sessions: repo.sessions.size, assistantMessages: repo.assistantMessages,
|
|
377
408
|
attentionHours: round(wb.attentionHours, 1), activeAttentionDays: wb.activeDays,
|
|
@@ -380,6 +411,9 @@ class Forensics {
|
|
|
380
411
|
tokens: repo.cold + repo.cacheWrite + repo.cacheRead + repo.output,
|
|
381
412
|
cost: round(costOf({ cold: repo.cold, cacheWrite: repo.cacheWrite, cacheRead: repo.cacheRead, output: repo.output }, CLAUDE_DEFAULT), 2),
|
|
382
413
|
reads: repo.reads, rereads: repo.rereads, staleRereads: repo.staleRereads,
|
|
414
|
+
dominantProvider: repo.codexTokens >= repo.claudeTokens ? "codex" : "claude",
|
|
415
|
+
codexTokens: repo.codexTokens, claudeTokens: repo.claudeTokens,
|
|
416
|
+
commits,
|
|
383
417
|
});
|
|
384
418
|
for (const b of wb.blocks) {
|
|
385
419
|
const d = localDay(b.start);
|
|
@@ -445,6 +479,7 @@ class Forensics {
|
|
|
445
479
|
sessionCount: this.allSessionIds.size,
|
|
446
480
|
repoCount: repoOut.length,
|
|
447
481
|
activeDays: activeAttentionDays,
|
|
482
|
+
totalCommits,
|
|
448
483
|
totalTokens, totalInputTokens: totalInput, coldInputTokens: cold, cacheWriteTokens: cacheWrite,
|
|
449
484
|
cacheReadTokens: cacheRead, outputTokens: output,
|
|
450
485
|
inputPct: totalTokens ? Math.round((totalInput / totalTokens) * 100) : 0,
|
|
@@ -493,7 +528,7 @@ function feedClaude(file, eng) {
|
|
|
493
528
|
eng.recordUsage(o.message.model || "unknown", cwd, session, {
|
|
494
529
|
cold: u.input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0,
|
|
495
530
|
cacheRead: u.cache_read_input_tokens || 0, output: u.output_tokens || 0,
|
|
496
|
-
});
|
|
531
|
+
}, "claude");
|
|
497
532
|
const blocks = Array.isArray(o.message.content) ? o.message.content : [];
|
|
498
533
|
for (const b of blocks) {
|
|
499
534
|
if (b?.type !== "tool_use")
|
|
@@ -626,8 +661,9 @@ function replayFile(eng, fe) {
|
|
|
626
661
|
if (fe.lastTs != null)
|
|
627
662
|
eng.noteTs(fe.lastTs);
|
|
628
663
|
eng.noteSession(fe.session);
|
|
664
|
+
const provider = fe.source === "codex" ? "codex" : "claude";
|
|
629
665
|
for (const u of fe.usage)
|
|
630
|
-
eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output });
|
|
666
|
+
eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output }, provider);
|
|
631
667
|
for (const e of fe.ev) {
|
|
632
668
|
if (e.t === "m")
|
|
633
669
|
eng.recordUserMsg(fe.cwd, fe.session, e.ms);
|
package/dist/migrate.js
CHANGED
|
@@ -249,13 +249,16 @@ function hasClaudeText(content) {
|
|
|
249
249
|
function fastSessionInfo(file, source) {
|
|
250
250
|
let conversationKey = `${source === "codex" ? "codex" : "claude-code"}:${sha16(file)}`;
|
|
251
251
|
let hasTextTurn = false;
|
|
252
|
+
let hasRealKey = false;
|
|
252
253
|
for (const obj of initialJsonObjects(file)) {
|
|
253
254
|
if (!isRecord(obj))
|
|
254
255
|
continue;
|
|
255
256
|
if (source === "codex" && obj.type === "session_meta") {
|
|
256
257
|
const payload = isRecord(obj.payload) ? obj.payload : {};
|
|
257
|
-
if (typeof payload.id === "string" && payload.id)
|
|
258
|
+
if (typeof payload.id === "string" && payload.id) {
|
|
258
259
|
conversationKey = `codex:${payload.id}`;
|
|
260
|
+
hasRealKey = true;
|
|
261
|
+
}
|
|
259
262
|
continue;
|
|
260
263
|
}
|
|
261
264
|
if (source === "codex") {
|
|
@@ -268,6 +271,7 @@ function fastSessionInfo(file, source) {
|
|
|
268
271
|
}
|
|
269
272
|
if (source === "claude-code" && typeof obj.sessionId === "string" && obj.sessionId) {
|
|
270
273
|
conversationKey = `claude-code:${obj.sessionId}`;
|
|
274
|
+
hasRealKey = true;
|
|
271
275
|
}
|
|
272
276
|
if (source === "claude-code" && (obj.type === "user" || obj.type === "assistant")) {
|
|
273
277
|
const message = isRecord(obj.message) ? obj.message : {};
|
|
@@ -275,7 +279,7 @@ function fastSessionInfo(file, source) {
|
|
|
275
279
|
hasTextTurn = true;
|
|
276
280
|
}
|
|
277
281
|
}
|
|
278
|
-
return { conversationKey, hasTextTurn };
|
|
282
|
+
return { conversationKey, hasTextTurn, hasRealKey };
|
|
279
283
|
}
|
|
280
284
|
function fastSessionEntries(opts = {}) {
|
|
281
285
|
const out = [];
|
|
@@ -283,14 +287,19 @@ function fastSessionEntries(opts = {}) {
|
|
|
283
287
|
for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
|
|
284
288
|
const stat = statSafe(filePath);
|
|
285
289
|
const info = fastSessionInfo(filePath, "codex");
|
|
286
|
-
if (
|
|
290
|
+
// Include if we found text OR a real session id (big sessions can have their first text turn beyond
|
|
291
|
+
// the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
|
|
292
|
+
// We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
|
|
293
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
287
294
|
out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
288
295
|
}
|
|
289
296
|
const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
|
|
290
297
|
for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
291
298
|
const stat = statSafe(filePath);
|
|
292
299
|
const info = fastSessionInfo(filePath, "claude-code");
|
|
293
|
-
|
|
300
|
+
// Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
|
|
301
|
+
// while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
|
|
302
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
294
303
|
out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
295
304
|
}
|
|
296
305
|
return out;
|
|
@@ -759,6 +768,39 @@ export function discoverMigratableSessions(opts = {}) {
|
|
|
759
768
|
claudeCount: sessions.length - codexCount,
|
|
760
769
|
};
|
|
761
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Targeted sizing for extraction start. The fast (stat-only) scan already knows WHICH sessions are
|
|
773
|
+
* pending — by conversation key + file path — without reading any content. This assembles full content
|
|
774
|
+
* for ONLY those pending sessions, never the whole local history. That keeps "start extraction" fast even
|
|
775
|
+
* when a user has gigabytes of already-processed logs (reading all of them just to prepare a few new jobs
|
|
776
|
+
* is what made sizing hang for 30s+).
|
|
777
|
+
*
|
|
778
|
+
* `processedKeys` (from the account import-status check) yields the accurate pending set; without it we
|
|
779
|
+
* fall back to the local-ledger pending. Either way we read at most the pending files, not the archive.
|
|
780
|
+
*/
|
|
781
|
+
export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
|
|
782
|
+
const fast = discoverMigratableFastDiscovery(opts);
|
|
783
|
+
const filtered = processedKeys ? applyFastAccountImportStatus(fast, processedKeys, opts) : fast;
|
|
784
|
+
const pending = [];
|
|
785
|
+
for (const entry of filtered.pending) {
|
|
786
|
+
const s = entry.source === "codex" ? assembleCodex(entry.filePath) : assembleClaude(entry.filePath);
|
|
787
|
+
if (s)
|
|
788
|
+
pending.push(s);
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
sessions: pending,
|
|
792
|
+
pending,
|
|
793
|
+
pendingTotal: filtered.pendingTotal,
|
|
794
|
+
alreadyMigrated: filtered.alreadyMigrated,
|
|
795
|
+
skippedActive: filtered.skippedActive,
|
|
796
|
+
limited: filtered.limited,
|
|
797
|
+
codexCount: filtered.codexCount,
|
|
798
|
+
claudeCount: filtered.claudeCount,
|
|
799
|
+
accountChecked: filtered.accountChecked,
|
|
800
|
+
accountCheckFailed: filtered.accountCheckFailed,
|
|
801
|
+
accountCheckUnavailable: filtered.accountCheckUnavailable,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
762
804
|
export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
|
|
763
805
|
const selectableSessions = opts.includeActive
|
|
764
806
|
? discovery.sessions
|